Beta/venv/lib/python3.12/site-packages/huggingface_hub/cli/jobs.py

1173 lines
40 KiB
Python
Raw Normal View History

2026-02-06 22:23:20 +01:00
# Copyright 2025 The HuggingFace Team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Contains commands to interact with jobs on the Hugging Face Hub.
Usage:
# run a job
hf jobs run <image> <command>
# List running or completed jobs
2026-06-16 17:09:34 +00:00
hf jobs ps [-a] [-f key=value]
2026-02-06 22:23:20 +01:00
2026-06-16 17:09:34 +00:00
# Print logs from a job (non-blocking)
2026-02-06 22:23:20 +01:00
hf jobs logs <job-id>
2026-06-16 17:09:34 +00:00
# Stream logs from a job (blocking, like `docker logs -f`)
hf jobs logs -f <job-id>
2026-02-06 22:23:20 +01:00
# Stream resources usage stats and metrics from a job
hf jobs stats <job-id>
# Inspect detailed information about a job
hf jobs inspect <job-id>
# Cancel a running job
hf jobs cancel <job-id>
# List available hardware options
hf jobs hardware
# Run a UV script
hf jobs uv run <script>
# Schedule a job
hf jobs scheduled run <schedule> <image> <command>
# List scheduled jobs
2026-06-16 17:09:34 +00:00
hf jobs scheduled ps [-a] [-f key=value]
2026-02-06 22:23:20 +01:00
# Inspect a scheduled job
hf jobs scheduled inspect <scheduled_job_id>
# Suspend a scheduled job
hf jobs scheduled suspend <scheduled_job_id>
# Resume a scheduled job
hf jobs scheduled resume <scheduled_job_id>
# Delete a scheduled job
hf jobs scheduled delete <scheduled_job_id>
"""
import multiprocessing
import multiprocessing.pool
2026-06-16 17:09:34 +00:00
import shutil
2026-02-06 22:23:20 +01:00
import time
2026-06-16 17:09:34 +00:00
from collections.abc import Callable, Iterable
from fnmatch import fnmatch
2026-02-06 22:23:20 +01:00
from queue import Empty, Queue
2026-06-16 17:09:34 +00:00
from typing import Annotated, Any, TypeVar
2026-02-06 22:23:20 +01:00
import typer
2026-06-16 17:09:34 +00:00
from huggingface_hub import JobHardware
from huggingface_hub.errors import CLIError, HfHubHTTPError
2026-02-06 22:23:20 +01:00
from huggingface_hub.utils import logging
from huggingface_hub.utils._cache_manager import _format_size
2026-06-16 17:09:34 +00:00
from huggingface_hub.utils._parsing import format_duration
from ._cli_utils import (
EnvFileOpt,
EnvOpt,
SecretsFileOpt,
SecretsOpt,
SoftChoice,
TokenOpt,
VolumesOpt,
get_hf_api,
parse_env_map,
parse_volumes,
typer_factory,
)
from ._output import _dataclass_to_dict, out
2026-02-06 22:23:20 +01:00
logger = logging.get_logger(__name__)
2026-06-16 17:09:34 +00:00
def _parse_namespace_from_job_id(job_id: str, namespace: str | None) -> tuple[str, str | None]:
"""Extract namespace from job_id if provided in 'namespace/job_id' format.
Allows users to pass job IDs copied from the Hub UI (e.g. 'username/job_id')
instead of only bare job IDs. If the namespace is also provided explicitly via
--namespace and conflicts, a CLIError is raised.
"""
if not job_id:
raise CLIError("Job ID cannot be empty.")
if job_id.count("/") > 1:
raise CLIError(f"Job ID must be in the form 'job_id' or 'namespace/job_id': '{job_id}'.")
if "/" not in job_id:
return job_id, namespace
extracted_namespace, parsed_job_id = job_id.split("/", 1)
if not extracted_namespace or not parsed_job_id:
raise CLIError(f"Job ID must be in the form 'job_id' or 'namespace/job_id': '{job_id}'.")
if namespace is not None and namespace != extracted_namespace:
raise CLIError(
f"Conflicting namespace: got --namespace='{namespace}' but job ID implies namespace='{extracted_namespace}'"
)
return parsed_job_id, extracted_namespace
2026-02-06 22:23:20 +01:00
STATS_UPDATE_MIN_INTERVAL = 0.1 # we set a limit here since there is one update per second per job
# Common job-related options
ImageArg = Annotated[
str,
typer.Argument(
help="The Docker image to use.",
),
]
ImageOpt = Annotated[
2026-06-16 17:09:34 +00:00
str | None,
2026-02-06 22:23:20 +01:00
typer.Option(
help="Use a custom Docker image with `uv` installed.",
),
]
FlavorOpt = Annotated[
2026-06-16 17:09:34 +00:00
str | None,
2026-02-06 22:23:20 +01:00
typer.Option(
2026-06-16 17:09:34 +00:00
help="Flavor for the hardware. Run 'hf jobs hardware' to list available flavors. Defaults to `cpu-basic`.",
click_type=SoftChoice(JobHardware),
2026-02-06 22:23:20 +01:00
),
]
2026-06-16 17:09:34 +00:00
LabelsOpt = Annotated[
list[str] | None,
2026-02-06 22:23:20 +01:00
typer.Option(
2026-06-16 17:09:34 +00:00
"-l",
"--label",
help="Set labels. E.g. --label KEY=VALUE or --label LABEL",
2026-02-06 22:23:20 +01:00
),
]
TimeoutOpt = Annotated[
2026-06-16 17:09:34 +00:00
str | None,
2026-02-06 22:23:20 +01:00
typer.Option(
help="Max duration: int/float with s (seconds, default), m (minutes), h (hours) or d (days).",
),
]
DetachOpt = Annotated[
bool,
typer.Option(
"-d",
"--detach",
help="Run the Job in the background and print the Job ID.",
),
]
NamespaceOpt = Annotated[
2026-06-16 17:09:34 +00:00
str | None,
2026-02-06 22:23:20 +01:00
typer.Option(
help="The namespace where the job will be running. Defaults to the current user's namespace.",
),
]
2026-06-16 17:09:34 +00:00
ExposeOpt = Annotated[
list[int] | None,
typer.Option(
"--expose",
help="Expose a container port through the jobs proxy. Repeat the flag for multiple ports (e.g. `--expose 8000 --expose 8001`). Each exposed port is reachable on the public jobs domain; access requires an HF token with read access to the job's namespace.",
),
]
2026-02-06 22:23:20 +01:00
WithOpt = Annotated[
2026-06-16 17:09:34 +00:00
list[str] | None,
2026-02-06 22:23:20 +01:00
typer.Option(
"--with",
help="Run with the given packages installed",
),
]
PythonOpt = Annotated[
2026-06-16 17:09:34 +00:00
str | None,
2026-02-06 22:23:20 +01:00
typer.Option(
"-p",
"--python",
help="The Python interpreter to use for the run environment",
),
]
SuspendOpt = Annotated[
2026-06-16 17:09:34 +00:00
bool | None,
2026-02-06 22:23:20 +01:00
typer.Option(
help="Suspend (pause) the scheduled Job",
),
]
ConcurrencyOpt = Annotated[
2026-06-16 17:09:34 +00:00
bool | None,
2026-02-06 22:23:20 +01:00
typer.Option(
help="Allow multiple instances of this Job to run concurrently",
),
]
ScheduleArg = Annotated[
str,
typer.Argument(
help="One of annually, yearly, monthly, weekly, daily, hourly, or a CRON schedule expression.",
),
]
ScriptArg = Annotated[
str,
typer.Argument(
help="UV script to run (local file or URL)",
),
]
ScriptArgsArg = Annotated[
2026-06-16 17:09:34 +00:00
list[str] | None,
2026-02-06 22:23:20 +01:00
typer.Argument(
help="Arguments for the script",
),
]
2026-06-16 17:09:34 +00:00
2026-02-06 22:23:20 +01:00
CommandArg = Annotated[
list[str],
typer.Argument(
help="The command to run.",
),
]
JobIdArg = Annotated[
str,
typer.Argument(
2026-06-16 17:09:34 +00:00
help="Job ID (or 'namespace/job_id')",
2026-02-06 22:23:20 +01:00
),
]
JobIdsArg = Annotated[
2026-06-16 17:09:34 +00:00
list[str] | None,
2026-02-06 22:23:20 +01:00
typer.Argument(
2026-06-16 17:09:34 +00:00
help="Job IDs (or 'namespace/job_id')",
2026-02-06 22:23:20 +01:00
),
]
ScheduledJobIdArg = Annotated[
str,
typer.Argument(
2026-06-16 17:09:34 +00:00
help="Scheduled Job ID (or 'namespace/scheduled_job_id')",
2026-02-06 22:23:20 +01:00
),
]
jobs_cli = typer_factory(help="Run and manage Jobs on the Hub.")
2026-06-16 17:09:34 +00:00
@jobs_cli.command(
"run",
context_settings={"ignore_unknown_options": True},
examples=[
"hf jobs run python:3.12 python -c 'print(\"Hello!\")'",
"hf jobs run --detach python:3.12 python script.py",
"hf jobs run -e FOO=foo python:3.12 python script.py",
"hf jobs run --secrets HF_TOKEN python:3.12 python script.py",
"hf jobs run -v hf://org/my-model:/data -v hf://buckets/org/b:/mnt python:3.12 python script.py",
],
)
2026-02-06 22:23:20 +01:00
def jobs_run(
image: ImageArg,
command: CommandArg,
env: EnvOpt = None,
secrets: SecretsOpt = None,
2026-06-16 17:09:34 +00:00
label: LabelsOpt = None,
volume: VolumesOpt = None,
2026-02-06 22:23:20 +01:00
env_file: EnvFileOpt = None,
secrets_file: SecretsFileOpt = None,
flavor: FlavorOpt = None,
timeout: TimeoutOpt = None,
detach: DetachOpt = False,
2026-06-16 17:09:34 +00:00
expose: ExposeOpt = None,
2026-02-06 22:23:20 +01:00
namespace: NamespaceOpt = None,
token: TokenOpt = None,
) -> None:
2026-06-16 17:09:34 +00:00
"""Run a Job."""
env_map = parse_env_map(env, env_file)
secrets_map = parse_env_map(secrets, secrets_file)
2026-02-06 22:23:20 +01:00
api = get_hf_api(token=token)
job = api.run_job(
image=image,
command=command,
env=env_map,
secrets=secrets_map,
2026-06-16 17:09:34 +00:00
labels=_parse_labels_map(label),
volumes=parse_volumes(volume),
2026-02-06 22:23:20 +01:00
flavor=flavor,
timeout=timeout,
2026-06-16 17:09:34 +00:00
expose=expose,
2026-02-06 22:23:20 +01:00
namespace=namespace,
)
2026-06-16 17:09:34 +00:00
out.result("Job started", id=job.id, url=job.url)
if isinstance(job.status.expose_urls, list):
urls = "\n".join(f" {url}" for url in job.status.expose_urls)
out.hint(f"Exposed ports are reachable at (requires an HF token with read access to the job):\n{urls}")
2026-02-06 22:23:20 +01:00
if detach:
2026-06-16 17:09:34 +00:00
job_ref = f"{job.owner.name}/{job.id}"
out.hint(f"Use `hf jobs logs -f {job_ref}` to stream logs, or `hf jobs inspect {job_ref}` to check status.")
2026-02-06 22:23:20 +01:00
return
2026-06-16 17:09:34 +00:00
for log in api.fetch_job_logs(job_id=job.id, namespace=job.owner.name, follow=True):
out.text(log)
2026-02-06 22:23:20 +01:00
2026-06-16 17:09:34 +00:00
@jobs_cli.command(
"logs",
examples=[
"hf jobs logs <job_id>",
"hf jobs logs -f <job_id>",
"hf jobs logs --tail 20 <job_id>",
"hf jobs logs -f --tail 100 <job_id>",
],
)
2026-02-06 22:23:20 +01:00
def jobs_logs(
job_id: JobIdArg,
2026-06-16 17:09:34 +00:00
follow: Annotated[
bool,
typer.Option(
"-f",
"--follow",
help="Follow log output (stream until the job completes). Without this flag, only currently available logs are printed.",
),
] = False,
tail: Annotated[
int | None,
typer.Option(
"-n",
"--tail",
help="Number of lines to show from the end of the logs. When combined with --follow, starts streaming from the last N lines.",
),
] = None,
2026-02-06 22:23:20 +01:00
namespace: NamespaceOpt = None,
token: TokenOpt = None,
) -> None:
2026-06-16 17:09:34 +00:00
"""Fetch the logs of a Job.
By default, prints currently available logs and exits (non-blocking).
Use --follow/-f to stream logs in real-time until the job completes.
Use --tail/-n to limit the number of lines returned (server-side when supported).
Note: following exits when the log stream ends, regardless of whether the Job
succeeded or failed. Run `hf jobs inspect <job_id>` to check the final status.
"""
job_id, namespace = _parse_namespace_from_job_id(job_id, namespace)
2026-02-06 22:23:20 +01:00
api = get_hf_api(token=token)
2026-06-16 17:09:34 +00:00
try:
logs = api.fetch_job_logs(job_id=job_id, namespace=namespace, follow=follow, tail=tail)
for log in logs:
out.text(log)
if follow:
job_ref = f"{namespace}/{job_id}" if namespace else job_id
out.hint(
f"Stream ended. Run `hf jobs inspect {job_ref}` to check the final status (e.g. COMPLETED or ERROR)."
)
except HfHubHTTPError as e:
status = e.response.status_code if e.response is not None else None
if status == 404:
raise CLIError("Job not found. Please check the job ID.") from e
elif status == 403:
raise CLIError("Access denied. You may not have permission to view this job.") from e
else:
raise CLIError(f"Failed to fetch job logs: {e}") from e
2026-02-06 22:23:20 +01:00
2026-06-16 17:09:34 +00:00
def _matches_filters(job_properties: dict[str, str], filters: list[tuple[str, str, str]]) -> bool:
2026-02-06 22:23:20 +01:00
"""Check if scheduled job matches all specified filters."""
2026-06-16 17:09:34 +00:00
for key, op_str, pattern in filters:
value = job_properties.get(key)
if value is None:
if op_str == "!=":
continue
2026-02-06 22:23:20 +01:00
return False
2026-06-16 17:09:34 +00:00
match = fnmatch(value.lower(), pattern.lower())
if (op_str == "=" and not match) or (op_str == "!=" and match):
2026-02-06 22:23:20 +01:00
return False
return True
def _clear_line(n: int) -> None:
LINE_UP = "\033[1A"
LINE_CLEAR = "\x1b[2K"
for i in range(n):
print(LINE_UP, end=LINE_CLEAR)
def _get_jobs_stats_rows(
job_id: str, metrics_stream: Iterable[dict[str, Any]], table_headers: list[str]
2026-06-16 17:09:34 +00:00
) -> Iterable[tuple[bool, str, list[list[str | int]]]]:
2026-02-06 22:23:20 +01:00
for metrics in metrics_stream:
row = [
job_id,
f"{metrics['cpu_usage_pct']}%",
round(metrics["cpu_millicores"] / 1000.0, 1),
f"{round(100 * metrics['memory_used_bytes'] / metrics['memory_total_bytes'], 2)}%",
f"{_format_size(metrics['memory_used_bytes'])}B / {_format_size(metrics['memory_total_bytes'])}B",
f"{_format_size(metrics['rx_bps'])}bps / {_format_size(metrics['tx_bps'])}bps",
]
if metrics["gpus"] and isinstance(metrics["gpus"], dict):
rows = [row] + [[""] * len(row)] * (len(metrics["gpus"]) - 1)
for row, gpu_id in zip(rows, sorted(metrics["gpus"])):
gpu = metrics["gpus"][gpu_id]
row += [
f"{gpu['utilization']}%",
f"{round(100 * gpu['memory_used_bytes'] / gpu['memory_total_bytes'], 2)}%",
f"{_format_size(gpu['memory_used_bytes'])}B / {_format_size(gpu['memory_total_bytes'])}B",
]
else:
row += ["N/A"] * (len(table_headers) - len(row))
rows = [row]
yield False, job_id, rows
yield True, job_id, []
2026-06-16 17:09:34 +00:00
@jobs_cli.command("stats", examples=["hf jobs stats <job_id>"])
2026-02-06 22:23:20 +01:00
def jobs_stats(
job_ids: JobIdsArg = None,
namespace: NamespaceOpt = None,
token: TokenOpt = None,
) -> None:
2026-06-16 17:09:34 +00:00
"""Fetch the resource usage statistics and metrics of Jobs"""
if job_ids is not None:
parsed_ids = []
for job_id in job_ids:
job_id, namespace = _parse_namespace_from_job_id(job_id, namespace)
parsed_ids.append(job_id)
job_ids = parsed_ids
2026-02-06 22:23:20 +01:00
api = get_hf_api(token=token)
if namespace is None:
namespace = api.whoami()["name"]
if job_ids is None:
job_ids = [
job.id
for job in api.list_jobs(namespace=namespace)
if (job.status.stage if job.status else "UNKNOWN") in ("RUNNING", "UPDATING")
]
if len(job_ids) == 0:
2026-06-16 17:09:34 +00:00
out.text("No running jobs found")
2026-02-06 22:23:20 +01:00
return
table_headers = [
"JOB ID",
"CPU %",
"NUM CPU",
"MEM %",
"MEM USAGE",
"NET I/O",
"GPU UTIL %",
"GPU MEM %",
"GPU MEM USAGE",
]
2026-06-16 17:09:34 +00:00
try:
with multiprocessing.pool.ThreadPool(len(job_ids)) as pool:
rows_per_job_id: dict[str, list[list[str | int]]] = {}
for job_id in job_ids:
row: list[str | int] = [job_id]
row += ["-- / --" if ("/" in header or "USAGE" in header) else "--" for header in table_headers[1:]]
rows_per_job_id[job_id] = [row]
last_update_time = time.time()
total_rows = [row for job_id in rows_per_job_id for row in rows_per_job_id[job_id]]
# In-place refresh (cursor-up + clear) requires a fixed line count and layout —
# `out.table`'s mode-dependent formatting would break it.
print(_tabulate(total_rows, headers=table_headers))
kwargs_list = [
{
"job_id": job_id,
"metrics_stream": api.fetch_job_metrics(job_id=job_id, namespace=namespace),
"table_headers": table_headers,
}
for job_id in job_ids
]
for done, job_id, rows in iflatmap_unordered(pool, _get_jobs_stats_rows, kwargs_list=kwargs_list):
if done:
rows_per_job_id.pop(job_id, None)
else:
rows_per_job_id[job_id] = rows
now = time.time()
if now - last_update_time >= STATS_UPDATE_MIN_INTERVAL:
_clear_line(2 + len(total_rows))
total_rows = [row for job_id in rows_per_job_id for row in rows_per_job_id[job_id]]
print(_tabulate(total_rows, headers=table_headers))
last_update_time = now
except HfHubHTTPError as e:
status = e.response.status_code if e.response is not None else None
if status == 404:
raise CLIError("Job not found. Please check the job ID.") from e
elif status == 403:
raise CLIError("Access denied. You may not have permission to view this job.") from e
else:
raise CLIError(f"Failed to fetch job stats: {e}") from e
2026-02-06 22:23:20 +01:00
2026-06-16 17:09:34 +00:00
@jobs_cli.command("ps", examples=["hf jobs ps", "hf jobs ps -a"])
2026-02-06 22:23:20 +01:00
def jobs_ps(
all: Annotated[
bool,
typer.Option(
"-a",
"--all",
help="Show all Jobs (default shows just running)",
),
] = False,
namespace: NamespaceOpt = None,
token: TokenOpt = None,
filter: Annotated[
2026-06-16 17:09:34 +00:00
list[str] | None,
2026-02-06 22:23:20 +01:00
typer.Option(
"-f",
"--filter",
help="Filter output based on conditions provided (format: key=value)",
),
] = None,
) -> None:
2026-06-16 17:09:34 +00:00
"""List Jobs."""
api = get_hf_api(token=token)
jobs = api.list_jobs(namespace=namespace)
filters: list[tuple[str, str, str]] = []
labels_filters: list[tuple[str, str, str]] = []
for f in filter or []:
if f.startswith("label!=") or f.startswith("label="):
if f.startswith("label!="):
label_part = f[len("label!=") :]
if "=" in label_part:
out.warning(f"Ignoring invalid label filter format 'label!={label_part}'. Use label!=key format.")
continue
label_key, op, label_value = label_part, "!=", "*"
2026-02-06 22:23:20 +01:00
else:
2026-06-16 17:09:34 +00:00
label_part = f[len("label=") :]
if "=" in label_part:
label_key, label_value = label_part.split("=", 1)
else:
label_key, label_value = label_part, "*"
# Negate predicate in case of key!=value
if label_key.endswith("!"):
op = "!="
label_key = label_key[:-1]
else:
op = "="
labels_filters.append((label_key.lower(), op, label_value.lower()))
elif "=" in f:
key, value = f.split("=", 1)
# Negate predicate in case of key!=value
if key.endswith("!"):
op = "!="
key = key[:-1]
else:
op = "="
filters.append((key.lower(), op, value.lower()))
else:
out.warning(f"Ignoring invalid filter format '{f}'. Use key=value format.")
# Filter jobs (operating on JobInfo objects to preserve existing filter behavior)
filtered_jobs = []
for job in jobs:
status = job.status.stage if job.status else "UNKNOWN"
if not all and status not in ("RUNNING", "UPDATING"):
continue
image_or_space = job.docker_image or "N/A"
cmd = job.command or []
command_str = " ".join(cmd) if cmd else "N/A"
props = {"id": job.id, "image": image_or_space, "status": status.lower(), "command": command_str}
if not _matches_filters(props, filters):
continue
if not _matches_filters(job.labels or {}, labels_filters):
continue
filtered_jobs.append(job)
# Build display items. Augment the raw api dict with curated, table-friendly columns.
items: list[dict[str, Any]] = []
for job in filtered_jobs:
item = _dataclass_to_dict(job)
durations = item.get("durations") or {}
cmd = item.get("command") or []
item["job_id"] = item.get("id", "")
item["image/space"] = item.get("docker_image") or "N/A"
item["command"] = " ".join(cmd) if cmd else "N/A"
item["created"] = item["created_at"][:19].replace("T", " ") if item.get("created_at") else "N/A"
item["status"] = (item.get("status") or {}).get("stage", "UNKNOWN")
item["runtime"] = format_duration(durations.get("running_secs"))
items.append(item)
out.table(
items,
headers=["job_id", "image/space", "command", "created", "status", "runtime"],
id_key="job_id",
)
if not items:
if filters:
filters_msg = ", ".join(f"{k}{o}{v}" for k, o, v in filters)
out.text(f"No jobs matched filters: {filters_msg}")
elif not all and not labels_filters:
out.hint("No running jobs. Use `-a`/`--all` to include finished (and failed) jobs.")
2026-02-06 22:23:20 +01:00
2026-06-16 17:09:34 +00:00
@jobs_cli.command("hardware", examples=["hf jobs hardware"])
2026-02-06 22:23:20 +01:00
def jobs_hardware() -> None:
2026-06-16 17:09:34 +00:00
"""List available hardware options for Jobs"""
api = get_hf_api()
hardware_list = api.list_jobs_hardware()
items = []
for hw in hardware_list:
accelerator_info = ""
if hw.accelerator:
accelerator_info = f"{hw.accelerator.quantity}x {hw.accelerator.model} ({hw.accelerator.vram})"
cost_min = f"${hw.unit_cost_usd:.4f}" if hw.unit_cost_usd else "free"
cost_hour = f"${hw.unit_cost_usd * 60:.2f}" if hw.unit_cost_usd else "free"
items.append(
{
"name": hw.name,
"pretty name": hw.pretty_name,
"cpu": hw.cpu,
"ram": hw.ram,
"storage": hw.ephemeral_storage,
"accelerator": accelerator_info,
"cost/min": cost_min,
"cost/hour": cost_hour,
}
)
out.table(items)
out.hint("Use `hf jobs run --flavor <name> ...` to request a specific hardware flavor.")
2026-02-06 22:23:20 +01:00
2026-06-16 17:09:34 +00:00
@jobs_cli.command("inspect", examples=["hf jobs inspect <job_id>"])
2026-02-06 22:23:20 +01:00
def jobs_inspect(
job_ids: Annotated[
list[str],
typer.Argument(
2026-06-16 17:09:34 +00:00
help="Job IDs to inspect (or 'namespace/job_id')",
2026-02-06 22:23:20 +01:00
),
],
namespace: NamespaceOpt = None,
token: TokenOpt = None,
) -> None:
2026-06-16 17:09:34 +00:00
"""Display detailed information on one or more Jobs"""
parsed_ids = []
for job_id in job_ids:
job_id, namespace = _parse_namespace_from_job_id(job_id, namespace)
parsed_ids.append(job_id)
job_ids = parsed_ids
2026-02-06 22:23:20 +01:00
api = get_hf_api(token=token)
2026-06-16 17:09:34 +00:00
try:
jobs = [api.inspect_job(job_id=job_id, namespace=namespace) for job_id in job_ids]
except HfHubHTTPError as e:
status = e.response.status_code if e.response is not None else None
if status == 404:
raise CLIError("Job not found. Please check the job ID.") from e
elif status == 403:
raise CLIError("Access denied. You may not have permission to view this job.") from e
else:
raise CLIError(f"Failed to inspect job: {e}") from e
out.table([_dataclass_to_dict(job) for job in jobs])
2026-02-06 22:23:20 +01:00
2026-06-16 17:09:34 +00:00
@jobs_cli.command("cancel", examples=["hf jobs cancel <job_id>"])
2026-02-06 22:23:20 +01:00
def jobs_cancel(
job_id: JobIdArg,
namespace: NamespaceOpt = None,
token: TokenOpt = None,
) -> None:
2026-06-16 17:09:34 +00:00
"""Cancel a Job"""
job_id, namespace = _parse_namespace_from_job_id(job_id, namespace)
api = get_hf_api(token=token)
try:
api.cancel_job(job_id=job_id, namespace=namespace)
except HfHubHTTPError as e:
status = e.response.status_code if e.response is not None else None
if status == 404:
raise CLIError("Job not found. Please check the job ID.") from e
elif status == 403:
raise CLIError("Access denied. You may not have permission to cancel this job.") from e
else:
raise CLIError(f"Failed to cancel job: {e}") from e
out.result("Job cancelled", id=job_id)
@jobs_cli.command(
"labels",
examples=[
"hf jobs labels <job_id> --label env=prod --label team=ml",
"hf jobs labels <job_id> --clear",
],
)
def jobs_labels(
job_id: JobIdArg,
label: LabelsOpt = None,
clear: Annotated[bool, typer.Option("--clear", help="Remove all labels from the job.")] = False,
namespace: NamespaceOpt = None,
token: TokenOpt = None,
) -> None:
"""Update labels on a Job. Replaces all existing labels."""
if not label and not clear:
raise CLIError("Please set at least one label with --label. To remove all labels, pass --clear.")
if label and clear:
raise CLIError(
"Cannot set labels and clear them at the same time. Please use either --label or --clear, not both."
)
job_id, namespace = _parse_namespace_from_job_id(job_id, namespace)
labels = _parse_labels_map(label) or {}
2026-02-06 22:23:20 +01:00
api = get_hf_api(token=token)
2026-06-16 17:09:34 +00:00
job = api.update_job_labels(job_id=job_id, labels=labels, namespace=namespace)
out.result("Labels updated", id=job.id)
2026-02-06 22:23:20 +01:00
2026-06-16 17:09:34 +00:00
uv_app = typer_factory(help="Run UV scripts (Python with inline dependencies) on HF infrastructure.")
2026-02-06 22:23:20 +01:00
jobs_cli.add_typer(uv_app, name="uv")
@uv_app.command(
"run",
context_settings={"ignore_unknown_options": True},
2026-06-16 17:09:34 +00:00
examples=[
"hf jobs uv run my_script.py",
"hf jobs uv run --detach my_script.py",
"hf jobs uv run ml_training.py --flavor a10g-small",
"hf jobs uv run --with transformers train.py",
"hf jobs uv run -v hf://org/my-model:/data -v hf://buckets/org/b:/mnt script.py",
],
2026-02-06 22:23:20 +01:00
)
def jobs_uv_run(
script: ScriptArg,
script_args: ScriptArgsArg = None,
image: ImageOpt = None,
flavor: FlavorOpt = None,
env: EnvOpt = None,
secrets: SecretsOpt = None,
2026-06-16 17:09:34 +00:00
label: LabelsOpt = None,
volume: VolumesOpt = None,
2026-02-06 22:23:20 +01:00
env_file: EnvFileOpt = None,
secrets_file: SecretsFileOpt = None,
timeout: TimeoutOpt = None,
detach: DetachOpt = False,
2026-06-16 17:09:34 +00:00
expose: ExposeOpt = None,
2026-02-06 22:23:20 +01:00
namespace: NamespaceOpt = None,
token: TokenOpt = None,
with_: WithOpt = None,
python: PythonOpt = None,
) -> None:
2026-06-16 17:09:34 +00:00
"""Run a UV script (local file or URL) on HF infrastructure"""
env_map = parse_env_map(env, env_file)
secrets_map = parse_env_map(secrets, secrets_file)
2026-02-06 22:23:20 +01:00
api = get_hf_api(token=token)
job = api.run_uv_job(
script=script,
script_args=script_args or [],
dependencies=with_,
python=python,
image=image,
env=env_map,
secrets=secrets_map,
2026-06-16 17:09:34 +00:00
labels=_parse_labels_map(label),
volumes=parse_volumes(volume),
flavor=flavor,
2026-02-06 22:23:20 +01:00
timeout=timeout,
2026-06-16 17:09:34 +00:00
expose=expose,
2026-02-06 22:23:20 +01:00
namespace=namespace,
)
2026-06-16 17:09:34 +00:00
out.result("Job started", id=job.id, url=job.url)
if isinstance(job.status.expose_urls, list):
urls = "\n".join(f" {url}" for url in job.status.expose_urls)
out.hint(f"Exposed ports are reachable at (requires an HF token with read access to the job):\n{urls}")
2026-02-06 22:23:20 +01:00
if detach:
2026-06-16 17:09:34 +00:00
job_ref = f"{job.owner.name}/{job.id}"
out.hint(f"Use `hf jobs logs -f {job_ref}` to stream logs, or `hf jobs inspect {job_ref}` to check status.")
2026-02-06 22:23:20 +01:00
return
2026-06-16 17:09:34 +00:00
for log in api.fetch_job_logs(job_id=job.id, namespace=job.owner.name, follow=True):
out.text(log)
2026-02-06 22:23:20 +01:00
scheduled_app = typer_factory(help="Create and manage scheduled Jobs on the Hub.")
jobs_cli.add_typer(scheduled_app, name="scheduled")
2026-06-16 17:09:34 +00:00
@scheduled_app.command(
"run",
context_settings={"ignore_unknown_options": True},
examples=['hf jobs scheduled run "0 0 * * *" python:3.12 python script.py'],
)
2026-02-06 22:23:20 +01:00
def scheduled_run(
schedule: ScheduleArg,
image: ImageArg,
command: CommandArg,
suspend: SuspendOpt = None,
concurrency: ConcurrencyOpt = None,
env: EnvOpt = None,
secrets: SecretsOpt = None,
2026-06-16 17:09:34 +00:00
label: LabelsOpt = None,
volume: VolumesOpt = None,
2026-02-06 22:23:20 +01:00
env_file: EnvFileOpt = None,
secrets_file: SecretsFileOpt = None,
flavor: FlavorOpt = None,
timeout: TimeoutOpt = None,
2026-06-16 17:09:34 +00:00
expose: ExposeOpt = None,
2026-02-06 22:23:20 +01:00
namespace: NamespaceOpt = None,
token: TokenOpt = None,
) -> None:
2026-06-16 17:09:34 +00:00
"""Schedule a Job."""
env_map = parse_env_map(env, env_file)
secrets_map = parse_env_map(secrets, secrets_file)
2026-02-06 22:23:20 +01:00
api = get_hf_api(token=token)
scheduled_job = api.create_scheduled_job(
image=image,
command=command,
schedule=schedule,
suspend=suspend,
concurrency=concurrency,
env=env_map,
secrets=secrets_map,
2026-06-16 17:09:34 +00:00
labels=_parse_labels_map(label),
volumes=parse_volumes(volume),
2026-02-06 22:23:20 +01:00
flavor=flavor,
timeout=timeout,
2026-06-16 17:09:34 +00:00
expose=expose,
2026-02-06 22:23:20 +01:00
namespace=namespace,
)
2026-06-16 17:09:34 +00:00
out.result("Scheduled Job created", id=scheduled_job.id)
out.hint(f"Use `hf jobs scheduled inspect {scheduled_job.id}` to view its details.")
2026-02-06 22:23:20 +01:00
2026-06-16 17:09:34 +00:00
@scheduled_app.command("ps", examples=["hf jobs scheduled ps"])
2026-02-06 22:23:20 +01:00
def scheduled_ps(
all: Annotated[
bool,
typer.Option(
"-a",
"--all",
help="Show all scheduled Jobs (default hides suspended)",
),
] = False,
namespace: NamespaceOpt = None,
token: TokenOpt = None,
filter: Annotated[
2026-06-16 17:09:34 +00:00
list[str] | None,
2026-02-06 22:23:20 +01:00
typer.Option(
"-f",
"--filter",
help="Filter output based on conditions provided (format: key=value)",
),
] = None,
) -> None:
2026-06-16 17:09:34 +00:00
"""List scheduled Jobs"""
api = get_hf_api(token=token)
scheduled_jobs = api.list_scheduled_jobs(namespace=namespace)
filters: list[tuple[str, str, str]] = []
for f in filter or []:
if "=" in f:
key, value = f.split("=", 1)
# Negate predicate in case of key!=value
if key.endswith("!"):
op = "!="
key = key[:-1]
2026-02-06 22:23:20 +01:00
else:
2026-06-16 17:09:34 +00:00
op = "="
filters.append((key.lower(), op, value.lower()))
else:
out.warning(f"Ignoring invalid filter format '{f}'. Use key=value format.")
# Filter scheduled jobs (operating on ScheduledJobInfo objects to preserve existing filter behavior)
filtered_jobs = []
for scheduled_job in scheduled_jobs:
suspend = scheduled_job.suspend or False
if not all and suspend:
continue
image_or_space = scheduled_job.job_spec.docker_image or "N/A"
cmd = scheduled_job.job_spec.command or []
command_str = " ".join(cmd) if cmd else "N/A"
props = {"id": scheduled_job.id, "image": image_or_space, "suspend": str(suspend), "command": command_str}
if not _matches_filters(props, filters):
continue
filtered_jobs.append(scheduled_job)
# Build display items. Augment with curated columns.
items: list[dict[str, Any]] = []
for sj in filtered_jobs:
item = _dataclass_to_dict(sj)
job_spec = item.get("job_spec") or {}
status_dict = item.get("status") or {}
last_job = status_dict.get("last_job")
cmd = job_spec.get("command") or []
item["image/space"] = job_spec.get("docker_image") or "N/A"
item["command"] = " ".join(cmd) if cmd else "N/A"
item["last_run"] = last_job["at"][:19].replace("T", " ") if last_job and last_job.get("at") else "N/A"
item["next_run"] = (
status_dict["next_job_run_at"][:19].replace("T", " ") if status_dict.get("next_job_run_at") else "N/A"
)
item["suspend"] = item.get("suspend") or False
items.append(item)
out.table(
items,
headers=["id", "schedule", "image/space", "command", "last_run", "next_run", "suspend"],
id_key="id",
)
if not items and filters:
filters_msg = ", ".join(f"{k}{o}{v}" for k, o, v in filters)
out.text(f"No scheduled jobs matched filters: {filters_msg}")
2026-02-06 22:23:20 +01:00
2026-06-16 17:09:34 +00:00
@scheduled_app.command("inspect", examples=["hf jobs scheduled inspect <id>"])
2026-02-06 22:23:20 +01:00
def scheduled_inspect(
scheduled_job_ids: Annotated[
list[str],
typer.Argument(
2026-06-16 17:09:34 +00:00
help="Scheduled Job IDs to inspect (or 'namespace/scheduled_job_id')",
2026-02-06 22:23:20 +01:00
),
],
namespace: NamespaceOpt = None,
token: TokenOpt = None,
) -> None:
2026-06-16 17:09:34 +00:00
"""Display detailed information on one or more scheduled Jobs"""
parsed_ids = []
for job_id in scheduled_job_ids:
job_id, namespace = _parse_namespace_from_job_id(job_id, namespace)
parsed_ids.append(job_id)
scheduled_job_ids = parsed_ids
2026-02-06 22:23:20 +01:00
api = get_hf_api(token=token)
scheduled_jobs = [
api.inspect_scheduled_job(scheduled_job_id=scheduled_job_id, namespace=namespace)
for scheduled_job_id in scheduled_job_ids
]
2026-06-16 17:09:34 +00:00
out.table([_dataclass_to_dict(scheduled_job) for scheduled_job in scheduled_jobs])
2026-02-06 22:23:20 +01:00
2026-06-16 17:09:34 +00:00
@scheduled_app.command("delete", examples=["hf jobs scheduled delete <id>"])
2026-02-06 22:23:20 +01:00
def scheduled_delete(
scheduled_job_id: ScheduledJobIdArg,
namespace: NamespaceOpt = None,
token: TokenOpt = None,
) -> None:
2026-06-16 17:09:34 +00:00
"""Delete a scheduled Job."""
scheduled_job_id, namespace = _parse_namespace_from_job_id(scheduled_job_id, namespace)
2026-02-06 22:23:20 +01:00
api = get_hf_api(token=token)
api.delete_scheduled_job(scheduled_job_id=scheduled_job_id, namespace=namespace)
2026-06-16 17:09:34 +00:00
out.result("Scheduled Job deleted", id=scheduled_job_id)
2026-02-06 22:23:20 +01:00
2026-06-16 17:09:34 +00:00
@scheduled_app.command("suspend", examples=["hf jobs scheduled suspend <id>"])
2026-02-06 22:23:20 +01:00
def scheduled_suspend(
scheduled_job_id: ScheduledJobIdArg,
namespace: NamespaceOpt = None,
token: TokenOpt = None,
) -> None:
2026-06-16 17:09:34 +00:00
"""Suspend (pause) a scheduled Job."""
scheduled_job_id, namespace = _parse_namespace_from_job_id(scheduled_job_id, namespace)
2026-02-06 22:23:20 +01:00
api = get_hf_api(token=token)
api.suspend_scheduled_job(scheduled_job_id=scheduled_job_id, namespace=namespace)
2026-06-16 17:09:34 +00:00
out.result("Scheduled Job suspended", id=scheduled_job_id)
out.hint(f"Use `hf jobs scheduled resume {scheduled_job_id}` to resume it.")
2026-02-06 22:23:20 +01:00
2026-06-16 17:09:34 +00:00
@scheduled_app.command("resume", examples=["hf jobs scheduled resume <id>"])
2026-02-06 22:23:20 +01:00
def scheduled_resume(
scheduled_job_id: ScheduledJobIdArg,
namespace: NamespaceOpt = None,
token: TokenOpt = None,
) -> None:
2026-06-16 17:09:34 +00:00
"""Resume (unpause) a scheduled Job."""
scheduled_job_id, namespace = _parse_namespace_from_job_id(scheduled_job_id, namespace)
2026-02-06 22:23:20 +01:00
api = get_hf_api(token=token)
api.resume_scheduled_job(scheduled_job_id=scheduled_job_id, namespace=namespace)
2026-06-16 17:09:34 +00:00
out.result("Scheduled Job resumed", id=scheduled_job_id)
@scheduled_app.command(
"labels",
examples=[
"hf jobs scheduled labels <id> --label env=prod --label team=ml",
"hf jobs scheduled labels <id> --clear",
],
)
def scheduled_labels(
scheduled_job_id: ScheduledJobIdArg,
label: LabelsOpt = None,
clear: Annotated[bool, typer.Option("--clear", help="Remove all labels from the scheduled job.")] = False,
namespace: NamespaceOpt = None,
token: TokenOpt = None,
) -> None:
"""Update labels on a scheduled Job. Replaces all existing labels."""
if not label and not clear:
raise CLIError("Please set at least one label with --label. To remove all labels, pass --clear.")
if label and clear:
raise CLIError(
"Cannot set labels and clear them at the same time. Please use either --label or --clear, not both."
)
scheduled_job_id, namespace = _parse_namespace_from_job_id(scheduled_job_id, namespace)
labels = _parse_labels_map(label) or {}
api = get_hf_api(token=token)
scheduled_job = api.update_scheduled_job_labels(
scheduled_job_id=scheduled_job_id, labels=labels, namespace=namespace
)
out.result("Labels updated", id=scheduled_job.id)
2026-02-06 22:23:20 +01:00
2026-06-16 17:09:34 +00:00
scheduled_uv_app = typer_factory(help="Schedule UV scripts on HF infrastructure.")
2026-02-06 22:23:20 +01:00
scheduled_app.add_typer(scheduled_uv_app, name="uv")
@scheduled_uv_app.command(
"run",
context_settings={"ignore_unknown_options": True},
2026-06-16 17:09:34 +00:00
examples=[
'hf jobs scheduled uv run "0 0 * * *" script.py',
'hf jobs scheduled uv run "0 0 * * *" script.py --with pandas',
],
2026-02-06 22:23:20 +01:00
)
def scheduled_uv_run(
schedule: ScheduleArg,
script: ScriptArg,
script_args: ScriptArgsArg = None,
suspend: SuspendOpt = None,
concurrency: ConcurrencyOpt = None,
image: ImageOpt = None,
flavor: FlavorOpt = None,
env: EnvOpt = None,
secrets: SecretsOpt = None,
2026-06-16 17:09:34 +00:00
label: LabelsOpt = None,
volume: VolumesOpt = None,
2026-02-06 22:23:20 +01:00
env_file: EnvFileOpt = None,
secrets_file: SecretsFileOpt = None,
timeout: TimeoutOpt = None,
2026-06-16 17:09:34 +00:00
expose: ExposeOpt = None,
2026-02-06 22:23:20 +01:00
namespace: NamespaceOpt = None,
token: TokenOpt = None,
with_: WithOpt = None,
python: PythonOpt = None,
) -> None:
2026-06-16 17:09:34 +00:00
"""Run a UV script (local file or URL) on HF infrastructure"""
env_map = parse_env_map(env, env_file)
secrets_map = parse_env_map(secrets, secrets_file)
2026-02-06 22:23:20 +01:00
api = get_hf_api(token=token)
job = api.create_scheduled_uv_job(
script=script,
script_args=script_args or [],
schedule=schedule,
suspend=suspend,
concurrency=concurrency,
dependencies=with_,
python=python,
image=image,
env=env_map,
secrets=secrets_map,
2026-06-16 17:09:34 +00:00
labels=_parse_labels_map(label),
volumes=parse_volumes(volume),
flavor=flavor,
2026-02-06 22:23:20 +01:00
timeout=timeout,
2026-06-16 17:09:34 +00:00
expose=expose,
2026-02-06 22:23:20 +01:00
namespace=namespace,
)
2026-06-16 17:09:34 +00:00
out.result("Scheduled Job created", id=job.id)
out.hint(f"Use `hf jobs scheduled inspect {job.id}` to view its details.")
2026-02-06 22:23:20 +01:00
### UTILS
2026-06-16 17:09:34 +00:00
def _parse_labels_map(labels: list[str] | None) -> dict[str, str] | None:
"""Parse label key-value pairs from CLI arguments.
Args:
labels: List of label strings in KEY=VALUE format. If KEY only, then VALUE is set to empty string.
Returns:
Dictionary mapping label keys to values, or None if no labels provided.
"""
if not labels:
return None
labels_map: dict[str, str] = {}
for label_var in labels:
key, value = label_var.split("=", 1) if "=" in label_var else (label_var, "")
labels_map[key] = value
return labels_map
def _tabulate(rows: list[list[str | int]], headers: list[str]) -> str:
2026-02-06 22:23:20 +01:00
"""
Inspired by:
- stackoverflow.com/a/8356620/593036
- stackoverflow.com/questions/9535954/printing-lists-as-tabular-data
"""
col_widths = [max(len(str(x)) for x in col) for col in zip(*rows, headers)]
2026-06-16 17:09:34 +00:00
terminal_width = max(shutil.get_terminal_size().columns, len(headers) * 12)
2026-02-06 22:23:20 +01:00
while len(headers) + sum(col_widths) > terminal_width:
col_to_minimize = col_widths.index(max(col_widths))
col_widths[col_to_minimize] //= 2
if len(headers) + sum(col_widths) <= terminal_width:
col_widths[col_to_minimize] = terminal_width - sum(col_widths) - len(headers) + col_widths[col_to_minimize]
row_format = ("{{:{}}} " * len(headers)).format(*col_widths)
lines = []
lines.append(row_format.format(*headers))
lines.append(row_format.format(*["-" * w for w in col_widths]))
for row in rows:
row_format_args = [
str(x)[: col_width - 3] + "..." if len(str(x)) > col_width else str(x)
for x, col_width in zip(row, col_widths)
]
lines.append(row_format.format(*row_format_args))
return "\n".join(lines)
T = TypeVar("T")
def _write_generator_to_queue(queue: Queue[T], func: Callable[..., Iterable[T]], kwargs: dict) -> None:
for result in func(**kwargs):
queue.put(result)
def iflatmap_unordered(
pool: multiprocessing.pool.ThreadPool,
func: Callable[..., Iterable[T]],
*,
kwargs_list: list[dict],
) -> Iterable[T]:
"""
Takes a function that returns an iterable of items, and run it in parallel using threads to return the flattened iterable of items as they arrive.
This is inspired by those three `map()` variants, and is the mix of all three:
* `imap()`: like `map()` but returns an iterable instead of a list of results
* `imap_unordered()`: like `imap()` but the output is sorted by time of arrival
* `flatmap()`: like `map()` but given a function which returns a list, `flatmap()` returns the flattened list that is the concatenation of all the output lists
"""
queue: Queue[T] = Queue()
async_results = [pool.apply_async(_write_generator_to_queue, (queue, func, kwargs)) for kwargs in kwargs_list]
try:
while True:
try:
yield queue.get(timeout=0.05)
except Empty:
if all(async_result.ready() for async_result in async_results) and queue.empty():
break
except KeyboardInterrupt:
pass
finally:
# we get the result in case there's an error to raise
try:
[async_result.get(timeout=0.05) for async_result in async_results]
except multiprocessing.TimeoutError:
pass