Compare commits

..

4 Commits

Author SHA1 Message Date
Pepijn 2e752bb828 fix(robots): Use module logger in ensure_safe_goal_position
Route the clamping warning through a module-level logger instead of the
root logger so applications can filter it by name.

Split out of #4183.

Refs #4183
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 22:26:26 +02:00
Pepijn 853af1c3f6 fix(datasets): Resolve only recipe-referenced bindings
Eagerly resolving every DEFAULT_BINDINGS entry made rendering fail on
frames whose events a default binding cannot disambiguate, e.g. the
camera-less vqa default on multi-camera frames, even when the recipe
never references that binding. Add TrainingRecipe.referenced_binding_names()
and skip bindings the recipe does not consume.

Split out of #4183 so the data-layer fix lands independently of the
language runtime.

Refs #4183
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 22:26:24 +02:00
Pepijn 22bd7a2f48 chore: sort imports in vla_jepa tests (#4354)
Pre-existing `I001` violations that `pre-commit run --all-files` auto-fixes.
They are unrelated to the documentation work but block the green run the rest of
this branch is verified against; confirmed present on main before this branch.
2026-08-07 17:30:23 +02:00
Pepijn 6c73c413eb docs: add API documentation infrastructure (#4348)
* docs: add API documentation infrastructure

LeRobot's documentation build passes `--not_python_module`, which tells
doc-builder there is no importable Python package and disables `[[autodoc]]`
entirely. The result is that all 90+ pages are hand-written guides and there is
no generated API reference at all.

This is the machinery to change that. It deliberately contains no docstring
changes of its own — every docstring edit lives in the follow-up PR, so this
one can be reviewed as tooling and configuration alone.

**The standard.** `docs/source/writing_docstrings.mdx` is the contract: Google
section headers with Hugging Face type formatting, the machine-checked argument
line, `**Attributes**:`, doc-builder cross-references, fenced doctest examples.
It also records three behaviours that are not discoverable from the source and
were verified against a local build: `[[autodoc]]` silently skips members with
no docstring; doc-builder does not inherit docstrings from base classes, so a
registered config shim whose body is `pass` renders every field with no
description; and module-level aliases resolve to the canonical class.

**Autodoc turned on**, with two changes that are not obvious:

- `--version main` on the main-docs job. Without `--not_python_module`,
  doc-builder resolves the version from `lerobot.__version__` and only maps it
  to the default branch when it contains "dev". transformers relies on that;
  our main carries 0.6.2. Verified by building both ways — dropping the flag
  alone would publish the main docs to /lerobot/v0.6.2/ instead of
  /lerobot/main/ and disable notebook building.
- `pre_command` on both jobs. doc-builder ships a mock-deps registry entry for
  lerobot, so the reusable workflow takes its light-install path, which cannot
  import the package. The heavy dependencies cannot be mocked either: draccus
  runs `register_subclass` at import time and `processor/converters.py` calls
  `functools.singledispatch.register(torch.Tensor)`, which needs a real class.
  `[dataset]` is the only extra required.

Workflow triggers gain `src/**`, since the reference is now generated from
docstrings. `docs/source/api/` is excluded from the prettier hook, which reads
`[[autodoc]]` member lists as lazy paragraph continuations and joins a ten-entry
list onto one line.

Nine API reference pages, scaffolded with each module's base class.

**Doctests.** `LeRobotDocTestParser` is mandatory rather than optional here:
ruff's `docstring-code-format = true` drops the blank line before a closing
fence, after which stdlib's `_EXAMPLE_RE` reads the fence as expected output and
every example with output fails. It is written against the installed pytest
rather than copied from transformers, whose version predates pytest 9's
`import_path` signature and its own fix for the `@property` line-number bug.
`preprocess_string` also diverges: the upstream fenced-block split puts a
single-line example's code in a chunk with no `>>>` in it, so neither the CUDA
skip nor the `+IGNORE_RESULT` injection fires for it.

**Checkers.** `utils/check_docstrings.py` is the ~300-line core of the
2203-line transformers original; the `@auto_docstring` system, modular
propagation, GitPython and `checkers.py` are not ported.
`utils/check_config_docstrings.py` checks that every registered robot config
documents its port and calibration semantics.

**Gates**, all set to values that pass today: ruff `D` with per-file-ignores
per unconverted module, `interrogate` at `fail-under = 52` against a measured
52.1%, and Makefile targets wired into the quality workflow. The doctest
allowlist ships empty and the `doctest` target handles that, because the files
carrying runnable examples arrive with the docstring PR.

* ci: build the docs on Python 3.12

The shared doc-builder workflows create their virtualenv with the runner's
system Python, which is 3.10.12 on ubuntu-22.04. lerobot requires >=3.12, so
the build died during "Setup environment":

    × No solution found when resolving dependencies:
    ╰─▶ Because the current Python version (3.10.12) does not satisfy
        Python>=3.12 and lerobot==0.6.2 depends on Python>=3.12 ...

That step runs before `pre_command`, so the real install this workflow already
performs never got the chance to run. There was no fix available on the caller
side either: `env:` does not propagate into a reusable workflow, so `UV_PYTHON`
is unavailable, and `uv venv` runs in the runner workspace root rather than the
checkout, so a `.python-version` file cannot reach it. The non-light fallback
(`uv pip install "./pkg[dev]"`) fails identically, so this is not specific to
the mock-deps path — it blocks any package requiring 3.12+.

huggingface/doc-builder#808 adds a `python_version` input to both build
workflows, which this passes. Pins move to that merge commit, picking up three
unrelated fixes in the same range (#810, #811, #812); the upload workflow is
unchanged there and is bumped only to keep all three pins on one SHA.

* chore: sort imports in vla_jepa tests

Enabling pydocstyle in the previous commit changes how ruff determines where a
module's import block ends, which makes I001 fire on three vla_jepa tests that
were clean before. The blank line between the `conftest` and `lerobot` imports
is the trigger: both are first-party, so isort wants them in one contiguous
block, and the docstring-aware analysis is what makes it notice.

These files are unrelated to the API reference, so the fix is only to satisfy
the new gate.
2026-08-07 15:55:25 +02:00
4 changed files with 45 additions and 2 deletions
+7
View File
@@ -192,6 +192,13 @@ class TrainingRecipe:
if recipe.weight <= 0:
raise ValueError(f"Blend component {name!r} must have a positive weight.")
def referenced_binding_names(self) -> set[str]:
"""Names of every binding referenced by this recipe's message turns."""
names: set[str] = set()
for turn in self.messages or []:
names |= self._referenced_bindings(turn)
return names
def _referenced_bindings(self, turn: MessageTurn) -> set[str]:
"""Return the binding names that ``turn`` references via placeholders or attributes."""
names: set[str] = set()
+7 -1
View File
@@ -290,8 +290,14 @@ def _resolve_bindings(
bindings: dict[str, LanguageRow | str | None] = {
"task": _resolve_task(task, dataset_ctx, persistent=persistent, sample_idx=sample_idx),
}
specs = {**DEFAULT_BINDINGS, **(recipe.bindings or {})}
declared = recipe.bindings or {}
specs = {**DEFAULT_BINDINGS, **declared}
# Only resolve bindings the recipe consumes: an unreferenced default may be
# unresolvable, e.g. the camera-less ``vqa`` default on multi-camera frames.
needed = recipe.referenced_binding_names() | set(declared)
for name, spec in specs.items():
if name not in needed:
continue
bindings[name] = _resolve_spec(spec, persistent=persistent, events=events, t=t)
return bindings
+3 -1
View File
@@ -21,6 +21,8 @@ from lerobot.utils.import_utils import make_device_from_device_class
from .config import RobotConfig
from .robot import Robot
logger = logging.getLogger(__name__)
def make_robot_from_config(config: RobotConfig) -> Robot:
# TODO(Steven): Consider just using the make_device_from_device_class for all types
@@ -118,7 +120,7 @@ def ensure_safe_goal_position(
}
if warnings_dict:
logging.warning(
logger.warning(
"Relative goal position magnitude had to be clamped to be safe.\n"
f"{pformat(warnings_dict, indent=4)}"
)
+28
View File
@@ -197,6 +197,34 @@ def test_emitted_at_filters_vqa_by_camera():
assert wrist["content"] == '{"count": 1}'
def test_unreferenced_default_bindings_are_not_resolved():
# A recipe that never references ``vqa`` must render on frames carrying
# multi-camera VQA events, which the camera-less default ``vqa`` binding
# cannot disambiguate (regression: eager DEFAULT_BINDINGS resolution).
recipe = TrainingRecipe(
messages=[
MessageTurn(role="user", content="${task}", stream="low_level"),
MessageTurn(
role="assistant",
content="${subtask}",
stream="low_level",
target=True,
if_present="subtask",
),
]
)
rendered = render_sample(
recipe=recipe,
persistent=PERSISTENT,
events=EVENTS_AT_3_TWO_CAMERAS,
t=3.0,
sample_idx=0,
task="tidy the table",
)
assert rendered is not None
assert rendered["messages"][1]["content"] == "subtask 1"
def test_emitted_at_raises_on_ambiguous_per_camera_vqa():
with pytest.raises(ValueError, match="Ambiguous resolver"):
emitted_at(