Files
lerobot/tests/jobs/test_hf.py
T
Nicolas Rabault 5ac3b49a5f feat(train): run training remotely on HF Jobs via --job.target (#3856)
* feat(train): add JobConfig group, save_checkpoint_to_hub flag, Hub checkpoint helper

Introduce a JobConfig draccus group on TrainPipelineConfig (--job.target/image/
timeout/detach/tags) whose is_remote property gates remote dispatch, plus a
save_checkpoint_to_hub flag and validation. Add push_checkpoint_to_hub(), which
uploads a saved checkpoint directory to the model repo under checkpoints/<step>/
and creates the repo idempotently (private propagates from policy.private).

* feat(train): run training remotely on HF Jobs via --job.target

When --job.target names a GPU flavor, train() dispatches to lerobot.jobs.submit_to_hf
instead of training locally: it authenticates, ensures the dataset is on the Hub
(pushing a local-only one privately), serializes a pod-compatible train_config.json
(strips client-only fields, points at the model repo), submits via HfApi.run_job
with HF_TOKEN/WANDB_API_KEY secrets, then streams logs and finishes when the model
is pushed. Wires push_checkpoint_to_hub into the training loop behind
save_checkpoint_to_hub, and tags jobs/datasets/model with 'lerobot' + --job.tags.

* docs(train): document remote training on HF Jobs

* test(train): skip remote-dispatch tests without the dataset extra

The module imports lerobot.scripts.lerobot_train, which eagerly pulls in
lerobot.datasets (dataset extra). The base fast-test CI tier runs without
that extra, so collection failed there. Guard with pytest.importorskip,
matching the existing tests/scripts dataset-extra tests.

* refactor(jobs): hoist huggingface_hub imports to module level in hf.py

huggingface_hub is a core dependency, so the per-function dynamic imports
had no lazy-loading rationale. Move them to a single module-level import
and update test monkeypatch targets to lerobot.jobs.hf.* accordingly.

* refactor(jobs): build remote config dict via cfg.to_dict()

TrainPipelineConfig.to_dict() already returns the canonical draccus
encoding, so the StringIO + draccus.dump + json.loads round-trip was
redundant. Use it directly and drop the now-unused io/draccus imports.

* refactor(train): use module-level HfApi import in push_checkpoint_to_hub

huggingface_hub is a core dependency; the in-function import was
unnecessary. Move HfApi to a module-level import and point the test
monkeypatches at lerobot.common.train_utils.HfApi.

* refactor(configs): export JobConfig from the configs package

Re-export JobConfig in lerobot/configs/__init__.py so external callers
import it as `from lerobot.configs import JobConfig`, matching the other
config classes. Adapt the train script and test imports.

* refactor(jobs): check dataset presence with api.repo_exists

Replace the dataset_info try/except RepositoryNotFoundError dance with a
direct api.repo_exists(repo_id, repo_type="dataset") call, dropping the
httpx/RepositoryNotFoundError test scaffolding.

* chore(jobs): annotate ensure_dataset_available api param as HfApi

Add the missing HfApi type hint via a TYPE_CHECKING import.

* refactor(jobs): use HF_LEROBOT_HOME constant for the local cache root

Resolve the local dataset cache via lerobot.utils.constants.HF_LEROBOT_HOME
instead of re-reading the env var by hand, dropping the os/Path imports.
Tests now patch the imported constant and assert on a stable message
substring (the previous "neither" match only passed by accident, matching
the test name embedded in the pytest tmp_path).

* chore(jobs): guard LeRobotDataset import with require_package

Surface a clear "install lerobot[dataset]" error if the datasets extra
is missing, instead of a raw ImportError, before pushing a local dataset.

* docs(configs): clarify the is_remote_target/is_remote split

Add a comment explaining why JobConfig keeps both the staticmethod (tests
a raw target string from argv before a config exists) and the property
(accessor for an existing config instance).

* docs(train): note how to pin a pushed model version for inference

Document --policy.pretrained_revision alongside --policy.path so a
specific Hub-pushed checkpoint (once --save_checkpoint_to_hub has
committed several) can be selected for inference.

* test(jobs): skip dataset import guard in base-deps test

The fast test env installs base deps only, so require_package('datasets')
raised ImportError before the mocked lerobot.datasets import was reached.
Monkeypatch the guard to a no-op so the unit test exercises the upload logic.

* fix(jobs): address claude review findings on remote training

Resolve the claude[bot] review on #3856:

- Reject reward-model training under --job.target with a clear error instead
  of crashing on a None policy inside build_remote_config_file.
- Support --policy.path remote runs: validate() no longer requires repo_id for
  remote runs (it is auto-generated in submit_to_hf), and repo_id/push_to_hub
  are now set after validate() resolves the policy.
- Narrow the bare `except Exception` in _tail_logs/_poll_until_done to
  (OSError, httpx.HTTPError) so programming errors surface instead of being
  silently retried or counted as job failures.
- Install the SIGINT detach handler only on the main thread.
- Generate model repo timestamps in UTC.

* docs(jobs): document the model-pushed marker contract and orphaned repos

Follow-up to the claude[bot] review on #3856 (non-blocking observations):

- Cross-reference the "Model pushed to <url>" log line between its producer
  (PreTrainedPolicy.push_model_to_hub) and the remote-run consumer in
  submit_to_hf, noting the contract is an early-finish optimization that
  falls back to status polling if it drifts.
- Note in the HF Jobs guide that a failed remote run leaves its model repo
  on the Hub (it is not auto-deleted) and how to remove it.

* feat(train): tag each pushed checkpoint with its step

Address review feedback on #3856: pushing a checkpoint to the Hub now
also creates a tag named after the checkpoint step, so a checkpoint can
be recovered with --policy.pretrained_revision=<step> instead of having
to look up its commit sha.

* fix(jobs): hoist ensure_dataset_available to a module-level import

Addresses Caroline's review comment on PR #3856: the local import of
ensure_dataset_available inside submit_to_hf was vestigial. dataset.py
does not import hf.py, so there is no circular-import risk and no extra
load cost (its heavy deps stay lazy), so make it a top-level import.

* refactor(configs): untangle config_path/resume resolution in validate()

Split the re-parse HACK block in TrainPipelineConfig.validate() into focused
helpers (_resolve_pretrained_from_cli, _resolve_resume_checkpoint) that handle
the policy path, reward-model path, and resume config_path as separate,
readable units. Behavior-preserving.

* feat(train): resume training from a Hub checkpoint

Allow --config_path to be a Hub repo id when resuming, not only a local path.
The latest checkpoint under checkpoints/<step>/ is downloaded into a fresh local
run dir and resumed from there (optimizer, scheduler, RNG and data order
restored as for a local resume). TrainPipelineConfig.from_pretrained falls back
to the latest checkpoint's train_config.json when a repo has no root config
(an interrupted run that only pushed checkpoints). The download is skipped when
dispatching remotely so the executor (local machine or HF Jobs pod) performs it.

- add find_latest_hub_checkpoint (utils/hub) and resolve_resume_checkpoint
  (common/train_utils), the symmetric download counterpart to
  push_checkpoint_to_hub
- unit tests for both helpers and the from_pretrained fallback

* feat(jobs): resume a run on HF Jobs from a checkpoint

When --resume is set with a remote --job.target, submit_to_hf resumes from the
checkpoint repo instead of staging a fresh config. A Hub config_path is resumed
in place (its checkpoint config already targets that repo); a local config_path
has its checkpoint uploaded to a new private repo first and the run is forced to
push back to it. The pod command carries --job.target=local so the checkpoint's
saved job.target can't make the pod re-dispatch itself, and the user's CLI
overrides are forwarded so a remote resume matches the same local command.
ensure_dataset_available is hoisted before the resume/fresh branch since it
applies to both.

* docs(train): document resuming from a Hub checkpoint, locally and on jobs

Show that --config_path accepts a Hub repo id for --resume, and that adding
--job.target resumes on HF Jobs (uploading a local checkpoint/dataset first).

* fix(jobs): default remote job timeout to 2d instead of the platform default

HF Jobs applies its own short 30-minute timeout when none is sent, which
silently kills long training runs. Pass an explicit, generous 2d cap by
default; users can still override --job.timeout to fail fast or extend it.

* fix(jobs): drop --dataset.root on resume + restore keyboard-control docs

Address the latest Claude review on #3856:

- _build_resume_job no longer forwards --dataset.root to the pod (a
  host-local path it can't read); the fresh-run path already nulls it in
  build_remote_config_file, so this makes resume consistent. Add a unit
  test for _pod_forwarded_args covering the drop in both flag forms.
- Restore the display-independent keyboard-control docs (n/r/q letter
  equivalents + X11/Wayland/headless Tip) in il_robots.mdx that this
  branch was stale on relative to main (#3875).

* fix(jobs): handle str-typed job stage from huggingface_hub

inspect_job's status.stage is an enum (with .value) in some
huggingface_hub versions and a plain str in others. The poller
assumed the enum shape, raising "'str' object has no attribute
'value'" on resume for users on the str-returning version.

Read it via getattr(..., "value", ...) so both shapes work, and
parametrize the poll test over enum and str stages so the str case
is actually exercised (the old mock only ever simulated the enum).

* refactor(jobs): use relative import for ensure_dataset_available

* refactor(train): hoist submit_to_hf import to module top

The `from lerobot.jobs import submit_to_hf` was a function-local import in
train(); it pulls no heavy/optional deps and has no circular-import risk, so
move it to the top-level import block.

* refactor(train): hoist _remote_target_in_argv imports to module top

Move `import sys` and `from lerobot.configs import JobConfig` out of the
function body and into the top-level import block.

* refactor(utils): use relative import for sibling constants in hub.py

`from lerobot.utils.constants import CHECKPOINTS_DIR` was the odd one out in
utils/ — sibling modules there are imported relatively (.constants, .errors,
.utils, ...). Match that convention.

* refactor(jobs): hoist LeRobotDataset import, guard dataset extra at package init

Move the `from lerobot.datasets import LeRobotDataset` import to the top of
dataset.py and relocate the `require_package("datasets", extra="dataset")`
guard to the jobs package __init__, per review feedback.

* test(jobs): skip test_hf if datasets extra is missing

lerobot.configs.train pulls in datasets at import time, so the module
fails to collect without lerobot[dataset]. Guard with importorskip,
matching the convention in tests/training/test_multi_gpu.py.

* test(jobs): skip test_dataset if datasets extra is missing

tests/jobs/test_dataset.py imports lerobot.jobs.dataset, which triggers
the require_package("datasets") guard in lerobot/jobs/__init__.py at
import time. Without lerobot[dataset] the module fails to collect in the
base CI tier. Guard with importorskip, same as test_hf.py.
2026-06-29 17:59:33 +02:00

494 lines
18 KiB
Python

# Copyright 2025 The HuggingFace Inc. 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.
import datetime as dt
import json
import threading
from types import SimpleNamespace
import draccus
import httpx
import pytest
pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])")
from lerobot.configs.train import TrainPipelineConfig
from lerobot.jobs.hf import (
_pod_forwarded_args,
_poll_until_done,
build_remote_config_file,
build_repo_id,
resolve_job_tags,
resolve_wandb_api_key,
submit_to_hf,
)
def test_resolve_job_tags_always_includes_lerobot_and_dedups():
assert resolve_job_tags(None) == ["lerobot"]
assert resolve_job_tags([]) == ["lerobot"]
assert resolve_job_tags(["lelab"]) == ["lerobot", "lelab"]
# lerobot isn't duplicated if passed explicitly; order is stable.
assert resolve_job_tags(["lelab", "lerobot", "lelab"]) == ["lerobot", "lelab"]
def _fake_inspect(stage_value, *, as_enum=True):
# huggingface_hub returns `stage` as an enum (with `.value`) in some versions and a plain str in others.
stage = SimpleNamespace(value=stage_value) if as_enum else stage_value
return lambda job_id: SimpleNamespace(status=SimpleNamespace(stage=stage))
@pytest.mark.parametrize("as_enum", [True, False], ids=["enum_stage", "str_stage"])
def test_poll_until_done_returns_terminal_stage(monkeypatch, as_enum):
monkeypatch.setattr("lerobot.jobs.hf.inspect_job", _fake_inspect("COMPLETED", as_enum=as_enum))
done = threading.Event()
assert _poll_until_done("j", done, poll_interval=0.01) == "COMPLETED"
assert done.is_set()
def test_poll_until_done_exits_when_done_already_set(monkeypatch):
# Non-terminal forever; with done pre-set the loop must not block and returns None.
monkeypatch.setattr("lerobot.jobs.hf.inspect_job", _fake_inspect("RUNNING"))
done = threading.Event()
done.set()
assert _poll_until_done("j", done, poll_interval=0.01) is None
def test_poll_until_done_gives_up_after_repeated_network_failures(monkeypatch):
monkeypatch.setattr(
"lerobot.jobs.hf.inspect_job", lambda job_id: (_ for _ in ()).throw(httpx.ConnectError("boom"))
)
done = threading.Event()
result = _poll_until_done("j", done, poll_interval=0.001, max_failures=3)
assert result is None
assert done.is_set()
def test_poll_until_done_propagates_programming_errors(monkeypatch):
"""A bug (e.g. TypeError) must surface, not be silently retried as a transient failure."""
monkeypatch.setattr("lerobot.jobs.hf.inspect_job", lambda job_id: (_ for _ in ()).throw(TypeError("bug")))
done = threading.Event()
with pytest.raises(TypeError):
_poll_until_done("j", done, poll_interval=0.001, max_failures=3)
def test_resolve_wandb_key_from_env(monkeypatch):
monkeypatch.setenv("WANDB_API_KEY", "abc123")
assert resolve_wandb_api_key() == "abc123"
def test_resolve_wandb_key_missing(monkeypatch, tmp_path):
monkeypatch.delenv("WANDB_API_KEY", raising=False)
monkeypatch.setenv("HOME", str(tmp_path)) # no ~/.netrc here
monkeypatch.setattr("netrc.netrc", lambda *a, **k: (_ for _ in ()).throw(FileNotFoundError()))
assert resolve_wandb_api_key() is None
def test_resolve_wandb_key_from_netrc(monkeypatch):
# No env var → fall back to the wandb credentials in ~/.netrc.
monkeypatch.delenv("WANDB_API_KEY", raising=False)
class _FakeNetrc:
def authenticators(self, host):
assert host == "api.wandb.ai"
return ("login", "account", "netrc-secret")
monkeypatch.setattr("netrc.netrc", lambda *a, **k: _FakeNetrc())
assert resolve_wandb_api_key() == "netrc-secret"
def test_resolve_wandb_key_netrc_without_wandb_entry(monkeypatch):
# ~/.netrc exists but has no api.wandb.ai entry → None.
monkeypatch.delenv("WANDB_API_KEY", raising=False)
class _FakeNetrc:
def authenticators(self, host):
return None
monkeypatch.setattr("netrc.netrc", lambda *a, **k: _FakeNetrc())
assert resolve_wandb_api_key() is None
def test_build_repo_id_sanitizes_and_timestamps():
now = dt.datetime(2026, 6, 19, 10, 22, 3)
assert build_repo_id("alice", "act", now) == "alice/act_2026-06-19_10-22-03"
# Runs of illegal characters collapse to a single dash; edges are trimmed.
assert build_repo_id("alice", "my cool/run!!", now) == "alice/my-cool-run_2026-06-19_10-22-03"
# A name with nothing usable falls back to "train".
assert build_repo_id("alice", "///", now) == "alice/train_2026-06-19_10-22-03"
def test_pod_forwarded_args_drops_host_only_flags():
"""User overrides are replayed on the pod, minus flags that only make sense on the submitter.
`--dataset.root` is a host-local path the pod can't read, so it must be dropped in both the
`--name=value` and `--name value` forms; unrelated overrides are forwarded untouched.
"""
argv = [
"--config_path=u/d",
"--dataset.root=/local/data",
"--dataset.root",
"/other/local/data",
"--policy.repo_id=u/keep",
"--steps=10",
"--job.target=a10g-small",
]
forwarded = _pod_forwarded_args(
argv,
drop_names=("--config_path", "--policy.repo_id", "--policy.push_to_hub", "--dataset.root"),
drop_prefixes=("--job.",),
)
assert forwarded == ["--steps=10"]
def _minimal_cfg():
return draccus.parse(
TrainPipelineConfig,
args=["--dataset.repo_id", "u/d", "--policy.type", "act", "--job.target", "a10g-small"],
)
def test_validate_skips_repo_id_check_for_remote():
"""Remote runs auto-assign repo_id in submit_to_hf, so validate() must not demand it up front."""
cfg = _minimal_cfg() # remote target, push_to_hub default True, no explicit repo_id
assert cfg.policy.repo_id is None
cfg.validate() # must not raise
def test_validate_requires_repo_id_for_local_push():
"""Local runs that push to the Hub still need an explicit repo_id."""
cfg = draccus.parse(
TrainPipelineConfig,
args=["--dataset.repo_id", "u/d", "--policy.type", "act"],
)
with pytest.raises(ValueError, match="repo_id"):
cfg.validate()
def test_build_remote_config_applies_overrides(tmp_path):
cfg = _minimal_cfg()
dest = tmp_path / "train_config.json"
out = build_remote_config_file(cfg, "u/run", dest)
assert out == dest
data = json.loads(dest.read_text())
# `job` is client-only orchestration and must be stripped for the pod.
assert "job" not in data
# save_checkpoint_to_hub defaults off → omitted so older images accept the config.
assert "save_checkpoint_to_hub" not in data
assert data["policy"]["push_to_hub"] is True
assert data["policy"]["repo_id"] == "u/run"
assert data["policy"]["device"] is None # pod auto-detects its GPU
assert data["dataset"]["root"] is None # pod resolves the dataset by repo_id
# the caller's cfg must be left untouched (function works on a deep copy)
assert cfg.job.target == "a10g-small"
assert cfg.save_checkpoint_to_hub is False
def test_build_remote_config_includes_checkpoint_flag_when_enabled(tmp_path):
cfg = draccus.parse(
TrainPipelineConfig,
args=[
"--dataset.repo_id",
"u/d",
"--policy.type",
"act",
"--job.target",
"a10g-small",
"--save_checkpoint_to_hub",
"true",
],
)
dest = tmp_path / "train_config.json"
build_remote_config_file(cfg, "u/run", dest)
data = json.loads(dest.read_text())
# explicitly enabled → kept in the config (requires a matching trainer image).
assert data["save_checkpoint_to_hub"] is True
assert "job" not in data
def test_build_remote_config_merges_tags_into_policy(tmp_path):
cfg = _minimal_cfg()
dest = tmp_path / "train_config.json"
build_remote_config_file(cfg, "u/run", dest, tags=["lerobot", "lelab"])
data = json.loads(dest.read_text())
# tags propagate to the model the pod pushes.
assert data["policy"]["tags"] == ["lerobot", "lelab"]
def test_build_remote_config_merges_tags_without_duplicating(tmp_path):
cfg = _minimal_cfg()
cfg.policy.tags = ["existing", "lerobot"]
dest = tmp_path / "train_config.json"
build_remote_config_file(cfg, "u/run", dest, tags=["lerobot", "lelab"])
data = json.loads(dest.read_text())
# pre-existing policy tags are kept; only genuinely-new tags are appended (no dup "lerobot").
assert data["policy"]["tags"] == ["existing", "lerobot", "lelab"]
def test_submit_requires_login(monkeypatch):
monkeypatch.setattr("lerobot.jobs.hf.get_token", lambda: None)
cfg = draccus.parse(
TrainPipelineConfig,
args=["--dataset.repo_id", "u/d", "--policy.type", "act", "--job.target", "a10g-small"],
)
with pytest.raises(RuntimeError, match="hf auth login"):
submit_to_hf(cfg)
def test_submit_passes_validation_and_submits(monkeypatch):
"""A type-based policy with no explicit repo_id is auto-assigned one and submitted."""
from unittest.mock import MagicMock
# Patch get_token
monkeypatch.setattr("lerobot.jobs.hf.get_token", lambda: "tok")
# Patch HfApi so whoami returns alice
class FakeHfApi:
def __init__(self, token=None):
pass
def whoami(self, token=None):
return {"name": "alice"}
monkeypatch.setattr("lerobot.jobs.hf.HfApi", FakeHfApi)
# ensure_dataset_available returns None; patch it out so no Hub access happens
# (hf.py imports it at module level, so patch it on lerobot.jobs.hf).
monkeypatch.setattr("lerobot.jobs.hf.ensure_dataset_available", lambda *a, **kw: None)
# Patch _stage_config_on_hub to skip network
monkeypatch.setattr(
"lerobot.jobs.hf._stage_config_on_hub",
lambda cfg, repo_id, token, tags=None: repo_id,
)
# Patch run_job to return a fake job
fake_job = MagicMock()
fake_job.id = "job-123"
run_job_calls = []
def fake_run_job(**kwargs):
run_job_calls.append(kwargs)
return fake_job
monkeypatch.setattr("lerobot.jobs.hf.run_job", fake_run_job)
cfg = draccus.parse(
TrainPipelineConfig,
args=[
"--dataset.repo_id",
"u/d",
"--policy.type",
"act",
"--job.target",
"a10g-small",
"--job.detach",
"true",
],
)
# Must NOT raise (pre-fix this raised ValueError about missing repo_id)
submit_to_hf(cfg)
assert len(run_job_calls) == 1, "run_job should have been called exactly once"
assert cfg.policy.repo_id is not None
assert cfg.policy.repo_id.startswith("alice/")
call = run_job_calls[0]
# The pod runs `lerobot-train --config_path=<staged repo>` on the requested flavor/image.
assert call["command"][0] == "lerobot-train"
assert call["command"][1].startswith("--config_path=")
assert call["flavor"] == "a10g-small"
assert call["image"] == "huggingface/lerobot-gpu:latest"
# The Hub token is forwarded so the pod can pull the (possibly private) dataset.
assert call["secrets"]["HF_TOKEN"] == "tok"
# Every job carries the lerobot tag as a queryable label.
assert call["labels"].get("lerobot") == "true"
def test_submit_rejects_reward_model_training(monkeypatch):
"""Remote training only supports policies; reward-model runs fail fast with a clear error."""
monkeypatch.setattr("lerobot.jobs.hf.get_token", lambda: "tok")
class FakeHfApi:
def __init__(self, token=None):
pass
def whoami(self, token=None):
return {"name": "alice"}
monkeypatch.setattr("lerobot.jobs.hf.HfApi", FakeHfApi)
cfg = _minimal_cfg()
cfg.reward_model = SimpleNamespace(type="reward") # marks this as reward-model training
monkeypatch.setattr(cfg, "validate", lambda: None) # skip pretrained-path resolution
with pytest.raises(ValueError, match="reward model"):
submit_to_hf(cfg)
@pytest.mark.timeout(15)
def test_submit_returns_when_job_completes(monkeypatch):
"""Non-detach path must RETURN (not hang) once the job reaches a terminal stage."""
from types import SimpleNamespace
monkeypatch.setattr("lerobot.jobs.hf.get_token", lambda: "tok")
class FakeHfApi:
def __init__(self, token=None):
pass
def whoami(self, token=None):
return {"name": "alice"}
monkeypatch.setattr("lerobot.jobs.hf.HfApi", FakeHfApi)
monkeypatch.setattr("lerobot.jobs.hf.ensure_dataset_available", lambda *a, **kw: None)
monkeypatch.setattr(
"lerobot.jobs.hf._stage_config_on_hub", lambda cfg, repo_id, token, tags=None: repo_id
)
monkeypatch.setattr("lerobot.jobs.hf.run_job", lambda **kw: SimpleNamespace(id="job-1", url="http://x"))
# Job is already COMPLETED on the first poll.
monkeypatch.setattr(
"lerobot.jobs.hf.inspect_job",
lambda job_id: SimpleNamespace(
status=SimpleNamespace(stage=SimpleNamespace(value="COMPLETED"), message=None)
),
)
# Log stream ends immediately.
monkeypatch.setattr("lerobot.jobs.hf.fetch_job_logs", lambda job_id, follow=True: iter(()))
cfg = draccus.parse(
TrainPipelineConfig,
args=["--dataset.repo_id", "u/d", "--policy.type", "act", "--job.target", "a10g-small"],
)
# Runs in the pytest main thread (signal handler install requires it); the
# @timeout marker fails the test instead of hanging if it regresses.
submit_to_hf(cfg)
@pytest.mark.timeout(15)
def test_submit_returns_on_model_pushed_marker(monkeypatch):
"""Finish when the model-pushed log appears, even if the job stage never flips."""
from types import SimpleNamespace
monkeypatch.setattr("lerobot.jobs.hf.get_token", lambda: "tok")
class FakeHfApi:
def __init__(self, token=None):
pass
def whoami(self, token=None):
return {"name": "alice"}
monkeypatch.setattr("lerobot.jobs.hf.HfApi", FakeHfApi)
monkeypatch.setattr("lerobot.jobs.hf.ensure_dataset_available", lambda *a, **kw: None)
monkeypatch.setattr(
"lerobot.jobs.hf._stage_config_on_hub", lambda cfg, repo_id, token, tags=None: repo_id
)
monkeypatch.setattr("lerobot.jobs.hf.run_job", lambda **kw: SimpleNamespace(id="job-1", url="http://x"))
# Job stays RUNNING forever — only the log marker can end the command.
monkeypatch.setattr(
"lerobot.jobs.hf.inspect_job",
lambda job_id: SimpleNamespace(
status=SimpleNamespace(stage=SimpleNamespace(value="RUNNING"), message=None)
),
)
pushed_line = "INFO Model pushed to https://huggingface.co/alice/myrun"
monkeypatch.setattr("lerobot.jobs.hf.fetch_job_logs", lambda job_id, follow=True: iter([pushed_line]))
cfg = draccus.parse(
TrainPipelineConfig,
args=[
"--dataset.repo_id",
"u/d",
"--policy.type",
"act",
"--policy.repo_id",
"alice/myrun",
"--job.target",
"a10g-small",
],
)
# Must return via the model-pushed marker despite the perpetual RUNNING stage.
submit_to_hf(cfg)
def test_submit_raises_when_wandb_enabled_without_key(monkeypatch):
"""wandb.enable with no key reachable anywhere fails fast, before submitting."""
monkeypatch.setattr("lerobot.jobs.hf.get_token", lambda: "tok")
class FakeHfApi:
def __init__(self, token=None):
pass
def whoami(self, token=None):
return {"name": "alice"}
monkeypatch.setattr("lerobot.jobs.hf.HfApi", FakeHfApi)
monkeypatch.setattr("lerobot.jobs.hf.resolve_wandb_api_key", lambda: None)
cfg = draccus.parse(
TrainPipelineConfig,
args=[
"--dataset.repo_id",
"u/d",
"--policy.type",
"act",
"--job.target",
"a10g-small",
"--wandb.enable",
"true",
],
)
with pytest.raises(ValueError, match="WANDB_API_KEY"):
submit_to_hf(cfg)
@pytest.mark.timeout(15)
def test_submit_raises_when_job_ends_in_error(monkeypatch):
"""A terminal non-COMPLETED stage with no model-pushed marker must raise with the status."""
from types import SimpleNamespace
monkeypatch.setattr("lerobot.jobs.hf.get_token", lambda: "tok")
class FakeHfApi:
def __init__(self, token=None):
pass
def whoami(self, token=None):
return {"name": "alice"}
monkeypatch.setattr("lerobot.jobs.hf.HfApi", FakeHfApi)
monkeypatch.setattr("lerobot.jobs.hf.ensure_dataset_available", lambda *a, **kw: None)
monkeypatch.setattr(
"lerobot.jobs.hf._stage_config_on_hub", lambda cfg, repo_id, token, tags=None: repo_id
)
monkeypatch.setattr("lerobot.jobs.hf.run_job", lambda **kw: SimpleNamespace(id="job-1", url="http://x"))
# Job fails: a terminal ERROR stage carrying the platform's status message.
monkeypatch.setattr(
"lerobot.jobs.hf.inspect_job",
lambda job_id: SimpleNamespace(
status=SimpleNamespace(stage=SimpleNamespace(value="ERROR"), message="Job timeout")
),
)
# Logs end without the model-pushed marker.
monkeypatch.setattr("lerobot.jobs.hf.fetch_job_logs", lambda job_id, follow=True: iter(()))
cfg = draccus.parse(
TrainPipelineConfig,
args=["--dataset.repo_id", "u/d", "--policy.type", "act", "--job.target", "a10g-small"],
)
with pytest.raises(RuntimeError, match=r"stage=ERROR \(Job timeout\)"):
submit_to_hf(cfg)