mirror of
https://github.com/huggingface/lerobot.git
synced 2026-08-08 17:39:44 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fde5db8406 | |||
| 39c4e746f1 | |||
| d3ee0b820c | |||
| 072c697c0e |
@@ -33,7 +33,7 @@ jobs:
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.repository == 'huggingface/lerobot'
|
||||
uses: huggingface/doc-builder/.github/workflows/upload_pr_documentation.yml@931031bf2b54aabb134ceb54980a6a2860a00f11 # main
|
||||
uses: huggingface/doc-builder/.github/workflows/upload_pr_documentation.yml@6108e850ae1cf2f71bb0815a600bcd50c39abfa7 # main
|
||||
with:
|
||||
package_name: lerobot
|
||||
secrets:
|
||||
|
||||
@@ -24,24 +24,19 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
# Triggers on pushes to main that touch the docs or the sources the API reference is generated from.
|
||||
# `src/**` is included because the API reference is built from docstrings via `[[autodoc]]`: without it,
|
||||
# published API pages would go stale as soon as a docstring changed.
|
||||
# Triggers the workflow on push events to main for the docs folder
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "src/**"
|
||||
|
||||
# Same for pull requests, so a docstring change gets a preview build and a broken `[[autodoc]]` path
|
||||
# fails the PR rather than main.
|
||||
# Triggers the workflow on pull request events targeting main for the docs folder
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "src/**"
|
||||
|
||||
release:
|
||||
types: [published]
|
||||
@@ -60,29 +55,16 @@ jobs:
|
||||
github.repository == 'huggingface/lerobot'
|
||||
permissions:
|
||||
contents: read
|
||||
uses: huggingface/doc-builder/.github/workflows/build_main_documentation.yml@931031bf2b54aabb134ceb54980a6a2860a00f11 # main
|
||||
uses: huggingface/doc-builder/.github/workflows/build_main_documentation.yml@6108e850ae1cf2f71bb0815a600bcd50c39abfa7 # main
|
||||
with:
|
||||
commit_sha: ${{ github.sha }}
|
||||
package: lerobot
|
||||
# The shared workflow builds its venv with the runner's system Python, which is 3.10 on
|
||||
# ubuntu-22.04. lerobot requires >=3.12, so without this the install fails during setup —
|
||||
# before `pre_command` below ever runs. Added upstream in huggingface/doc-builder#808.
|
||||
python_version: "3.12"
|
||||
# doc-builder ships a mock-deps registry entry for lerobot, so the reusable workflow takes its
|
||||
# "light install" path: `pip install ./lerobot --no-deps` plus a handful of real dependencies.
|
||||
# That is not enough to import lerobot — draccus runs `register_subclass` at import time and
|
||||
# `processor/converters.py` calls `functools.singledispatch.register(torch.Tensor)`, neither of
|
||||
# which works against a mock. Install the package for real before the build.
|
||||
pre_command: uv pip install "./lerobot[dataset]"
|
||||
# `--version main` is load-bearing: without `--not_python_module`, doc-builder falls back to
|
||||
# `lerobot.__version__` and only maps that to the default branch when it contains "dev". Our main
|
||||
# branch carries a release version (0.6.2), so omitting this would publish the main docs to
|
||||
# /lerobot/v0.6.2/ instead of /lerobot/main/ and disable notebook building.
|
||||
additional_args: >-
|
||||
--not_python_module
|
||||
${{
|
||||
(github.event_name == 'release' && format('--version {0}', github.event.release.tag_name)) ||
|
||||
(inputs.version != '' && format('--version {0}', inputs.version)) ||
|
||||
'--version main'
|
||||
''
|
||||
}}
|
||||
secrets:
|
||||
token: ${{ secrets.HUGGINGFACE_PUSH }}
|
||||
@@ -96,12 +78,9 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
uses: huggingface/doc-builder/.github/workflows/build_pr_documentation.yml@931031bf2b54aabb134ceb54980a6a2860a00f11 # main
|
||||
uses: huggingface/doc-builder/.github/workflows/build_pr_documentation.yml@6108e850ae1cf2f71bb0815a600bcd50c39abfa7 # main
|
||||
with:
|
||||
commit_sha: ${{ github.event.pull_request.head.sha }}
|
||||
pr_number: ${{ github.event.number }}
|
||||
package: lerobot
|
||||
# See the comment on build_main_docs. The PR workflow passes its own `--version pr_<n>`, so no
|
||||
# additional_args are needed here.
|
||||
python_version: "3.12"
|
||||
pre_command: uv pip install "./lerobot[dataset]"
|
||||
additional_args: --not_python_module
|
||||
|
||||
@@ -56,41 +56,3 @@ jobs:
|
||||
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
|
||||
with:
|
||||
extra_args: --all-files --show-diff-on-failure --color=always
|
||||
|
||||
# This job runs the examples in our docstrings and validates the doctest allowlist.
|
||||
# See docs/source/writing_docstrings.mdx for the standard these enforce.
|
||||
doc-checks:
|
||||
name: Run Documentation Checks (Doctests)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Examples that need a physical robot, a serial port or a Hub download are skipped by content.
|
||||
# Everything else has to actually run. See src/lerobot/utils/doctest_utils.py.
|
||||
SKIP_HARDWARE_DOCTEST: "1"
|
||||
SKIP_CUDA_DOCTEST: "1"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup uv and Python
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
version: "0.11.30"
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra test --extra dataset
|
||||
|
||||
- name: Check the doctest list is sorted and its paths exist
|
||||
run: make check-doctest-list
|
||||
|
||||
- name: Check documented arguments match their signatures
|
||||
run: make check-docstrings
|
||||
|
||||
- name: Check docstring coverage has not regressed
|
||||
run: uv run --with interrogate interrogate --config=pyproject.toml
|
||||
|
||||
- name: Run doctests
|
||||
run: make doctest
|
||||
|
||||
+2
-11
@@ -67,11 +67,7 @@ repos:
|
||||
args: [--prose-wrap=preserve]
|
||||
# Jinja2 model-card templates use a .md extension but contain {% ... %} /
|
||||
# {{ ... }} tags that prettier's Markdown formatter mangles (e.g. table loops).
|
||||
#
|
||||
# docs/source/api/ holds the generated API reference. Its `[[autodoc]]` blocks restrict output
|
||||
# to an indented `- member` list, which prettier reads as a lazy paragraph continuation and
|
||||
# joins onto one line — silently turning a member list into part of the directive.
|
||||
exclude: ^(src/lerobot/templates/.*\.md|docs/source/api/.*\.mdx)$
|
||||
exclude: ^src/lerobot/templates/.*\.md$
|
||||
|
||||
##### Security #####
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
@@ -108,13 +104,8 @@ repos:
|
||||
# args: ["--docstring-style", "google", "-v", "2"]
|
||||
# exclude: ^tests/.*$
|
||||
|
||||
# interrogate runs in CI (quality.yml, doc-checks job) rather than here. Its 1.7.0 release still imports
|
||||
# the deprecated `py` package, which resolves against whatever `py` happens to be importable in
|
||||
# pre-commit's isolated env — on a machine with miniconda on the path that is a stray `py.py` and the
|
||||
# hook dies before it reads any config. The gate is the same either way; the CI step is just reliable.
|
||||
# - repo: https://github.com/econchick/interrogate
|
||||
# rev: 1.7.0
|
||||
# hooks:
|
||||
# - id: interrogate
|
||||
# args: ["--config=pyproject.toml"]
|
||||
# pass_filenames: false
|
||||
# args: ["-vv", "--config=pyproject.toml"]
|
||||
|
||||
@@ -50,10 +50,6 @@ To run checks manually on all files:
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
### Docstrings
|
||||
|
||||
The API reference is generated from the docstrings in `src/lerobot/`. If you add or change anything public, follow the [docstring standard](https://huggingface.co/docs/lerobot/writing_docstrings) — the format is parsed by the renderer and checked in CI.
|
||||
|
||||
### Running Tests
|
||||
|
||||
We use `pytest`. First, ensure you have test artifacts by installing **git-lfs**:
|
||||
|
||||
@@ -184,29 +184,3 @@ test-smolvla-ete-eval:
|
||||
# backend, so it does not require a real model checkpoint or GPU.
|
||||
annotation-e2e:
|
||||
uv run python -m tests.annotations.run_e2e_smoke
|
||||
|
||||
# Docstring & doctest checks. See docs/source/writing_docstrings.mdx for the standard these enforce.
|
||||
|
||||
# Run the examples in the docstrings listed in utils/documentation_tests.txt. Hardware and GPU examples are
|
||||
# skipped by content (see src/lerobot/utils/doctest_utils.py); CI sets both flags.
|
||||
doctest:
|
||||
@files=$$(grep -v '^\s*#' utils/documentation_tests.txt | grep -v '^\s*$$'); \
|
||||
if [ -z "$$files" ]; then \
|
||||
echo "utils/documentation_tests.txt lists no files; nothing to run."; \
|
||||
else \
|
||||
SKIP_HARDWARE_DOCTEST=1 uv run pytest --doctest-modules --no-header -q $$files; \
|
||||
fi
|
||||
|
||||
check-doctest-list:
|
||||
uv run python utils/check_doctest_list.py
|
||||
|
||||
fix-doctest-list:
|
||||
uv run python utils/check_doctest_list.py --fix_and_overwrite
|
||||
|
||||
check-docstrings:
|
||||
uv run python utils/check_docstrings.py
|
||||
uv run python utils/check_config_docstrings.py
|
||||
|
||||
fix-docstrings:
|
||||
uv run python utils/check_docstrings.py --fix_and_overwrite
|
||||
uv run python utils/check_doctest_list.py --fix_and_overwrite
|
||||
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
"""Root conftest: makes doctest collection use LeRobot's parser.
|
||||
|
||||
This only affects `--doctest-modules` runs (see `make doctest`). The test suite itself is configured by
|
||||
`tests/conftest.py`.
|
||||
"""
|
||||
|
||||
import doctest
|
||||
|
||||
import _pytest.doctest
|
||||
|
||||
from lerobot.utils.doctest_utils import LeRobotDoctestModule, LeRobotDocTestParser
|
||||
|
||||
# Lets an example opt out of output comparison with `# doctest: +IGNORE_RESULT`, for calls whose output is
|
||||
# a progress bar or otherwise not reproducible.
|
||||
IGNORE_RESULT = doctest.register_optionflag("IGNORE_RESULT")
|
||||
|
||||
OutputChecker = doctest.OutputChecker
|
||||
|
||||
|
||||
class CustomOutputChecker(OutputChecker):
|
||||
"""An output checker that honours the `IGNORE_RESULT` flag."""
|
||||
|
||||
def check_output(self, want, got, optionflags):
|
||||
"""Return `True` when `IGNORE_RESULT` is set, otherwise defer to stdlib.
|
||||
|
||||
Args:
|
||||
want (`str`):
|
||||
The expected output.
|
||||
got (`str`):
|
||||
The actual output.
|
||||
optionflags (`int`):
|
||||
Bitmask of active doctest option flags.
|
||||
|
||||
Returns:
|
||||
`bool`: Whether the output is considered a match.
|
||||
"""
|
||||
if IGNORE_RESULT & optionflags:
|
||||
return True
|
||||
return OutputChecker.check_output(self, want, got, optionflags)
|
||||
|
||||
|
||||
# Reassigning these module attributes is how doctest behaviour is customised; mypy sees it as assigning to
|
||||
# a type, which is exactly what is intended here.
|
||||
doctest.OutputChecker = CustomOutputChecker # type: ignore[misc]
|
||||
_pytest.doctest.DoctestModule = LeRobotDoctestModule
|
||||
doctest.DocTestParser = LeRobotDocTestParser # type: ignore[misc]
|
||||
@@ -191,28 +191,6 @@
|
||||
- sections:
|
||||
- local: contributing
|
||||
title: Contribute to LeRobot
|
||||
- local: writing_docstrings
|
||||
title: Writing docstrings
|
||||
- local: backwardcomp
|
||||
title: Backward compatibility
|
||||
title: "About"
|
||||
- sections:
|
||||
- local: api/robots
|
||||
title: Robots
|
||||
- local: api/teleoperators
|
||||
title: Teleoperators
|
||||
- local: api/cameras
|
||||
title: Cameras
|
||||
- local: api/motors
|
||||
title: Motors
|
||||
- local: api/datasets
|
||||
title: Datasets
|
||||
- local: api/policies
|
||||
title: Policies
|
||||
- local: api/processor
|
||||
title: Processors
|
||||
- local: api/envs
|
||||
title: Environments
|
||||
- local: api/configs
|
||||
title: Configuration
|
||||
title: "API Reference"
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Cameras
|
||||
|
||||
Cameras supply the image observations a policy sees. Every backend — OpenCV, Intel RealSense, Reachy 2 —
|
||||
implements the [`Camera`] interface, so swapping hardware does not change the code that reads frames.
|
||||
|
||||
See the [Cameras guide](../cameras) for choosing and configuring a camera, and
|
||||
[Third-Party Cameras & Sensors](../third_party_sensors) for devices outside the core set.
|
||||
|
||||
## Camera
|
||||
|
||||
[[autodoc]] lerobot.cameras.Camera
|
||||
- connect
|
||||
- disconnect
|
||||
- read
|
||||
- async_read
|
||||
- find_cameras
|
||||
|
||||
## CameraConfig
|
||||
|
||||
[[autodoc]] lerobot.cameras.CameraConfig
|
||||
|
||||
## make_cameras_from_configs
|
||||
|
||||
[[autodoc]] lerobot.cameras.make_cameras_from_configs
|
||||
@@ -1,27 +0,0 @@
|
||||
# Configuration
|
||||
|
||||
LeRobot configuration is plain dataclasses parsed by [draccus](https://github.com/dlwh/draccus), so every
|
||||
field is settable from the CLI. [`TrainPipelineConfig`] is the top-level object for `lerobot-train`.
|
||||
|
||||
Polymorphic configs (policies, robots, environments) use `draccus.ChoiceRegistry`: a subclass registers
|
||||
itself with `@register_subclass("name")` and is then selectable by that name on the command line.
|
||||
|
||||
## TrainPipelineConfig
|
||||
|
||||
[[autodoc]] lerobot.configs.train.TrainPipelineConfig
|
||||
|
||||
## PreTrainedConfig
|
||||
|
||||
[[autodoc]] lerobot.configs.PreTrainedConfig
|
||||
|
||||
## DatasetConfig
|
||||
|
||||
[[autodoc]] lerobot.configs.DatasetConfig
|
||||
|
||||
## EvalConfig
|
||||
|
||||
[[autodoc]] lerobot.configs.EvalConfig
|
||||
|
||||
## WandBConfig
|
||||
|
||||
[[autodoc]] lerobot.configs.WandBConfig
|
||||
@@ -1,23 +0,0 @@
|
||||
# Datasets
|
||||
|
||||
[`LeRobotDataset`] is the format every LeRobot script reads and writes. It is episode-aware, decodes video
|
||||
observations on the fly, and round-trips to the Hugging Face Hub.
|
||||
|
||||
See [Using LeRobotDataset](../lerobot-dataset-v3) for the format and the common operations,
|
||||
[Porting Large Datasets](../porting_datasets_v3) for migration, and [Tools](../tools) for the CLI.
|
||||
|
||||
## LeRobotDataset
|
||||
|
||||
[[autodoc]] lerobot.datasets.LeRobotDataset
|
||||
|
||||
## LeRobotDatasetMetadata
|
||||
|
||||
[[autodoc]] lerobot.datasets.LeRobotDatasetMetadata
|
||||
|
||||
## MultiLeRobotDataset
|
||||
|
||||
[[autodoc]] lerobot.datasets.MultiLeRobotDataset
|
||||
|
||||
## StreamingLeRobotDataset
|
||||
|
||||
[[autodoc]] lerobot.datasets.StreamingLeRobotDataset
|
||||
@@ -1,19 +0,0 @@
|
||||
# Environments
|
||||
|
||||
Simulation environments are configured through [`EnvConfig`] and built by [`make_env`]. Each subclass
|
||||
declares its `gym_kwargs` and how to construct the vectorised environments.
|
||||
|
||||
See [Environments from the Hub](../envhub) for using published environments and
|
||||
[Adding a New Benchmark](../adding_benchmarks) for contributing one.
|
||||
|
||||
## EnvConfig
|
||||
|
||||
[[autodoc]] lerobot.envs.EnvConfig
|
||||
|
||||
## make_env
|
||||
|
||||
[[autodoc]] lerobot.envs.make_env
|
||||
|
||||
## make_env_config
|
||||
|
||||
[[autodoc]] lerobot.envs.make_env_config
|
||||
@@ -1,23 +0,0 @@
|
||||
# Motors
|
||||
|
||||
`MotorsBus` is the low-level interface to a chain of servos on a serial bus. Robots use it to read positions
|
||||
and write goal positions; you rarely touch it directly unless you are adding hardware.
|
||||
|
||||
See [Bring Your Own Hardware](../integrate_hardware) for adding a new bus, and
|
||||
[Updating Feetech Firmware](../feetech) and [Damiao Motors and CAN Bus](../damiao) for device-specific notes.
|
||||
|
||||
## MotorsBus
|
||||
|
||||
[[autodoc]] lerobot.motors.motors_bus.MotorsBus
|
||||
|
||||
## Motor
|
||||
|
||||
[[autodoc]] lerobot.motors.Motor
|
||||
|
||||
## MotorCalibration
|
||||
|
||||
[[autodoc]] lerobot.motors.MotorCalibration
|
||||
|
||||
## MotorNormMode
|
||||
|
||||
[[autodoc]] lerobot.motors.MotorNormMode
|
||||
@@ -1,20 +0,0 @@
|
||||
# Policies
|
||||
|
||||
Every policy inherits [`PreTrainedPolicy`], which combines a `torch.nn.Module` with the Hub mixin, so any
|
||||
policy can be pushed to and loaded from the Hugging Face Hub with the same two calls.
|
||||
|
||||
Each policy has its own guide with training recipes and results — [ACT](../act), [SmolVLA](../smolvla),
|
||||
[π₀](../pi0), [π₀.₅](../pi05) and the rest are listed under Policies. To add one, see
|
||||
[Adding a Policy](../bring_your_own_policies).
|
||||
|
||||
## PreTrainedPolicy
|
||||
|
||||
[[autodoc]] lerobot.policies.pretrained.PreTrainedPolicy
|
||||
|
||||
## PreTrainedConfig
|
||||
|
||||
[[autodoc]] lerobot.configs.PreTrainedConfig
|
||||
|
||||
## make_policy
|
||||
|
||||
[[autodoc]] lerobot.policies.factory.make_policy
|
||||
@@ -1,20 +0,0 @@
|
||||
# Processors
|
||||
|
||||
Processors are the data transformation layer between a robot, a dataset and a policy. A pipeline is a chain
|
||||
of [`ProcessorStep`]s; each step declares how it transforms both the data and the feature contract.
|
||||
|
||||
See [Introduction to Robot Processors](../introduction_processors) for the concepts,
|
||||
[Implement your own processor](../implement_your_own_processor) to write a step, and
|
||||
[Debug your processor pipeline](../debug_processor_pipeline) when a pipeline misbehaves.
|
||||
|
||||
## ProcessorStep
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.ProcessorStep
|
||||
|
||||
## DataProcessorPipeline
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.DataProcessorPipeline
|
||||
|
||||
## PolicyProcessorPipeline
|
||||
|
||||
[[autodoc]] lerobot.processor.pipeline.PolicyProcessorPipeline
|
||||
@@ -1,147 +0,0 @@
|
||||
# Robots
|
||||
|
||||
Every robot in LeRobot implements the [`Robot`] interface: connect, read an observation, send an action,
|
||||
disconnect. Writing a policy or a recording script against that interface means it works with any supported
|
||||
arm without change.
|
||||
|
||||
This page is the generated reference. For wiring, calibration and first-run instructions, start with the
|
||||
hardware guides — [SO-101](../so101), [LeKiwi](../lekiwi), [Hope Jr](../hope_jr), [Reachy 2](../reachy2),
|
||||
[OpenArm](../openarm) — or [Imitation Learning for Robots](../il_robots) for the end-to-end workflow. To add
|
||||
a robot of your own, see [Bring Your Own Hardware](../integrate_hardware).
|
||||
|
||||
## Robot
|
||||
|
||||
The abstract base class. Subclasses implement every method below; the contract described here is what a
|
||||
policy or recording loop can rely on.
|
||||
|
||||
[[autodoc]] lerobot.robots.Robot
|
||||
- connect
|
||||
- disconnect
|
||||
- configure
|
||||
- calibrate
|
||||
- get_observation
|
||||
- send_action
|
||||
- observation_features
|
||||
- action_features
|
||||
- is_connected
|
||||
- is_calibrated
|
||||
|
||||
## RobotConfig
|
||||
|
||||
[[autodoc]] lerobot.robots.RobotConfig
|
||||
|
||||
## make_robot_from_config
|
||||
|
||||
[[autodoc]] lerobot.robots.make_robot_from_config
|
||||
|
||||
## SO-100 and SO-101 followers
|
||||
|
||||
`SO100Follower` and `SO101Follower` are aliases of the same `SOFollower` class; the two arms differ in their
|
||||
configuration, not their control code. `SO100FollowerConfig` and `SO101FollowerConfig` are likewise aliases
|
||||
of `SOFollowerRobotConfig`.
|
||||
|
||||
[[autodoc]] lerobot.robots.so_follower.SOFollower
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.so_follower.SOFollowerRobotConfig
|
||||
|
||||
## BiSOFollower
|
||||
|
||||
Two SO followers driven as one bimanual robot.
|
||||
|
||||
[[autodoc]] lerobot.robots.bi_so_follower.BiSOFollower
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.bi_so_follower.BiSOFollowerConfig
|
||||
|
||||
## KochFollower
|
||||
|
||||
[[autodoc]] lerobot.robots.koch_follower.KochFollower
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.koch_follower.KochFollowerConfig
|
||||
|
||||
## LeKiwi
|
||||
|
||||
`LeKiwi` runs on the robot itself. `LeKiwiClient` is the host-side proxy that talks to it over the network
|
||||
and presents the same [`Robot`] interface.
|
||||
|
||||
[[autodoc]] lerobot.robots.lekiwi.LeKiwi
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.lekiwi.LeKiwiConfig
|
||||
|
||||
[[autodoc]] lerobot.robots.lekiwi.LeKiwiClient
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.lekiwi.LeKiwiClientConfig
|
||||
|
||||
## OpenArmFollower
|
||||
|
||||
[[autodoc]] lerobot.robots.openarm_follower.OpenArmFollower
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.openarm_follower.OpenArmFollowerConfig
|
||||
|
||||
## BiOpenArmFollower
|
||||
|
||||
[[autodoc]] lerobot.robots.bi_openarm_follower.BiOpenArmFollower
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.bi_openarm_follower.BiOpenArmFollowerConfig
|
||||
|
||||
## OmxFollower
|
||||
|
||||
[[autodoc]] lerobot.robots.omx_follower.OmxFollower
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.omx_follower.OmxFollowerConfig
|
||||
|
||||
## Reachy2Robot
|
||||
|
||||
[[autodoc]] lerobot.robots.reachy2.Reachy2Robot
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.reachy2.Reachy2RobotConfig
|
||||
|
||||
## UnitreeG1
|
||||
|
||||
[[autodoc]] lerobot.robots.unitree_g1.UnitreeG1
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.unitree_g1.UnitreeG1Config
|
||||
|
||||
## Hope Jr
|
||||
|
||||
The Hope Jr humanoid is exposed as two independent robots, an arm and a hand.
|
||||
|
||||
[[autodoc]] lerobot.robots.hope_jr.HopeJrArm
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.hope_jr.HopeJrArmConfig
|
||||
|
||||
[[autodoc]] lerobot.robots.hope_jr.HopeJrHand
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.hope_jr.HopeJrHandConfig
|
||||
|
||||
## RebotB601Follower
|
||||
|
||||
[[autodoc]] lerobot.robots.rebot_b601_follower.RebotB601Follower
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.rebot_b601_follower.RebotB601FollowerRobotConfig
|
||||
|
||||
## BiRebotB601Follower
|
||||
|
||||
[[autodoc]] lerobot.robots.bi_rebot_b601_follower.BiRebotB601Follower
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.bi_rebot_b601_follower.BiRebotB601FollowerConfig
|
||||
|
||||
## EarthRoverMiniPlus
|
||||
|
||||
[[autodoc]] lerobot.robots.earthrover_mini_plus.EarthRoverMiniPlus
|
||||
- all
|
||||
|
||||
[[autodoc]] lerobot.robots.earthrover_mini_plus.EarthRoverMiniPlusConfig
|
||||
@@ -1,30 +0,0 @@
|
||||
# Teleoperators
|
||||
|
||||
A teleoperator produces actions for a robot to follow — a leader arm, a gamepad, a keyboard, a phone. All of
|
||||
them implement the [`Teleoperator`] interface, so a recording script written against it works with any input
|
||||
device.
|
||||
|
||||
See [Phone teleoperation](../phone_teleop) and [Isaac Teleop](../isaac_teleop) for setup guides, and
|
||||
[Imitation Learning for Robots](../il_robots) for the recording workflow.
|
||||
|
||||
## Teleoperator
|
||||
|
||||
[[autodoc]] lerobot.teleoperators.Teleoperator
|
||||
- connect
|
||||
- disconnect
|
||||
- configure
|
||||
- calibrate
|
||||
- get_action
|
||||
- send_feedback
|
||||
- action_features
|
||||
- feedback_features
|
||||
- is_connected
|
||||
- is_calibrated
|
||||
|
||||
## TeleoperatorConfig
|
||||
|
||||
[[autodoc]] lerobot.teleoperators.TeleoperatorConfig
|
||||
|
||||
## make_teleoperator_from_config
|
||||
|
||||
[[autodoc]] lerobot.teleoperators.make_teleoperator_from_config
|
||||
+81
-16
@@ -241,24 +241,89 @@ See the [Real-Time Chunking](./rtc) guide for details on tuning RTC parameters.
|
||||
|
||||
---
|
||||
|
||||
## Interactive Sessions
|
||||
|
||||
Add `--interactive=true` to drive the rollout from the terminal instead of starting immediately. Hardware connects and the policy loads as usual, but **the robot stays still until you type `/start`** — useful when you want to position the scene first, re-instruct the policy between attempts, or run several takes without paying the load time again.
|
||||
|
||||
```bash
|
||||
lerobot-rollout \
|
||||
--strategy.type=base \
|
||||
--policy.path=${HF_USER}/my_smolvla_policy \
|
||||
--robot.type=so100_follower \
|
||||
--robot.port=/dev/ttyACM0 \
|
||||
--robot.cameras="{ front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}}" \
|
||||
--task="pick up the cube" \
|
||||
--interactive=true
|
||||
```
|
||||
|
||||
| Command | Action |
|
||||
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/start` | Start (or restart) the policy control loop |
|
||||
| `/subtask <text>` | Change the instruction the policy follows, without stopping. No argument prints the current task. Only affects policies that condition on language (SmolVLA, π0/π0.5, and similar) |
|
||||
| `/ask <question>` | Ask a supported policy text head about its latest view. The answer is generated in the background without pausing the session |
|
||||
| `/reset` | Stop movement, return the robot to its startup position, and restore the `--task` instruction |
|
||||
| `/stop` | End the session and run the normal shutdown routines |
|
||||
| `/help` | List the commands |
|
||||
|
||||
```text
|
||||
> /start
|
||||
Rollout running — task 'pick up the cube'. /subtask <text> to change it, ...
|
||||
> /subtask put the cube in the box
|
||||
Task: 'pick up the cube' → 'put the cube in the box' (applies from the next policy inference)
|
||||
> /ask where is the red cube?
|
||||
Question queued: 'where is the red cube?' (the rollout keeps running)
|
||||
[policy] The red cube is beside the bowl.
|
||||
> /reset
|
||||
Task restored to 'pick up the cube'
|
||||
Resetting — returning the robot to its initial position...
|
||||
Robot reset — holding at initial position. /start to run.
|
||||
> /stop
|
||||
```
|
||||
|
||||
`Ctrl-C` still shuts down as usual, and closing stdin (`Ctrl-D`, or the end of a piped script) ends the session — so a piped script must keep stdin open for the intended duration:
|
||||
|
||||
```bash
|
||||
(printf '/start\n'; sleep 60; printf '/stop\n') | lerobot-rollout ... --interactive=true
|
||||
```
|
||||
|
||||
**How `/subtask` reaches the policy.** The stdin reader publishes the new instruction to the inference engine, which picks it up on its own inference thread, so nothing is mutated across threads while the robot is moving. How quickly the behavior changes depends on the backend:
|
||||
|
||||
- **Sync** (`--inference.type=sync`) — precomputed chunk actions are dropped, so the new instruction applies on the very next control tick. Without this a chunking policy would keep executing up to `chunk_size` stale actions (seconds of the old behavior). Only the queued actions are discarded, so observation history and the rest of the episode state are preserved.
|
||||
- **RTC** (`--inference.type=rtc`) — the next chunk is generated under the new instruction and merged over the previous chunk's leftover prefix, so the switch lands within one inference and the motion stays continuous. The queue is deliberately not cleared: that would leave the robot without commands for a full inference latency. (With blending turned off via `--inference.rtc.enabled=false` the queued chunk drains first, so the switch lands up to one chunk later.)
|
||||
|
||||
With `--use_torch_compile=true`, a switch whose instruction tokenizes to a different length can trigger a recompilation on the next forward pass, pausing inference for as long as the original warm-up took. Prefer leaving compilation off for sessions where you expect to re-instruct the policy often.
|
||||
|
||||
**How `/ask` runs without taking over the rollout.** During an active rollout, the inference engine caches the latest policy-ready observation, so the command reader never touches cameras, processors, or robot hardware. A single background worker sends that snapshot to the optional `PreTrainedPolicy.generate_text(..., kind=TextKind.VQA, user_text=question)` hook and prints the result when ready. Questions are independent turns; there is no conversation history, and a second question is rejected while one is running so stale image tensors cannot accumulate. WALL-OSS (`wall_x`) is the first policy implementing this hook; policies without a compatible text head report that `/ask` is unsupported.
|
||||
|
||||
Text and action calls share one policy safely: the engine gives a pending question priority after the current action inference finishes, while action inference uses a non-blocking gate. The hardware loop therefore keeps ticking and `/ask` never clears an action queue. RTC continues dispatching its buffered actions while text is decoded. Sync keeps the robot on its last commanded target until the policy is available again. Text generation still consumes model/GPU capacity, so response generation can reduce action freshness; RTC is preferred when uninterrupted action buffering matters.
|
||||
|
||||
`/stop` suppresses any late answer and gives an active decoder five seconds to finish cleanly. If it is stuck, hardware teardown continues rather than leaving the robot session open indefinitely; the daemon may retain its model/GPU resources until it returns.
|
||||
|
||||
**Console logs are muted while the session runs** so they don't interleave with what you're typing; they resume when it ends. A fatal inference error is still printed. Run without `--interactive` to watch the live log.
|
||||
|
||||
Interactive sessions currently require `--strategy.type=base`: the recording strategies finalize their dataset when their loop exits, so they cannot be restarted by `/start`, and their keyboard controls would compete for the same terminal.
|
||||
|
||||
---
|
||||
|
||||
## Common Flags
|
||||
|
||||
| Flag | Description | Default |
|
||||
| --------------------------------- | ----------------------------------------------------------------- | ------- |
|
||||
| `--policy.path` | **Required.** HF Hub model ID or local checkpoint path | -- |
|
||||
| `--robot.type` | **Required.** Robot type (e.g. `so100_follower`, `koch_follower`) | -- |
|
||||
| `--robot.port` | Serial port for the robot | -- |
|
||||
| `--robot.cameras` | Camera configuration (JSON dict) | -- |
|
||||
| `--fps` | Control loop frequency | 30 |
|
||||
| `--duration` | Run time in seconds (0 = infinite) | 0 |
|
||||
| `--device` | Torch device (`cpu`, `cuda`, `mps`) | auto |
|
||||
| `--task` | Task description (used when no dataset is provided) | -- |
|
||||
| `--display_data` | Stream telemetry to Rerun visualization | false |
|
||||
| `--display_ip` / `--display_port` | Remote Rerun server address | -- |
|
||||
| `--interpolation_multiplier` | Action interpolation factor | 1 |
|
||||
| `--use_torch_compile` | Enable `torch.compile` for inference | false |
|
||||
| `--resume` | Resume a previous recording session | false |
|
||||
| `--play_sounds` | Vocal synthesis for events | true |
|
||||
| Flag | Description | Default |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `--policy.path` | **Required.** HF Hub model ID or local checkpoint path | -- |
|
||||
| `--robot.type` | **Required.** Robot type (e.g. `so100_follower`, `koch_follower`) | -- |
|
||||
| `--robot.port` | Serial port for the robot | -- |
|
||||
| `--robot.cameras` | Camera configuration (JSON dict) | -- |
|
||||
| `--fps` | Control loop frequency | 30 |
|
||||
| `--duration` | Run time in seconds (0 = infinite) | 0 |
|
||||
| `--device` | Torch device (`cpu`, `cuda`, `mps`) | auto |
|
||||
| `--task` | Task description (used when no dataset is provided) | -- |
|
||||
| `--display_data` | Stream telemetry to Rerun visualization | false |
|
||||
| `--display_ip` / `--display_port` | Remote Rerun server address | -- |
|
||||
| `--interpolation_multiplier` | Action interpolation factor | 1 |
|
||||
| `--interactive` | Chat-style stdin session (see [Interactive Sessions](#interactive-sessions)); the robot stays idle until `/start`. Base strategy only | false |
|
||||
| `--use_torch_compile` | Enable `torch.compile` for inference | false |
|
||||
| `--resume` | Resume a previous recording session | false |
|
||||
| `--play_sounds` | Vocal synthesis for events | true |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
# Writing docstrings
|
||||
|
||||
LeRobot's API reference is generated directly from the docstrings in `src/lerobot/`. A docstring is not a
|
||||
comment — it is the published documentation for that object, and the format below is what the renderer and
|
||||
the CI checks parse.
|
||||
|
||||
This page is the contract. If you are adding or editing anything public in `src/lerobot/`, follow it.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **An undocumented public method is an invisible one.** `[[autodoc]]` silently skips members that have no
|
||||
> docstring — no warning, no error, it simply does not appear on the rendered page. Coverage and
|
||||
> API-reference completeness are the same problem.
|
||||
|
||||
## The format in one example
|
||||
|
||||
Google section headers, Hugging Face type formatting. Both, not one or the other.
|
||||
|
||||
````python
|
||||
def send_action(self, action: RobotAction, rate_hz: float = 30.0) -> RobotAction:
|
||||
"""Command the robot to move to a target joint configuration.
|
||||
|
||||
Values are clipped by the configured maximum relative target before reaching the motors, so the
|
||||
returned action may differ from the requested one.
|
||||
|
||||
Args:
|
||||
action (`dict[str, float]`):
|
||||
Target values keyed by motor name, e.g. `{"shoulder_pan.pos": 0.0}`. Keys must match the
|
||||
robot's action features.
|
||||
rate_hz (`float`, *optional*, defaults to `30.0`):
|
||||
Control loop frequency.
|
||||
|
||||
Returns:
|
||||
`dict[str, float]`: The action actually written to the motors after safety clipping.
|
||||
|
||||
Raises:
|
||||
DeviceNotConnectedError: If the robot has not been connected.
|
||||
|
||||
Example:
|
||||
```python
|
||||
>>> from lerobot.robots.so_follower import SO101Follower, SO101FollowerConfig
|
||||
>>> robot = SO101Follower(SO101FollowerConfig(port="/dev/ttyACM0")) # doctest: +SKIP
|
||||
>>> robot.connect() # doctest: +SKIP
|
||||
>>> robot.send_action({"shoulder_pan.pos": 0.0}) # doctest: +SKIP
|
||||
```
|
||||
"""
|
||||
````
|
||||
|
||||
Cross-references are omitted from the examples on this page — see [Cross-references](#cross-references) for
|
||||
their syntax and why they cannot be shown inside a code block.
|
||||
|
||||
## Rules
|
||||
|
||||
### Sections
|
||||
|
||||
`Args:` · `Returns:` · `Raises:` · `Yields:` · `Example:` · `Note:`
|
||||
|
||||
In that order. No other section headers. A one-line summary comes first, then an optional free-form
|
||||
description, then the sections.
|
||||
|
||||
### The `Args:` line is machine-parsed
|
||||
|
||||
```
|
||||
name (`type`, *optional*, defaults to `X`):
|
||||
Description, indented on its own line.
|
||||
```
|
||||
|
||||
The `*optional*, defaults to` clause is **checked against the real signature default** by
|
||||
`make check-docstrings`. It is not decorative — if you write a default that has drifted from the code, CI
|
||||
fails. Omit the clause entirely for required parameters:
|
||||
|
||||
```python
|
||||
Args:
|
||||
port (`str`):
|
||||
Serial port the arm is connected to, e.g. `/dev/ttyACM0`.
|
||||
max_relative_target (`float | dict[str, float]`, *optional*):
|
||||
Caps the magnitude of the relative positional target vector. `None` disables clipping.
|
||||
use_degrees (`bool`, *optional*, defaults to `True`):
|
||||
Keep `True` for backward compatibility with existing policies and datasets.
|
||||
```
|
||||
|
||||
Types go in backticks. Use `*optional*` with no `defaults to` when the default is `None` or is otherwise not
|
||||
worth restating.
|
||||
|
||||
### `Returns:` is type-first
|
||||
|
||||
One indented line, type first, then a colon, then the description:
|
||||
|
||||
```python
|
||||
Returns:
|
||||
`dict[str, float]`: The action actually written to the motors after safety clipping.
|
||||
```
|
||||
|
||||
`Yields:` takes the same shape.
|
||||
|
||||
### `**Attributes**:`, never `Attributes:`
|
||||
|
||||
doc-builder parses a bare `Attributes:` as a **synonym for `Parameters:`**, so your attributes get rendered
|
||||
as constructor arguments. This is silent and wrong. Whenever the attributes differ from the constructor
|
||||
parameters, use the bold form with a `--` separator:
|
||||
|
||||
```python
|
||||
class Robot(abc.ABC):
|
||||
"""The base abstract class for all LeRobot-compatible robots.
|
||||
|
||||
**Attributes**:
|
||||
- **config_class** (`type[RobotConfig]`) -- The expected configuration class for this robot.
|
||||
- **name** (`str`) -- The unique robot name used to identify this robot type.
|
||||
"""
|
||||
```
|
||||
|
||||
Note `--`, not `:`.
|
||||
|
||||
### Cross-references
|
||||
|
||||
Use doc-builder's bracket syntax: a square-bracketed backtick-quoted path. **Sphinx roles (`:pymeth:`,
|
||||
`:pyattr:`) are not supported** and render as literal text on the page.
|
||||
|
||||
| Want | Write |
|
||||
| ---------------------------- | ----------------------------------- |
|
||||
| Class in the main package | [`Robot`] |
|
||||
| Method, show the full path | [`Robot.connect`] |
|
||||
| Method, show the bare name | [`~Robot.connect`] |
|
||||
| Nested path | [`~robots.Robot.connect`] |
|
||||
| Object in another HF library | [`~accelerate.Accelerator`] |
|
||||
|
||||
The `~` strips the path from the **link text only**; the link still resolves to the full path.
|
||||
|
||||
> [!NOTE]
|
||||
> doc-builder resolves this syntax everywhere in a page — including inside fenced code blocks. That is why
|
||||
> the docstring examples on this page use plain prose instead of cross-references: a code block containing
|
||||
> one would render the resolved link rather than the syntax you need to type. In your own docstrings, use
|
||||
> cross-references freely; this restriction only affects documentation _about_ the syntax.
|
||||
|
||||
### Callouts
|
||||
|
||||
Use GitHub-style blockquotes:
|
||||
|
||||
```markdown
|
||||
> [!TIP]
|
||||
> Call this once at startup — it takes about two seconds.
|
||||
|
||||
> [!WARNING]
|
||||
> Torque is disabled on disconnect. The arm will drop if it is holding a load.
|
||||
```
|
||||
|
||||
The `<Tip>` component is legacy per doc-builder; don't add new ones.
|
||||
|
||||
### Examples must be fenced
|
||||
|
||||
An example lives inside a fenced ` ```python ` block containing `>>> `. The fence is what makes it render
|
||||
as a code block, and it is what the doctest preprocessor's regex looks for:
|
||||
|
||||
````python
|
||||
Example:
|
||||
```python
|
||||
>>> from lerobot.robots.so_follower import SO101FollowerConfig
|
||||
>>> cfg = SO101FollowerConfig(port="/dev/ttyACM0")
|
||||
>>> cfg.use_degrees
|
||||
True
|
||||
```
|
||||
````
|
||||
|
||||
> [!WARNING]
|
||||
> An unfenced `>>>` is still collected — doctest finds prompts anywhere in a docstring. What you lose is the
|
||||
> rendering, so it shows up as a wall of prose on the page. Every example needs the fence.
|
||||
|
||||
Every example either executes in CI or carries `# doctest: +SKIP`. Anything that touches hardware, a GPU, or
|
||||
downloads from the Hub gets `+SKIP`:
|
||||
|
||||
````python
|
||||
Example:
|
||||
```python
|
||||
>>> robot.connect() # doctest: +SKIP
|
||||
>>> policy = ACTPolicy.from_pretrained("lerobot/act_aloha_sim_transfer_cube_human") # doctest: +SKIP
|
||||
```
|
||||
````
|
||||
|
||||
Add files containing runnable examples to `utils/documentation_tests.txt`.
|
||||
|
||||
Put examples on the three to five genuine entry points of a module. Examples on trivial accessors are noise.
|
||||
|
||||
## Three patterns you will hit constantly
|
||||
|
||||
### Config dataclasses
|
||||
|
||||
Configuration fields are historically documented with `#` comments above each field. **doc-builder cannot
|
||||
see inline comments** — such a class renders with every field listed and not a single description. Move them
|
||||
into an `Args:` block on the class docstring:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SOFollowerConfig:
|
||||
"""Configuration for SO-family follower arms.
|
||||
|
||||
Args:
|
||||
port (`str`):
|
||||
Serial port the arm is connected to, e.g. `/dev/ttyACM0`.
|
||||
max_relative_target (`float | dict[str, float]`, *optional*):
|
||||
Caps the magnitude of the relative positional target vector. A scalar applies to all motors;
|
||||
a dict maps motor name to a per-motor cap. `None` disables clipping.
|
||||
use_degrees (`bool`, *optional*, defaults to `True`):
|
||||
Keep `True` for backward compatibility with existing policies and datasets.
|
||||
"""
|
||||
|
||||
port: str
|
||||
max_relative_target: float | dict[str, float] | None = None
|
||||
use_degrees: bool = True
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **doc-builder does not inherit docstrings from base classes.** LeRobot's registered config classes are
|
||||
> often thin multiple-inheritance shims:
|
||||
>
|
||||
> ```python
|
||||
> @RobotConfig.register_subclass("so101_follower")
|
||||
> @dataclass
|
||||
> class SOFollowerRobotConfig(RobotConfig, SOFollowerConfig):
|
||||
> pass
|
||||
> ```
|
||||
>
|
||||
> That class renders **every** field — including the ones it inherits — with no descriptions at all, no
|
||||
> matter how well the bases are documented. The `Args:` block must live on the concrete class that
|
||||
> `[[autodoc]]` names, and it must cover inherited fields too.
|
||||
|
||||
### Base class, then concrete subclass
|
||||
|
||||
The abstract base carries the canonical contract. Subclasses document only what deviates — port semantics,
|
||||
calibration quirks, motor layout, supported feature keys. Do not copy the base contract into every subclass.
|
||||
|
||||
`Robot`, `Teleoperator`, `Camera`, `MotorsBus`, `ProcessorStep`, and `PreTrainedPolicy` all follow this
|
||||
shape.
|
||||
|
||||
### Module-level aliases
|
||||
|
||||
Several public names are aliases rather than distinct classes:
|
||||
|
||||
```python
|
||||
SO100FollowerConfig = SOFollowerRobotConfig
|
||||
SO101FollowerConfig = SOFollowerRobotConfig
|
||||
```
|
||||
|
||||
`[[autodoc]]` resolves the alias and renders the **canonical** class name, so a `## SO101FollowerConfig`
|
||||
heading will show `class lerobot.robots.so_follower.SOFollowerRobotConfig` in the body. Document the
|
||||
canonical class once, and mention the aliases in the page's prose rather than giving each alias its own
|
||||
autodoc block.
|
||||
|
||||
## What not to document
|
||||
|
||||
- **Private members.** Anything starting with `_` is not part of the public API.
|
||||
- **The type annotation restated as prose.** `port (`str`): A string.` adds nothing. Say what it is for.
|
||||
- **Vendored upstream code.** `src/lerobot/policies/molmoact2/molmoact2_hf_model/` is vendored from
|
||||
`transformers` and already carries upstream-style docstrings. Leave it alone — restyling it only creates
|
||||
conflicts on the next sync. It is excluded from the API reference and from the docstring checks.
|
||||
|
||||
## How this is enforced
|
||||
|
||||
| Check | What it catches |
|
||||
| ------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `make check-docstrings` | An `Args:` entry that doesn't match the signature; a documented default that has drifted from the real one |
|
||||
| `make doctest` | Examples that no longer run |
|
||||
| `make check-doctest-list` | Stale or unsorted entries in `utils/documentation_tests.txt` |
|
||||
| `ruff` (`D` rules) | Google-convention style violations |
|
||||
| `interrogate` | Docstring coverage falling below the current threshold |
|
||||
| doc-builder | A `[[autodoc]]` path that points at something that doesn't exist — this breaks the docs build |
|
||||
|
||||
Run them together before opening a PR:
|
||||
|
||||
```bash
|
||||
make check-docstrings && make doctest && pre-commit run --all-files
|
||||
```
|
||||
|
||||
Then render the page and actually look at it:
|
||||
|
||||
```bash
|
||||
doc-builder build lerobot docs/source/ --build_dir /tmp/doc-build
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Every public member you touched has a docstring.
|
||||
- [ ] Every `Args:` entry matches the signature, including the `*optional*, defaults to` clause.
|
||||
- [ ] `Returns:` is type-first on one indented line.
|
||||
- [ ] No bare `Attributes:` — use `**Attributes**:` with `--` separators.
|
||||
- [ ] No Sphinx roles — cross-references use [`~module.Class.method`].
|
||||
- [ ] Examples are inside a fenced ` ```python ` block, and either run in CI or carry `# doctest: +SKIP`.
|
||||
- [ ] Config dataclass fields are in an `Args:` block on the concrete class, not `#` comments.
|
||||
- [ ] The rendered page has been eyeballed.
|
||||
+17
-64
@@ -401,7 +401,7 @@ exclude = ["tests/artifacts/**/*.safetensors", "*_pb2.py", "*_pb2_grpc.py"]
|
||||
# N: pep8-naming
|
||||
# TODO: Uncomment rules when ready to use
|
||||
select = [
|
||||
"E", "W", "F", "I", "B", "C4", "T20", "N", "UP", "SIM", "D" #, "A", "S", "RUF"
|
||||
"E", "W", "F", "I", "B", "C4", "T20", "N", "UP", "SIM" #, "A", "S", "D", "RUF"
|
||||
]
|
||||
ignore = [
|
||||
"E501", # Line too long
|
||||
@@ -411,53 +411,9 @@ ignore = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"__init__.py" = ["F401", "F403", "E402", "D104"]
|
||||
"__init__.py" = ["F401", "F403", "E402"]
|
||||
# E402: conditional-import guards (TYPE_CHECKING / is_package_available) must precede the imports they protect
|
||||
"src/lerobot/scripts/convert_dataset_v21_to_v30.py" = ["E402"]
|
||||
|
||||
# D (pydocstyle) is enabled globally, but only holds for code that has been converted to the docstring
|
||||
# standard in docs/source/writing_docstrings.mdx. Every module below is still on the old style; each entry
|
||||
# is deleted as that module is converted, and this block can be removed once it is empty.
|
||||
#
|
||||
# Not part of the API reference and not planned for conversion: tests, examples, benchmarks, templates,
|
||||
# CI helper scripts and the packaging shim.
|
||||
"tests/**" = ["D"]
|
||||
"examples/**" = ["D"]
|
||||
"benchmarks/**" = ["D"]
|
||||
"scripts/**" = ["D"]
|
||||
"setup.py" = ["D"]
|
||||
"src/lerobot/templates/**" = ["D"]
|
||||
# Vendored from transformers; keeps its upstream docstring style so syncs stay clean.
|
||||
"src/lerobot/policies/molmoact2/molmoact2_hf_model/**" = ["D"]
|
||||
# Awaiting conversion, one PR per module.
|
||||
"src/lerobot/annotations/**" = ["D"]
|
||||
"src/lerobot/async_inference/**" = ["D"]
|
||||
"src/lerobot/cameras/**" = ["D"]
|
||||
"src/lerobot/common/**" = ["D"]
|
||||
"src/lerobot/configs/**" = ["D"]
|
||||
"src/lerobot/data_processing/**" = ["D"]
|
||||
"src/lerobot/datasets/**" = ["D"]
|
||||
"src/lerobot/distributed/**" = ["D"]
|
||||
"src/lerobot/envs/**" = ["D"]
|
||||
"src/lerobot/jobs/**" = ["D"]
|
||||
"src/lerobot/model/**" = ["D"]
|
||||
"src/lerobot/motors/**" = ["D"]
|
||||
"src/lerobot/optim/**" = ["D"]
|
||||
"src/lerobot/policies/**" = ["D"]
|
||||
"src/lerobot/processor/**" = ["D"]
|
||||
"src/lerobot/rewards/**" = ["D"]
|
||||
"src/lerobot/rl/**" = ["D"]
|
||||
"src/lerobot/robots/**" = ["D"]
|
||||
"src/lerobot/rollout/**" = ["D"]
|
||||
"src/lerobot/scripts/**" = ["D"]
|
||||
"src/lerobot/teleoperators/**" = ["D"]
|
||||
"src/lerobot/transforms/**" = ["D"]
|
||||
"src/lerobot/transport/**" = ["D"]
|
||||
"src/lerobot/utils/**" = ["D"]
|
||||
"src/lerobot/lerobot_types.py" = ["D"]
|
||||
# Package root: two one-line docstring fixes land with the docstring PR.
|
||||
"src/lerobot/__init__.py" = ["D"]
|
||||
"src/lerobot/__version__.py" = ["D"]
|
||||
[tool.ruff.lint.isort]
|
||||
combine-as-imports = true
|
||||
known-first-party = ["lerobot"]
|
||||
@@ -501,24 +457,21 @@ default.extend-ignore-identifiers-re = [
|
||||
"seperated_timestep",
|
||||
]
|
||||
|
||||
# Docstring coverage gate. `fail-under` is a RATCHET, not a target: it is set just below the currently
|
||||
# measured coverage so it passes today, and is raised in the same PR that documents a module. Never set it
|
||||
# to a value that fails on main. The destination is 100; see docs/source/writing_docstrings.mdx.
|
||||
[tool.interrogate]
|
||||
ignore-init-module = true
|
||||
ignore-init-method = true
|
||||
ignore-nested-functions = false
|
||||
ignore-magic = false
|
||||
ignore-semiprivate = false
|
||||
ignore-private = false
|
||||
ignore-property-decorators = false
|
||||
ignore-module = false
|
||||
ignore-setters = false
|
||||
fail-under = 52
|
||||
output-format = "term-missing"
|
||||
color = true
|
||||
paths = ["src/lerobot"]
|
||||
exclude = ["src/lerobot/policies/molmoact2/molmoact2_hf_model"]
|
||||
# TODO: Uncomment when ready to use
|
||||
# [tool.interrogate]
|
||||
# ignore-init-module = true
|
||||
# ignore-init-method = true
|
||||
# ignore-nested-functions = false
|
||||
# ignore-magic = false
|
||||
# ignore-semiprivate = false
|
||||
# ignore-private = false
|
||||
# ignore-property-decorators = false
|
||||
# ignore-module = false
|
||||
# ignore-setters = false
|
||||
# fail-under = 80
|
||||
# output-format = "term-missing"
|
||||
# color = true
|
||||
# paths = ["src/lerobot"]
|
||||
|
||||
# TODO: Enable mypy gradually module by module across multiple PRs
|
||||
# Uncomment [tool.mypy] first, then uncomment individual module overrides as they get proper type annotations
|
||||
|
||||
@@ -31,6 +31,7 @@ from .types import (
|
||||
PipelineFeatureType,
|
||||
PolicyFeature,
|
||||
RTCAttentionSchedule,
|
||||
TextKind,
|
||||
)
|
||||
from .video import (
|
||||
DEFAULT_DEPTH_UNIT,
|
||||
@@ -54,6 +55,7 @@ __all__ = [
|
||||
"PipelineFeatureType",
|
||||
"PolicyFeature",
|
||||
"RTCAttentionSchedule",
|
||||
"TextKind",
|
||||
# Config classes
|
||||
"DatasetRecordConfig",
|
||||
"DatasetConfig",
|
||||
|
||||
@@ -67,6 +67,12 @@ class PreTrainedConfig(draccus.ChoiceRegistry, HubMixin, abc.ABC): # type: igno
|
||||
# Whether the policy employed PEFT for training.
|
||||
use_peft: bool = False
|
||||
|
||||
# Decoding defaults for policies that implement `generate_text`. They live
|
||||
# in config.json so a text head uses the settings it was trained/evaluated
|
||||
# with; policy-specific decoding knobs belong on the concrete config.
|
||||
text_temperature: float = 0.0 # 0.0 = greedy; > 0 enables sampling
|
||||
text_top_p: float = 1.0
|
||||
|
||||
push_to_hub: bool = True # type: ignore[assignment] # TODO: use a different name to avoid override
|
||||
repo_id: str | None = None
|
||||
|
||||
|
||||
@@ -31,6 +31,13 @@ class PipelineFeatureType(str, Enum):
|
||||
OBSERVATION = "OBSERVATION"
|
||||
|
||||
|
||||
class TextKind(str, Enum):
|
||||
"""Text-generation requests understood by interactive policy hooks."""
|
||||
|
||||
SUBTASK = "subtask"
|
||||
VQA = "vqa"
|
||||
|
||||
|
||||
class NormalizationMode(str, Enum):
|
||||
MIN_MAX = "MIN_MAX"
|
||||
MEAN_STD = "MEAN_STD"
|
||||
|
||||
@@ -28,7 +28,8 @@ from huggingface_hub.errors import HfHubHTTPError
|
||||
from safetensors.torch import load_model as load_model_as_safetensor
|
||||
from torch import Tensor, nn
|
||||
|
||||
from lerobot.configs import PreTrainedConfig
|
||||
from lerobot.configs import PreTrainedConfig, TextKind
|
||||
from lerobot.utils.constants import ACTION
|
||||
from lerobot.utils.device_utils import resolve_safetensors_device
|
||||
from lerobot.utils.hub import HubMixin
|
||||
from lerobot.utils.import_utils import _peft_available, require_package
|
||||
@@ -210,6 +211,51 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def drop_queued_actions(self) -> None:
|
||||
"""Discard actions precomputed by earlier ``select_action`` calls.
|
||||
|
||||
Chunking policies answer most control ticks from a queue filled by an
|
||||
earlier forward pass, so a mid-episode change to the conditioning —
|
||||
e.g. a new language instruction — would otherwise only take effect
|
||||
once that queue drains (up to ``chunk_size`` ticks). Dropping the
|
||||
queue forces a fresh forward pass on the next ``select_action``.
|
||||
|
||||
Unlike :meth:`reset` this keeps the rest of the episode state (e.g.
|
||||
observation history), so it does not perturb policies that condition
|
||||
on it. Call it from the thread that calls ``select_action``: it
|
||||
mutates the same queues that thread pops from.
|
||||
|
||||
Policies that keep no action queue inherit a no-op.
|
||||
"""
|
||||
queues = getattr(self, "_queues", None)
|
||||
if isinstance(queues, dict) and ACTION in queues:
|
||||
queues[ACTION].clear()
|
||||
action_queue = getattr(self, "_action_queue", None)
|
||||
if action_queue is not None:
|
||||
action_queue.clear()
|
||||
|
||||
def supports_text_generation(self) -> bool:
|
||||
"""Whether this policy implements the optional :meth:`generate_text` hook."""
|
||||
return type(self).generate_text is not PreTrainedPolicy.generate_text
|
||||
|
||||
def generate_text(
|
||||
self,
|
||||
batch: dict[str, Tensor],
|
||||
*,
|
||||
kind: TextKind = TextKind.SUBTASK,
|
||||
user_text: str | None = None,
|
||||
) -> str:
|
||||
"""Generate one string from a policy's optional language head.
|
||||
|
||||
Interactive rollout calls this with a policy-ready observation batch.
|
||||
Implementations must treat the batch as read-only and avoid mutating
|
||||
action queues or episode state: text generation runs on a background
|
||||
worker while the control loop remains active.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} has no text head. Implement `generate_text` to support /ask."
|
||||
)
|
||||
|
||||
def supports_rtc(self) -> bool:
|
||||
"""Whether this policy implements Real-Time Chunking inference semantics."""
|
||||
return False
|
||||
|
||||
@@ -52,6 +52,7 @@ from torch.nn import CrossEntropyLoss
|
||||
from torchvision.transforms import InterpolationMode
|
||||
from torchvision.transforms.v2 import functional as tv_functional
|
||||
|
||||
from lerobot.configs import TextKind
|
||||
from lerobot.utils.constants import ACTION, OBS_STATE
|
||||
from lerobot.utils.import_utils import (
|
||||
_wallx_deps_available,
|
||||
@@ -107,6 +108,7 @@ else:
|
||||
|
||||
from .utils import (
|
||||
get_wallx_normal_text,
|
||||
img_key_mapping,
|
||||
preprocesser_call,
|
||||
process_grounding_points,
|
||||
replace_action_token,
|
||||
@@ -1585,6 +1587,25 @@ class Qwen2_5_VLMoEForAction(_Qwen2_5_VLForAction_Base): # noqa: N801
|
||||
- Handles special cases for input_embeds, generation methods, and GPU synchronization
|
||||
- Manages vision inputs to avoid unnecessary forward passes
|
||||
"""
|
||||
if cache_position is None:
|
||||
past_length = 0
|
||||
if past_key_values is not None and hasattr(past_key_values, "get_seq_length"):
|
||||
past_length = int(past_key_values.get_seq_length())
|
||||
input_length = input_ids.shape[1]
|
||||
end = input_length if input_length > past_length else past_length + input_length
|
||||
cache_position = torch.arange(
|
||||
past_length,
|
||||
end,
|
||||
dtype=torch.long,
|
||||
device=input_ids.device,
|
||||
)
|
||||
if cache_position.numel() == 0:
|
||||
cache_position = torch.arange(
|
||||
input_length,
|
||||
dtype=torch.long,
|
||||
device=input_ids.device,
|
||||
)
|
||||
|
||||
# Initialize MoE token types if not provided
|
||||
if moe_token_types is None:
|
||||
moe_token_types = torch.zeros_like(
|
||||
@@ -1851,6 +1872,23 @@ class WallXPolicy(PreTrainedPolicy):
|
||||
"""Get parameters for optimization."""
|
||||
return self.parameters()
|
||||
|
||||
@staticmethod
|
||||
def _observation_prompt(img_keys: list[str]) -> str:
|
||||
prompt = "Observation:"
|
||||
for label in img_key_mapping(img_keys):
|
||||
prompt += f" {label}: <|vision_start|><|image_pad|><|vision_end|>"
|
||||
return prompt
|
||||
|
||||
def _format_text_prompt(self, instruction: str, kind: str, img_keys: list[str]) -> str:
|
||||
if kind == TextKind.SUBTASK:
|
||||
instruction = f"{instruction}\nPredict the next action in language."
|
||||
return (
|
||||
"<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n"
|
||||
f"<|im_start|>user\n{self._observation_prompt(img_keys)}\n"
|
||||
f"Instruction: {instruction}<|im_end|>\n"
|
||||
"<|im_start|>assistant\n"
|
||||
)
|
||||
|
||||
def preprocess_inputs(
|
||||
self,
|
||||
batch: dict[str, Any],
|
||||
@@ -2080,6 +2118,118 @@ class WallXPolicy(PreTrainedPolicy):
|
||||
|
||||
return loss, loss_dict
|
||||
|
||||
def _build_text_inputs(
|
||||
self,
|
||||
batch: dict[str, Any],
|
||||
*,
|
||||
kind: str,
|
||||
user_text: str | list[str] | None,
|
||||
) -> BatchFeature:
|
||||
batch_size = batch[OBS_STATE].shape[0]
|
||||
img_keys = [key for key in self.config.image_features if key in batch]
|
||||
if not img_keys:
|
||||
raise ValueError("Wall-X text generation requires at least one image feature.")
|
||||
|
||||
image_inputs, dimensions_by_key = _prepare_wall_x_image_inputs(batch, img_keys)
|
||||
orig_height, orig_width, resized_height, resized_width = dimensions_by_key[img_keys[-1]]
|
||||
tasks = batch["task"] if isinstance(batch["task"], list) else [batch["task"]] * batch_size
|
||||
if user_text is None:
|
||||
instructions = tasks
|
||||
elif isinstance(user_text, str):
|
||||
instructions = [user_text] * batch_size
|
||||
elif len(user_text) == batch_size:
|
||||
instructions = user_text
|
||||
else:
|
||||
raise ValueError(f"Expected one text prompt for each of the {batch_size} samples.")
|
||||
|
||||
texts = [
|
||||
process_grounding_points(
|
||||
self._format_text_prompt(str(instruction), kind, img_keys),
|
||||
orig_height,
|
||||
orig_width,
|
||||
resized_height,
|
||||
resized_width,
|
||||
MODEL_TYPE,
|
||||
)
|
||||
for instruction in instructions
|
||||
]
|
||||
inputs = preprocesser_call(
|
||||
processor=self.model.processor,
|
||||
text=texts,
|
||||
images=image_inputs,
|
||||
videos=None,
|
||||
device=batch[OBS_STATE].device,
|
||||
padding=True,
|
||||
truncation=True,
|
||||
return_tensors="pt",
|
||||
max_length=TOKENIZER_MAX_LENGTH,
|
||||
)
|
||||
inputs.pop("labels", None)
|
||||
inputs["moe_token_types"] = torch.zeros_like(inputs.input_ids, dtype=torch.bool)
|
||||
for key, value in inputs.items():
|
||||
if isinstance(value, torch.Tensor):
|
||||
inputs[key] = value.to(batch[OBS_STATE].device)
|
||||
return inputs
|
||||
|
||||
@torch.no_grad()
|
||||
def generate_text(
|
||||
self,
|
||||
batch: dict[str, Tensor],
|
||||
*,
|
||||
kind: TextKind = TextKind.SUBTASK,
|
||||
user_text: str | None = None,
|
||||
) -> str:
|
||||
"""Generate one grounded language response from the WALL-OSS VLM."""
|
||||
outputs = self.generate_texts(
|
||||
batch,
|
||||
kind=kind,
|
||||
user_text=user_text,
|
||||
temperature=self.config.text_temperature,
|
||||
top_p=self.config.text_top_p,
|
||||
)
|
||||
if len(outputs) != 1:
|
||||
raise ValueError(f"Interactive rollout expected one Wall-X output, got {len(outputs)}.")
|
||||
return outputs[0]
|
||||
|
||||
@torch.no_grad()
|
||||
def generate_texts(
|
||||
self,
|
||||
batch: dict[str, Any],
|
||||
*,
|
||||
kind: TextKind = TextKind.VQA,
|
||||
user_text: str | list[str] | None = None,
|
||||
max_new_tokens: int = 100,
|
||||
min_new_tokens: int = 0,
|
||||
temperature: float = 0.0,
|
||||
top_p: float = 1.0,
|
||||
) -> list[str]:
|
||||
"""Generate grounded Wall-X text for one or more observations."""
|
||||
self.eval()
|
||||
if kind not in {TextKind.VQA, TextKind.SUBTASK}:
|
||||
raise ValueError("Unsupported Wall-X text kind.")
|
||||
inputs = self._build_text_inputs(batch, kind=kind, user_text=user_text)
|
||||
prompt_length = inputs.input_ids.shape[1]
|
||||
sampling = temperature > 0
|
||||
generation_kwargs: dict[str, Any] = {
|
||||
"max_new_tokens": max_new_tokens,
|
||||
"min_new_tokens": min_new_tokens,
|
||||
"do_sample": sampling,
|
||||
"eos_token_id": self.model.processor.tokenizer.eos_token_id,
|
||||
"pad_token_id": self.model.processor.tokenizer.pad_token_id,
|
||||
"use_cache": True,
|
||||
}
|
||||
if sampling:
|
||||
generation_kwargs.update(temperature=temperature, top_p=top_p)
|
||||
output_ids = self.model.generate(**inputs, **generation_kwargs)
|
||||
return [
|
||||
value.strip()
|
||||
for value in self.model.processor.tokenizer.batch_decode(
|
||||
output_ids[:, prompt_length:],
|
||||
skip_special_tokens=True,
|
||||
clean_up_tokenization_spaces=True,
|
||||
)
|
||||
]
|
||||
|
||||
@torch.no_grad()
|
||||
def predict_action_chunk(self, batch: dict[str, Tensor]) -> Tensor:
|
||||
"""Predict action chunk for evaluation."""
|
||||
|
||||
@@ -47,6 +47,15 @@ from .inference import (
|
||||
SyncInferenceEngine,
|
||||
create_inference_engine,
|
||||
)
|
||||
from .interactive import (
|
||||
InteractiveCommand,
|
||||
InteractiveSession,
|
||||
LinkedEvent,
|
||||
StdinCommandListener,
|
||||
TextQueryRequest,
|
||||
TextQueryWorker,
|
||||
parse_command,
|
||||
)
|
||||
from .strategies import (
|
||||
BaseStrategy,
|
||||
DAggerStrategy,
|
||||
@@ -65,13 +74,18 @@ __all__ = [
|
||||
"DAggerStrategy",
|
||||
"DAggerStrategyConfig",
|
||||
"DatasetContext",
|
||||
"EpisodicStrategy",
|
||||
"EpisodicStrategyConfig",
|
||||
"HardwareContext",
|
||||
"HighlightStrategy",
|
||||
"HighlightStrategyConfig",
|
||||
"EpisodicStrategy",
|
||||
"EpisodicStrategyConfig",
|
||||
"InferenceEngine",
|
||||
"InferenceEngineConfig",
|
||||
"InteractiveCommand",
|
||||
"InteractiveSession",
|
||||
"LinkedEvent",
|
||||
"TextQueryRequest",
|
||||
"TextQueryWorker",
|
||||
"PolicyContext",
|
||||
"ProcessorContext",
|
||||
"RTCInferenceConfig",
|
||||
@@ -83,9 +97,11 @@ __all__ = [
|
||||
"RuntimeContext",
|
||||
"SentryStrategy",
|
||||
"SentryStrategyConfig",
|
||||
"StdinCommandListener",
|
||||
"SyncInferenceConfig",
|
||||
"SyncInferenceEngine",
|
||||
"build_rollout_context",
|
||||
"create_inference_engine",
|
||||
"create_strategy",
|
||||
"parse_command",
|
||||
]
|
||||
|
||||
@@ -239,6 +239,13 @@ class RolloutConfig:
|
||||
# Runtime
|
||||
fps: float = 30.0
|
||||
duration: float = 0.0 # 0 = infinite (24/7 mode)
|
||||
# Interactive session: control the rollout from stdin with chat-style
|
||||
# commands (/start, /subtask <text>, /ask <question>, /reset, /stop) while hardware and
|
||||
# policy stay warm. The robot does not move until /start is received,
|
||||
# `/subtask` re-instructs the policy mid-run, and console logs are muted
|
||||
# while the session runs so they don't interleave with the prompt.
|
||||
# Currently limited to --strategy.type=base.
|
||||
interactive: bool = False
|
||||
interpolation_multiplier: int = 1
|
||||
device: str | None = None
|
||||
task: str = ""
|
||||
@@ -294,6 +301,17 @@ class RolloutConfig:
|
||||
"Base strategy does not record data. Use sentry, highlight, or dagger for recording."
|
||||
)
|
||||
|
||||
# Interactive mode drives strategy.run() in restartable segments and reads
|
||||
# commands from stdin. Recording strategies are excluded for now: their
|
||||
# run() loops finalize the dataset on exit (so they cannot be restarted)
|
||||
# and their keyboard listeners read the same terminal as the command
|
||||
# prompt.
|
||||
if self.interactive and not isinstance(self.strategy, BaseStrategyConfig):
|
||||
raise ValueError(
|
||||
f"--interactive=true currently supports only --strategy.type=base "
|
||||
f"(got '{self.strategy.type}')."
|
||||
)
|
||||
|
||||
# Sentry MUST use streaming encoding to avoid disk I/O blocking the control loop
|
||||
if (
|
||||
isinstance(self.strategy, SentryStrategyConfig)
|
||||
|
||||
@@ -22,9 +22,20 @@ or asynchronously in a background thread (RTC).
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import logging
|
||||
from copy import copy
|
||||
from threading import Event, Lock
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import torch
|
||||
|
||||
from lerobot.configs import TextKind
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lerobot.policies.pretrained import PreTrainedPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InferenceEngine(abc.ABC):
|
||||
"""Abstract backend for producing actions during rollout.
|
||||
@@ -47,12 +58,165 @@ class InferenceEngine(abc.ABC):
|
||||
backends always compute from ``obs_frame``; async backends ignore
|
||||
it (they receive observations via ``notify_observation``).
|
||||
|
||||
Task
|
||||
----
|
||||
``task`` / ``set_task`` hold the language instruction the policy is
|
||||
conditioned on. ``set_task`` is safe to call from any thread (the
|
||||
interactive session's ``/subtask`` command calls it from its stdin
|
||||
reader); subclasses pick the new value up on their own inference
|
||||
thread via :meth:`_take_task`, so no policy state is ever mutated
|
||||
across threads.
|
||||
|
||||
Text queries
|
||||
------------
|
||||
Backends publish their latest policy-ready observation through
|
||||
:meth:`_publish_text_observation`. The interactive ``/ask`` worker takes
|
||||
a shallow snapshot and calls :meth:`generate_text`; an engine-owned gate
|
||||
prevents language decoding from racing an action-model call. Action
|
||||
inference uses a non-blocking acquire so the hardware loop keeps ticking
|
||||
while a text response is being decoded.
|
||||
|
||||
Optional hooks
|
||||
--------------
|
||||
``notify_observation`` / ``pause`` / ``resume`` have a no-op default
|
||||
so rollout strategies can invoke them unconditionally.
|
||||
|
||||
Subclasses must call ``super().__init__(task=..., policy=...)``; the task
|
||||
holder and optional text-generation plumbing are set up there.
|
||||
"""
|
||||
|
||||
def __init__(self, task: str = "", policy: PreTrainedPolicy | None = None) -> None:
|
||||
self._task = task
|
||||
self._task_changed = False
|
||||
self._task_lock = Lock()
|
||||
self._policy = policy
|
||||
|
||||
self._text_observation: dict | None = None
|
||||
self._text_observation_lock = Lock()
|
||||
self._text_observation_publication_enabled = True
|
||||
|
||||
# A text query gets priority after the current action inference ends.
|
||||
# Action backends never block on this lock: they return "no action yet"
|
||||
# and let the control loop keep servicing hardware at its normal rate.
|
||||
self._policy_call_lock = Lock()
|
||||
self._text_query_pending = Event()
|
||||
self._text_query_serial_lock = Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Task (language instruction)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def task(self) -> str:
|
||||
"""The language instruction currently conditioning inference."""
|
||||
with self._task_lock:
|
||||
return self._task
|
||||
|
||||
def set_task(self, task: str) -> bool:
|
||||
"""Set the instruction used from the next inference onwards.
|
||||
|
||||
Callable from any thread. Returns ``True`` when the value
|
||||
actually changed, so callers can report no-op switches.
|
||||
"""
|
||||
with self._task_lock:
|
||||
if task == self._task:
|
||||
return False
|
||||
previous, self._task = self._task, task
|
||||
self._task_changed = True
|
||||
logger.info("Task changed: '%s' -> '%s'", previous, task)
|
||||
return True
|
||||
|
||||
def _take_task(self) -> tuple[str, bool]:
|
||||
"""Read the task and whether it changed since the last read.
|
||||
|
||||
Call from the thread that runs inference: the "changed" edge is
|
||||
consumed here so the backend can drop actions precomputed under
|
||||
the previous instruction before using the new one.
|
||||
"""
|
||||
with self._task_lock:
|
||||
changed, self._task_changed = self._task_changed, False
|
||||
return self._task, changed
|
||||
|
||||
def _discard_task_change(self) -> None:
|
||||
"""Drop a pending task-change edge, e.g. from ``reset`` (state is already cleared)."""
|
||||
with self._task_lock:
|
||||
self._task_changed = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Optional text generation (interactive /ask)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def supports_text_generation(self) -> bool:
|
||||
"""Whether the attached policy implements the optional text hook."""
|
||||
return self._policy is not None and self._policy.supports_text_generation()
|
||||
|
||||
def _publish_text_observation(self, observation: dict) -> None:
|
||||
"""Cache a policy-ready observation for a future background query."""
|
||||
with self._text_observation_lock:
|
||||
if self._text_observation_publication_enabled:
|
||||
self._text_observation = copy(observation)
|
||||
|
||||
def invalidate_text_observation(self) -> None:
|
||||
"""Discard the cached view and reject publications until the next reset.
|
||||
|
||||
``/reset`` calls this before its deferred main-thread homing begins.
|
||||
Keeping publication disabled matters because action inference may
|
||||
already be in flight and otherwise republish the pre-reset scene.
|
||||
"""
|
||||
with self._text_observation_lock:
|
||||
self._text_observation = None
|
||||
self._text_observation_publication_enabled = False
|
||||
|
||||
def _reset_text_observation(self) -> None:
|
||||
"""Clear the cached view and allow the next control segment to publish."""
|
||||
with self._text_observation_lock:
|
||||
self._text_observation = None
|
||||
self._text_observation_publication_enabled = True
|
||||
|
||||
def snapshot_text_observation(self) -> dict | None:
|
||||
"""Return the latest policy-ready view without touching robot hardware."""
|
||||
with self._text_observation_lock:
|
||||
if self._text_observation is None:
|
||||
return None
|
||||
observation = copy(self._text_observation)
|
||||
# A /subtask may have arrived since this visual observation was cached.
|
||||
# Keep the image/state snapshot but pair it with the latest instruction.
|
||||
observation["task"] = self.task
|
||||
return observation
|
||||
|
||||
def generate_text(
|
||||
self,
|
||||
observation: dict,
|
||||
*,
|
||||
kind: TextKind,
|
||||
user_text: str | None = None,
|
||||
) -> str:
|
||||
"""Run one policy text query outside the hardware loop.
|
||||
|
||||
Only one query is admitted at a time. Setting the pending edge before
|
||||
acquiring the shared policy gate prevents a busy action backend from
|
||||
repeatedly winning the lock and starving the question.
|
||||
"""
|
||||
if self._policy is None or not self._policy.supports_text_generation():
|
||||
raise NotImplementedError("This policy does not support text generation.")
|
||||
with self._text_query_serial_lock:
|
||||
self._text_query_pending.set()
|
||||
try:
|
||||
with self._policy_call_lock, torch.inference_mode():
|
||||
return self._policy.generate_text(observation, kind=kind, user_text=user_text)
|
||||
finally:
|
||||
self._text_query_pending.clear()
|
||||
|
||||
def _try_begin_action_inference(self) -> bool:
|
||||
"""Acquire policy ownership without blocking the hardware loop."""
|
||||
if self._text_query_pending.is_set():
|
||||
return False
|
||||
return self._policy_call_lock.acquire(blocking=False)
|
||||
|
||||
def _end_action_inference(self) -> None:
|
||||
"""Release policy ownership acquired by :meth:`_try_begin_action_inference`."""
|
||||
self._policy_call_lock.release()
|
||||
|
||||
@abc.abstractmethod
|
||||
def start(self) -> None:
|
||||
"""Initialise the backend."""
|
||||
@@ -87,3 +251,8 @@ class InferenceEngine(abc.ABC):
|
||||
def failed(self) -> bool:
|
||||
"""True if an unrecoverable error occurred in the backend."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def failure_traceback(self) -> str | None:
|
||||
"""Formatted traceback of the unrecoverable error, when ``failed`` is True."""
|
||||
return None
|
||||
|
||||
@@ -124,13 +124,12 @@ class RTCInferenceEngine(InferenceEngine):
|
||||
rtc_queue_threshold: int = 30,
|
||||
shutdown_event: Event | None = None,
|
||||
) -> None:
|
||||
self._policy = policy
|
||||
super().__init__(task=task, policy=policy)
|
||||
self._preprocessor = preprocessor
|
||||
self._postprocessor = postprocessor
|
||||
self._robot = robot_wrapper
|
||||
self._rtc_config = rtc_config
|
||||
self._hw_features = hw_features
|
||||
self._task = task
|
||||
self._fps = fps
|
||||
self._device = device or "cpu"
|
||||
self._use_torch_compile = use_torch_compile
|
||||
@@ -140,10 +139,14 @@ class RTCInferenceEngine(InferenceEngine):
|
||||
self._action_queue: ActionQueue | None = None
|
||||
self._obs_holder: dict[str, Any] = {}
|
||||
self._obs_lock = Lock()
|
||||
# Bumped by reset() (under _obs_lock) so chunks whose inference started
|
||||
# before a reset are discarded instead of merged into the fresh queue.
|
||||
self._reset_epoch = 0
|
||||
self._policy_active = Event()
|
||||
self._compile_warmup_done = Event()
|
||||
self._shutdown_event = Event()
|
||||
self._rtc_error = Event()
|
||||
self._failure_traceback: str | None = None
|
||||
self._global_shutdown_event = shutdown_event
|
||||
self._rtc_thread: Thread | None = None
|
||||
|
||||
@@ -190,6 +193,15 @@ class RTCInferenceEngine(InferenceEngine):
|
||||
"""True if the RTC background thread exited due to an unrecoverable error."""
|
||||
return self._rtc_error.is_set()
|
||||
|
||||
@property
|
||||
def failure_traceback(self) -> str | None:
|
||||
"""Traceback captured when the RTC thread died (see ``failed``).
|
||||
|
||||
Kept on the engine so consumers that mute console logging (the
|
||||
interactive session) can still surface the fatal error.
|
||||
"""
|
||||
return self._failure_traceback
|
||||
|
||||
@property
|
||||
def action_queue(self) -> ActionQueue | None:
|
||||
"""The shared action queue between the RTC thread and the main loop."""
|
||||
@@ -235,13 +247,31 @@ class RTCInferenceEngine(InferenceEngine):
|
||||
self._policy_active.set()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the policy, processors, and action queue."""
|
||||
"""Reset the policy, processors, and action queue.
|
||||
|
||||
Call while the engine is paused (both DAgger transitions and the
|
||||
interactive session do): the RTC thread may still be finishing an
|
||||
inference started before the pause, so ``reset`` also drops the last
|
||||
published observation — it can be arbitrarily stale by the time the
|
||||
engine resumes (e.g. the robot was returned to its initial position
|
||||
in the meantime), and a chunk computed from it would jerk the robot
|
||||
toward the old pose — and bumps the reset epoch so any in-flight
|
||||
chunk is discarded instead of merged into the cleared queue.
|
||||
"""
|
||||
logger.info("Resetting RTC inference state (policy + processors + queue)")
|
||||
self._policy.reset()
|
||||
self._preprocessor.reset()
|
||||
self._postprocessor.reset()
|
||||
with self._policy_call_lock:
|
||||
self._policy.reset()
|
||||
self._preprocessor.reset()
|
||||
self._postprocessor.reset()
|
||||
if self._action_queue is not None:
|
||||
self._action_queue.clear()
|
||||
with self._obs_lock:
|
||||
self._obs_holder["obs"] = None
|
||||
self._reset_epoch += 1
|
||||
self._reset_text_observation()
|
||||
# The queue was just cleared, so a pending task change has nothing
|
||||
# stale left to blend against.
|
||||
self._discard_task_change()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Action production (called from main thread)
|
||||
@@ -281,11 +311,18 @@ class RTCInferenceEngine(InferenceEngine):
|
||||
queue = self._action_queue
|
||||
with self._obs_lock:
|
||||
obs = self._obs_holder.get("obs")
|
||||
epoch_before = self._reset_epoch
|
||||
if queue is None or obs is None:
|
||||
time.sleep(_RTC_IDLE_SLEEP_S)
|
||||
continue
|
||||
|
||||
if queue.qsize() <= self._rtc_queue_threshold:
|
||||
if not self._try_begin_action_inference():
|
||||
# A background /ask is using the policy. The control
|
||||
# thread can keep draining the already-produced RTC
|
||||
# actions; do not start another model call meanwhile.
|
||||
time.sleep(_RTC_IDLE_SLEEP_S)
|
||||
continue
|
||||
try:
|
||||
current_time = time.perf_counter()
|
||||
idx_before = queue.get_action_index()
|
||||
@@ -294,13 +331,27 @@ class RTCInferenceEngine(InferenceEngine):
|
||||
latency = latency_tracker.max()
|
||||
delay = math.ceil(latency / time_per_chunk) if latency else 0
|
||||
|
||||
task, task_changed = self._take_task()
|
||||
if task_changed:
|
||||
# No queue flush on purpose: dropping the queued
|
||||
# actions would leave the robot without commands for
|
||||
# a full inference latency. With RTC blending on
|
||||
# (the default) this chunk — already conditioned on
|
||||
# the new instruction — is merged over the previous
|
||||
# chunk's leftover prefix, so the switch lands within
|
||||
# one inference and the transition stays continuous.
|
||||
# With blending disabled the queue drains first, so
|
||||
# it lands up to one chunk later.
|
||||
logger.info("Task changed to '%s' — applied from this chunk on", task)
|
||||
|
||||
obs_batch = build_dataset_frame(self._hw_features, obs, prefix="observation")
|
||||
obs_batch = prepare_observation_for_inference(
|
||||
obs_batch, policy_device, self._task, self._robot.robot_type
|
||||
obs_batch, policy_device, task, self._robot.robot_type
|
||||
)
|
||||
obs_batch["task"] = [self._task]
|
||||
obs_batch["task"] = [task]
|
||||
|
||||
preprocessed = self._preprocessor(obs_batch)
|
||||
self._publish_text_observation(preprocessed)
|
||||
|
||||
if prev_actions is not None and self._relative_step is not None:
|
||||
# Rebase against the raw cached state so the leftover tail stays in
|
||||
@@ -339,7 +390,12 @@ class RTCInferenceEngine(InferenceEngine):
|
||||
else:
|
||||
latency_tracker.add(new_latency)
|
||||
|
||||
queue.merge(original, processed, new_delay, idx_before)
|
||||
with self._obs_lock:
|
||||
epoch_unchanged = epoch_before == self._reset_epoch
|
||||
if epoch_unchanged:
|
||||
queue.merge(original, processed, new_delay, idx_before)
|
||||
else:
|
||||
logger.info("Discarding action chunk computed before an engine reset")
|
||||
|
||||
if (
|
||||
is_warmup
|
||||
@@ -364,12 +420,15 @@ class RTCInferenceEngine(InferenceEngine):
|
||||
# Persistent failure: stop retrying and propagate shutdown.
|
||||
raise
|
||||
time.sleep(_RTC_ERROR_RETRY_DELAY_S)
|
||||
finally:
|
||||
self._end_action_inference()
|
||||
else:
|
||||
time.sleep(_RTC_IDLE_SLEEP_S)
|
||||
|
||||
except Exception as e:
|
||||
self._failure_traceback = traceback.format_exc()
|
||||
logger.error("Fatal error in RTC thread: %s", e)
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(self._failure_traceback)
|
||||
self._rtc_error.set()
|
||||
# Unblock any warmup waiters so the main loop doesn't spin forever
|
||||
self._compile_warmup_done.set()
|
||||
|
||||
@@ -65,12 +65,11 @@ class SyncInferenceEngine(InferenceEngine):
|
||||
device: str | None,
|
||||
robot_type: str,
|
||||
) -> None:
|
||||
self._policy = policy
|
||||
super().__init__(task=task, policy=policy)
|
||||
self._preprocessor = preprocessor
|
||||
self._postprocessor = postprocessor
|
||||
self._dataset_features = dataset_features
|
||||
self._ordered_action_keys = ordered_action_keys
|
||||
self._task = task
|
||||
self._device = torch.device(device or "cpu")
|
||||
self._robot_type = robot_type
|
||||
logger.info(
|
||||
@@ -90,33 +89,59 @@ class SyncInferenceEngine(InferenceEngine):
|
||||
def reset(self) -> None:
|
||||
"""Reset the policy and pre/post-processors."""
|
||||
logger.info("Resetting sync inference state (policy + processors)")
|
||||
self._policy.reset()
|
||||
self._preprocessor.reset()
|
||||
self._postprocessor.reset()
|
||||
with self._policy_call_lock:
|
||||
self._policy.reset()
|
||||
self._preprocessor.reset()
|
||||
self._postprocessor.reset()
|
||||
self._reset_text_observation()
|
||||
# The policy was just reset, so a pending task change has nothing
|
||||
# stale left to flush.
|
||||
self._discard_task_change()
|
||||
|
||||
def get_action(self, obs_frame: dict | None) -> torch.Tensor | None:
|
||||
"""Run the full inference pipeline on ``obs_frame`` and return an action tensor."""
|
||||
if obs_frame is None:
|
||||
return None
|
||||
# Shallow copy is intentional: the caller (`send_next_action`) builds
|
||||
# ``obs_frame`` fresh per tick via ``build_dataset_frame``, so the
|
||||
# tensor/array values are not shared with any other reader.
|
||||
observation = copy(obs_frame)
|
||||
autocast_ctx = (
|
||||
torch.autocast(device_type=self._device.type)
|
||||
if self._device.type == "cuda" and self._policy.config.use_amp
|
||||
else nullcontext()
|
||||
)
|
||||
with torch.inference_mode(), autocast_ctx:
|
||||
observation = prepare_observation_for_inference(
|
||||
observation, self._device, self._task, self._robot_type
|
||||
if not self._try_begin_action_inference():
|
||||
# A background /ask owns (or is waiting for) the policy. Do not
|
||||
# block the control thread; the robot keeps executing its last
|
||||
# dispatched target until action inference becomes available.
|
||||
return None
|
||||
try:
|
||||
# Shallow copy is intentional: the caller (`send_next_action`) builds
|
||||
# ``obs_frame`` fresh per tick via ``build_dataset_frame``, so the
|
||||
# tensor/array values are not shared with any other reader.
|
||||
observation = copy(obs_frame)
|
||||
autocast_ctx = (
|
||||
torch.autocast(device_type=self._device.type)
|
||||
if self._device.type == "cuda" and self._policy.config.use_amp
|
||||
else nullcontext()
|
||||
)
|
||||
observation = self._preprocessor(observation)
|
||||
action = self._policy.select_action(observation)
|
||||
action = self._postprocessor(action)
|
||||
action_tensor = action.squeeze(0).cpu()
|
||||
task, task_changed = self._take_task()
|
||||
with torch.inference_mode(), autocast_ctx:
|
||||
if task_changed:
|
||||
# Chunking policies serve actions from an internal queue filled
|
||||
# under the previous instruction (up to chunk_size ticks of stale
|
||||
# behavior), so drop them and let the new instruction take effect
|
||||
# on this very tick. Deliberately narrower than ``policy.reset``:
|
||||
# observation history and other episode state are kept, so a
|
||||
# policy that conditions on them (and one that ignores the task
|
||||
# entirely) sees no discontinuity. Safe to mutate here — this is
|
||||
# the thread that calls ``select_action``.
|
||||
logger.info("Task changed to '%s' — dropping precomputed actions", task)
|
||||
self._policy.drop_queued_actions()
|
||||
observation = prepare_observation_for_inference(
|
||||
observation, self._device, task, self._robot_type
|
||||
)
|
||||
observation = self._preprocessor(observation)
|
||||
self._publish_text_observation(observation)
|
||||
action = self._policy.select_action(observation)
|
||||
action = self._postprocessor(action)
|
||||
action_tensor = action.squeeze(0).cpu()
|
||||
|
||||
# Reorder to match dataset action ordering so the caller can treat
|
||||
# the returned tensor uniformly across backends.
|
||||
action_dict = make_robot_action(action_tensor, self._dataset_features)
|
||||
return torch.tensor([action_dict[k] for k in self._ordered_action_keys])
|
||||
# Reorder to match dataset action ordering so the caller can treat
|
||||
# the returned tensor uniformly across backends.
|
||||
action_dict = make_robot_action(action_tensor, self._dataset_features)
|
||||
return torch.tensor([action_dict[k] for k in self._ordered_action_keys])
|
||||
finally:
|
||||
self._end_action_inference()
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
"""Interactive rollout session: chat-style stdin commands for ``lerobot-rollout``.
|
||||
|
||||
Enabled with ``--interactive=true``, this module lets the operator control a
|
||||
rollout from the terminal while hardware and policy stay connected and warm:
|
||||
|
||||
/start start (or restart) the policy control loop
|
||||
/subtask <text> change the instruction the policy follows, mid-run
|
||||
/ask <question> ask the policy about the latest view without stopping
|
||||
/reset stop movement, return the robot to its initial position,
|
||||
and restore the instruction passed on the command line
|
||||
/stop end the session and run the normal shutdown routines
|
||||
/help show the available commands
|
||||
|
||||
Threading model (mirrors the DAgger events pattern): a daemon
|
||||
:class:`StdinCommandListener` thread reads lines and only ever publishes
|
||||
thread-safe state — flags for the session loop, and the instruction string
|
||||
via :meth:`InferenceEngine.set_task`; it never touches hardware, and never
|
||||
mutates policy state (the engine applies a task change on its own inference
|
||||
thread). ``/ask`` snapshots the latest policy-ready observation and hands it
|
||||
to one background text-query worker; the worker never reads robot hardware.
|
||||
The :class:`InteractiveSession` driver runs on the main thread and executes
|
||||
``strategy.run(ctx)`` in *segments*: each ``/start`` begins a segment, and
|
||||
``/reset`` / ``/stop`` end it by setting the session's :class:`LinkedEvent`,
|
||||
which every strategy control loop already polls as
|
||||
``ctx.runtime.shutdown_event``. Real shutdown signals (SIGINT/SIGTERM)
|
||||
propagate through the linked event's parent, so Ctrl-C behaves exactly as in
|
||||
non-interactive runs.
|
||||
|
||||
While the session runs, console log handlers are muted (including
|
||||
non-propagating library loggers like ``transformers``) and Python warnings
|
||||
are suppressed, so system output does not interleave with the chat prompt;
|
||||
only the session's own output is shown. File log handlers are unaffected,
|
||||
and console logging resumes when the session ends (so teardown logs are
|
||||
visible). A fatal inference-engine error is still surfaced: the session
|
||||
prints the engine's captured traceback. Run without ``--interactive`` to
|
||||
see the full live log output.
|
||||
|
||||
The command table is intentionally a name → (handler, argument hint, help)
|
||||
mapping so further commands can be registered without restructuring the
|
||||
parser, the help output, or the session loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import select
|
||||
import sys
|
||||
import time
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from queue import Empty, Queue
|
||||
from threading import Event, Lock, Thread
|
||||
from typing import IO, TYPE_CHECKING
|
||||
|
||||
from lerobot.configs import TextKind
|
||||
from lerobot.utils.utils import log_say
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .context import RolloutContext
|
||||
from .strategies import RolloutStrategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BANNER_RULE = "─" * 60
|
||||
|
||||
|
||||
def _mute_console_log_handlers() -> list[tuple[logging.Handler, int]]:
|
||||
"""Mute console log handlers for the interactive session.
|
||||
|
||||
System logs (policy, robot, control loop) contend with the chat prompt
|
||||
for the terminal, so raise every console handler above ``CRITICAL``
|
||||
while the session runs. All loggers are covered, not just the root:
|
||||
libraries like ``transformers`` and ``datasets`` attach their own
|
||||
stderr handlers with ``propagate=False``. File handlers are left
|
||||
untouched — anyone who wants a persistent log can attach one — and the
|
||||
previous levels are returned so :func:`_restore_log_handlers` can undo
|
||||
the muting.
|
||||
"""
|
||||
loggers = [logging.getLogger()]
|
||||
loggers += [lg for lg in logging.Logger.manager.loggerDict.values() if isinstance(lg, logging.Logger)]
|
||||
muted = []
|
||||
for lg in loggers:
|
||||
for handler in lg.handlers:
|
||||
if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler):
|
||||
muted.append((handler, handler.level))
|
||||
handler.setLevel(logging.CRITICAL + 1)
|
||||
return muted
|
||||
|
||||
|
||||
def _restore_log_handlers(muted: list[tuple[logging.Handler, int]]) -> None:
|
||||
"""Restore handler levels changed by :func:`_mute_console_log_handlers`."""
|
||||
for handler, level in muted:
|
||||
handler.setLevel(level)
|
||||
|
||||
|
||||
class LinkedEvent(Event):
|
||||
"""A ``threading.Event`` whose ``is_set`` also reflects a parent event.
|
||||
|
||||
``set``/``clear`` act only on the local flag, so the interactive session
|
||||
can raise and clear its own segment-stop requests without masking (or
|
||||
accidentally re-arming) the process-wide shutdown event carried by
|
||||
``parent``. Every rollout strategy control loop polls
|
||||
``ctx.runtime.shutdown_event.is_set()``, so installing a ``LinkedEvent``
|
||||
there makes the loops react both to session commands and to real
|
||||
shutdown signals.
|
||||
"""
|
||||
|
||||
_WAIT_SLICE_S = 0.05
|
||||
|
||||
def __init__(self, parent: Event) -> None:
|
||||
super().__init__()
|
||||
self.parent = parent
|
||||
|
||||
def is_set(self) -> bool:
|
||||
return super().is_set() or self.parent.is_set()
|
||||
|
||||
def wait(self, timeout: float | None = None) -> bool:
|
||||
"""Wait for either the local or the parent flag.
|
||||
|
||||
The base ``Event.wait`` only watches the local flag, so poll in short
|
||||
slices to also observe the parent. Strategy loops only call
|
||||
``is_set()``; this coarse wait exists for API completeness.
|
||||
"""
|
||||
deadline = None if timeout is None else time.perf_counter() + timeout
|
||||
while not self.is_set():
|
||||
remaining = None if deadline is None else deadline - time.perf_counter()
|
||||
if remaining is not None and remaining <= 0:
|
||||
return False
|
||||
wait_slice = self._WAIT_SLICE_S if remaining is None else min(self._WAIT_SLICE_S, remaining)
|
||||
super().wait(wait_slice)
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InteractiveCommand:
|
||||
"""A parsed ``/name args`` line from the interactive prompt."""
|
||||
|
||||
name: str
|
||||
args: str = ""
|
||||
|
||||
|
||||
def _format_task(task: str) -> str:
|
||||
"""Render a task string for the operator, naming the empty case explicitly."""
|
||||
return repr(task) if task else "(none — set one with /subtask <text>)"
|
||||
|
||||
|
||||
def _strip_quotes(text: str) -> str:
|
||||
"""Drop one layer of matching surrounding quotes from a command argument."""
|
||||
if len(text) >= 2 and text[0] == text[-1] and text[0] in ("'", '"'):
|
||||
return text[1:-1]
|
||||
return text
|
||||
|
||||
|
||||
def parse_command(line: str) -> InteractiveCommand | None:
|
||||
"""Parse an input line into an :class:`InteractiveCommand`.
|
||||
|
||||
Commands are ``/name`` optionally followed by free-text arguments
|
||||
(unused by the built-in commands, but the grammar already supports
|
||||
future ones like ``/subtask grab the red cube``). Returns ``None`` for
|
||||
lines that are not commands (no leading ``/`` or a bare ``/``).
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line.startswith("/"):
|
||||
return None
|
||||
head, *rest = line.split(maxsplit=1)
|
||||
name = head[1:].lower()
|
||||
if not name:
|
||||
return None
|
||||
return InteractiveCommand(name=name, args=rest[0].strip() if rest else "")
|
||||
|
||||
|
||||
class StdinCommandListener:
|
||||
"""Daemon thread that reads input lines and forwards them to a callback.
|
||||
|
||||
On POSIX the reader polls the stream with ``select`` so ``stop()`` can
|
||||
end the thread promptly; elsewhere (or for file-like objects without a
|
||||
file descriptor) it falls back to a blocking ``readline`` daemon thread
|
||||
that dies with the process. Blank lines are skipped; end-of-file and
|
||||
unexpected read errors trigger ``on_eof`` (an interactive Ctrl-D or an
|
||||
exhausted piped script both mean "no more commands" — the session must
|
||||
not keep the robot running with no way to command it).
|
||||
|
||||
Unlike :class:`lerobot.utils.keyboard_input.TerminalKeyListener`, this
|
||||
reader leaves the terminal in canonical (line-buffered, echoing) mode —
|
||||
the operator is typing chat-style commands, not pressing hotkeys.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_line: Callable[[str], None],
|
||||
on_eof: Callable[[], None] | None = None,
|
||||
stream: IO[str] | None = None,
|
||||
poll_interval_s: float = 0.2,
|
||||
) -> None:
|
||||
self._on_line = on_line
|
||||
self._on_eof = on_eof
|
||||
self._stream = stream if stream is not None else sys.stdin
|
||||
self._poll_interval_s = poll_interval_s
|
||||
self._running = False
|
||||
self._thread: Thread | None = None
|
||||
self._use_select = False
|
||||
if os.name == "posix":
|
||||
try:
|
||||
self._stream.fileno()
|
||||
self._use_select = True
|
||||
except (OSError, ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the reader thread (idempotent)."""
|
||||
if self._thread is not None:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = Thread(target=self._run, daemon=True, name="InteractiveStdin")
|
||||
self._thread.start()
|
||||
if not self._use_select:
|
||||
logger.info("stdin listener running in blocking mode (select unavailable for this stream)")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the reader thread.
|
||||
|
||||
Blocking-mode threads may be stuck inside ``readline`` and cannot be
|
||||
joined; they are daemons and die with the process. Late lines are
|
||||
ignored via the ``_running`` flag either way.
|
||||
"""
|
||||
self._running = False
|
||||
thread = self._thread
|
||||
self._thread = None
|
||||
if thread is not None and thread.is_alive() and self._use_select:
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
def _run(self) -> None:
|
||||
if self._use_select:
|
||||
self._run_select()
|
||||
else:
|
||||
self._run_blocking()
|
||||
|
||||
def _run_select(self) -> None:
|
||||
"""Poll the file descriptor and split lines from raw bytes.
|
||||
|
||||
Reading raw bytes (instead of ``stream.readline()``) matters: a
|
||||
buffered file object can slurp several lines off the descriptor at
|
||||
once, after which ``select`` reports the drained fd as not-ready and
|
||||
the buffered lines would never be delivered — breaking pasted or
|
||||
piped command sequences.
|
||||
"""
|
||||
fd = self._stream.fileno()
|
||||
buffer = b""
|
||||
while self._running:
|
||||
try:
|
||||
ready, _, _ = select.select([fd], [], [], self._poll_interval_s)
|
||||
except (OSError, ValueError): # stream closed underneath us
|
||||
self._emit_read_error()
|
||||
return
|
||||
if not ready:
|
||||
continue
|
||||
try:
|
||||
chunk = os.read(fd, 4096)
|
||||
except OSError:
|
||||
self._emit_read_error()
|
||||
return
|
||||
if not self._running:
|
||||
return
|
||||
if chunk == b"": # EOF: Ctrl-D or the piped input ended
|
||||
self._emit_line(buffer) # a final command without trailing newline still counts
|
||||
self._emit_eof()
|
||||
return
|
||||
buffer += chunk
|
||||
while b"\n" in buffer:
|
||||
raw, buffer = buffer.split(b"\n", 1)
|
||||
self._emit_line(raw)
|
||||
|
||||
def _run_blocking(self) -> None:
|
||||
while self._running:
|
||||
try:
|
||||
line = self._stream.readline()
|
||||
except (OSError, ValueError):
|
||||
self._emit_read_error()
|
||||
return
|
||||
if not self._running:
|
||||
return
|
||||
if line == "": # EOF
|
||||
self._emit_eof()
|
||||
return
|
||||
self._emit_line(line.encode() if isinstance(line, str) else line)
|
||||
|
||||
def _emit_line(self, raw: bytes) -> None:
|
||||
line = raw.decode(errors="replace").strip()
|
||||
if not line:
|
||||
return
|
||||
try:
|
||||
self._on_line(line)
|
||||
except Exception: # never let a handler error kill the reader thread
|
||||
logger.exception("Error while handling interactive input %r", line)
|
||||
|
||||
def _emit_eof(self) -> None:
|
||||
logger.info("Interactive input stream closed (EOF)")
|
||||
if self._on_eof is not None:
|
||||
try:
|
||||
self._on_eof()
|
||||
except Exception:
|
||||
logger.exception("Error while handling interactive input EOF")
|
||||
|
||||
def _emit_read_error(self) -> None:
|
||||
"""Treat an unexpected read failure like EOF so the session shuts down.
|
||||
|
||||
A dead command channel must not leave the robot running with no way
|
||||
to stop it. Deliberate ``stop()`` calls clear ``_running`` first and
|
||||
do not reach this path.
|
||||
"""
|
||||
if self._running:
|
||||
logger.warning("Interactive input stream failed — treating as EOF")
|
||||
self._emit_eof()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextQueryRequest:
|
||||
"""One immutable policy question paired with the view seen at submission."""
|
||||
|
||||
question: str
|
||||
observation: dict
|
||||
|
||||
|
||||
class TextQueryWorker:
|
||||
"""Single background worker for non-blocking interactive policy questions.
|
||||
|
||||
At most one request may be queued or running. This bounds the number of
|
||||
retained image tensors (the observation can live on the GPU) and gives the
|
||||
operator an explicit "busy" response instead of accumulating questions
|
||||
against increasingly stale views.
|
||||
"""
|
||||
|
||||
_STOP = object()
|
||||
_JOIN_TIMEOUT_S = 5.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
answer: Callable[[TextQueryRequest], str],
|
||||
on_answer: Callable[[TextQueryRequest, str], None],
|
||||
on_error: Callable[[TextQueryRequest, Exception], None],
|
||||
) -> None:
|
||||
self._answer = answer
|
||||
self._on_answer = on_answer
|
||||
self._on_error = on_error
|
||||
self._queue: Queue[TextQueryRequest | object] = Queue(maxsize=1)
|
||||
self._state_lock = Lock()
|
||||
self._busy = False
|
||||
self._stopping = Event()
|
||||
self._stop_enqueued = False
|
||||
self._thread: Thread | None = None
|
||||
|
||||
@property
|
||||
def busy(self) -> bool:
|
||||
with self._state_lock:
|
||||
return self._busy
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the worker (idempotent)."""
|
||||
with self._state_lock:
|
||||
if self._thread is not None or self._stopping.is_set():
|
||||
return
|
||||
self._thread = Thread(target=self._run, daemon=True, name="InteractiveTextQuery")
|
||||
self._thread.start()
|
||||
|
||||
def submit(self, request: TextQueryRequest) -> bool:
|
||||
"""Queue ``request`` without blocking; return ``False`` when busy or stopping."""
|
||||
with self._state_lock:
|
||||
if self._busy or self._stopping.is_set():
|
||||
return False
|
||||
self._busy = True
|
||||
# stop() takes the same lock before publishing its sentinel, so a
|
||||
# successful admission cannot race with shutdown and hit Queue.Full.
|
||||
self._queue.put_nowait(request)
|
||||
return True
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Reject new work, discard a queued request, and suppress late callbacks."""
|
||||
with self._state_lock:
|
||||
self._stopping.set()
|
||||
discard_request = not self._stop_enqueued
|
||||
if discard_request:
|
||||
self._discard_queued_request()
|
||||
|
||||
def stop(self, timeout_s: float = _JOIN_TIMEOUT_S) -> bool:
|
||||
"""Cancel queued work and give an active model call bounded time to finish.
|
||||
|
||||
Returns ``False`` when decoding is still stuck after ``timeout_s``. The
|
||||
worker is a daemon and callbacks stay suppressed, allowing hardware
|
||||
teardown to proceed instead of hanging indefinitely.
|
||||
"""
|
||||
self.cancel()
|
||||
with self._state_lock:
|
||||
thread = self._thread
|
||||
enqueue_stop = thread is not None and not self._stop_enqueued
|
||||
self._stop_enqueued = self._stop_enqueued or enqueue_stop
|
||||
if thread is None:
|
||||
return True
|
||||
if enqueue_stop:
|
||||
self._queue.put(self._STOP)
|
||||
thread.join(timeout=timeout_s)
|
||||
stopped = not thread.is_alive()
|
||||
if stopped:
|
||||
with self._state_lock:
|
||||
if self._thread is thread:
|
||||
self._thread = None
|
||||
return stopped
|
||||
|
||||
def _discard_queued_request(self) -> None:
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
except Empty:
|
||||
return
|
||||
self._queue.task_done()
|
||||
if item is self._STOP:
|
||||
# A concurrent/repeated cancel must not consume the sentinel that
|
||||
# an earlier stop() already published for the worker.
|
||||
self._queue.put_nowait(self._STOP)
|
||||
return
|
||||
with self._state_lock:
|
||||
self._busy = False
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
item = self._queue.get()
|
||||
try:
|
||||
if item is self._STOP:
|
||||
return
|
||||
request = item
|
||||
assert isinstance(request, TextQueryRequest)
|
||||
if self._stopping.is_set():
|
||||
continue
|
||||
try:
|
||||
answer = self._answer(request)
|
||||
except Exception as exc: # a language failure must not end robot control
|
||||
self._deliver(self._on_error, request, exc)
|
||||
else:
|
||||
self._deliver(self._on_answer, request, answer)
|
||||
finally:
|
||||
if item is not self._STOP:
|
||||
with self._state_lock:
|
||||
self._busy = False
|
||||
self._queue.task_done()
|
||||
|
||||
def _deliver(self, callback: Callable[..., None], *args) -> None:
|
||||
"""Linearize a result callback with cancellation."""
|
||||
with self._state_lock:
|
||||
if not self._stopping.is_set():
|
||||
callback(*args)
|
||||
|
||||
|
||||
class InteractiveSession:
|
||||
"""Drive a rollout strategy from chat-style stdin commands.
|
||||
|
||||
The session owns the outer lifecycle: after ``strategy.setup(ctx)`` the
|
||||
robot stays idle until ``/start``. Each run *segment* executes
|
||||
``strategy.run(ctx)`` on the calling (main) thread until the operator
|
||||
interrupts it or the strategy returns on its own (e.g. ``--duration``
|
||||
elapsed). ``/reset`` pauses the inference engine and returns the robot
|
||||
to its initial position while hardware and policy stay warm; ``/stop``
|
||||
ends the session so the caller can run ``strategy.teardown(ctx)`` — the
|
||||
same shutdown routine as non-interactive rollouts.
|
||||
|
||||
Requires ``ctx.runtime.shutdown_event`` to be a :class:`LinkedEvent`
|
||||
(installed by ``lerobot-rollout`` when ``--interactive=true``): the
|
||||
session sets the local flag to end a segment, and process signals still
|
||||
propagate through the parent.
|
||||
|
||||
Commands are last-write-wins: ``/reset`` and ``/stop`` cancel a pending
|
||||
``/start`` so the robot never starts moving after the operator's final
|
||||
command asked it not to. End-of-file on the command stream stops the
|
||||
session (a closed stdin means there is no way left to command the
|
||||
robot), so piped scripts must keep stdin open for the intended session
|
||||
duration, e.g. ``(printf '/start\\n'; sleep 60; printf '/stop\\n') |
|
||||
lerobot-rollout ... --interactive=true``.
|
||||
"""
|
||||
|
||||
_POLL_INTERVAL_S = 0.2
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
strategy: RolloutStrategy,
|
||||
ctx: RolloutContext,
|
||||
input_stream: IO[str] | None = None,
|
||||
) -> None:
|
||||
stop_event = ctx.runtime.shutdown_event
|
||||
if not isinstance(stop_event, LinkedEvent):
|
||||
raise TypeError(
|
||||
"InteractiveSession requires ctx.runtime.shutdown_event to be a LinkedEvent so "
|
||||
"/reset can end a run segment without triggering process shutdown. Build the "
|
||||
"rollout context with build_rollout_context(cfg, LinkedEvent(shutdown_event))."
|
||||
)
|
||||
self._strategy = strategy
|
||||
self._ctx = ctx
|
||||
self._segment_stop = stop_event
|
||||
self._global_shutdown = stop_event.parent
|
||||
# The instruction the rollout was launched with; /reset restores it.
|
||||
self._initial_task = ctx.policy.inference.task
|
||||
self._listener = StdinCommandListener(self._handle_line, on_eof=self._handle_eof, stream=input_stream)
|
||||
self._text_query = TextQueryWorker(
|
||||
self._answer_text_query,
|
||||
self._report_text_answer,
|
||||
self._report_text_error,
|
||||
)
|
||||
|
||||
# Written by the listener thread, consumed by the main loop.
|
||||
self._start_requested = Event()
|
||||
self._reset_requested = Event()
|
||||
self._stop_requested = Event()
|
||||
self._wake = Event()
|
||||
self._running = Event()
|
||||
|
||||
# name -> (handler, argument hint, help line); /help and the banner
|
||||
# render from this table, so future commands stay documented for free.
|
||||
self._commands: dict[str, tuple[Callable[[InteractiveCommand], None], str, str]] = {
|
||||
"start": (self._cmd_start, "", "start (or restart) the policy control loop"),
|
||||
"subtask": (self._cmd_subtask, " <text>", "set the instruction the policy follows"),
|
||||
"ask": (self._cmd_ask, " <question>", "ask the policy about the latest view"),
|
||||
"reset": (self._cmd_reset, "", "stop movement, return to initial position, restore the task"),
|
||||
"stop": (self._cmd_stop, "", "end the session and shut down"),
|
||||
"help": (self._cmd_help, "", "show this help"),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main-thread session loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the session until ``/stop``, EOF, engine failure, or a shutdown signal."""
|
||||
play_sounds = self._ctx.runtime.cfg.play_sounds
|
||||
muted_handlers: list[tuple[logging.Handler, int]] = []
|
||||
saved_warning_filters = warnings.filters[:]
|
||||
try:
|
||||
muted_handlers = _mute_console_log_handlers()
|
||||
warnings.simplefilter("ignore")
|
||||
self._print(self._render_banner())
|
||||
self._text_query.start()
|
||||
self._listener.start()
|
||||
while not self._global_shutdown.is_set():
|
||||
if self._ctx.policy.inference.failed:
|
||||
self._report_engine_failure()
|
||||
break
|
||||
if self._stop_requested.is_set():
|
||||
break
|
||||
if self._reset_requested.is_set():
|
||||
self._reset_requested.clear()
|
||||
self._reset_robot()
|
||||
continue
|
||||
if self._start_requested.is_set():
|
||||
self._start_requested.clear()
|
||||
self._run_segment()
|
||||
continue
|
||||
self._wake.wait(timeout=self._POLL_INTERVAL_S)
|
||||
self._wake.clear()
|
||||
finally:
|
||||
self._listener.stop()
|
||||
# A model call cannot be force-cancelled safely. Give it a bounded
|
||||
# grace period, then prioritize hardware teardown if it is wedged.
|
||||
if not self._text_query.stop():
|
||||
self._print(
|
||||
"Policy question did not finish within 5 seconds — "
|
||||
"continuing hardware shutdown; its daemon thread will be abandoned."
|
||||
)
|
||||
# Restore before log_say so teardown logs are visible again.
|
||||
_restore_log_handlers(muted_handlers)
|
||||
warnings.filters[:] = saved_warning_filters
|
||||
log_say("Interactive session ended", play_sounds)
|
||||
|
||||
def _report_engine_failure(self) -> None:
|
||||
"""Surface a fatal engine error despite the muted console logging."""
|
||||
self._print("Inference engine failed — shutting down.")
|
||||
failure_traceback = self._ctx.policy.inference.failure_traceback
|
||||
if failure_traceback:
|
||||
self._print(failure_traceback)
|
||||
else:
|
||||
self._print("Re-run without --interactive=true to see the error output.")
|
||||
|
||||
def _run_segment(self) -> None:
|
||||
"""Execute one ``strategy.run`` segment until interrupted or finished."""
|
||||
engine = self._ctx.policy.inference
|
||||
# Clear the local flag *before* checking the request flags: command
|
||||
# handlers set their flag first and the segment-stop event second, so
|
||||
# a /reset or /stop racing with this /start is either seen here or
|
||||
# ends the freshly started loop on its first tick.
|
||||
self._segment_stop.clear()
|
||||
if self._stop_requested.is_set() or self._reset_requested.is_set() or self._global_shutdown.is_set():
|
||||
return
|
||||
self._strategy.reset_control_state()
|
||||
log_say("Starting rollout", self._ctx.runtime.cfg.play_sounds)
|
||||
self._print(
|
||||
f"Rollout running — task {_format_task(engine.task)}. "
|
||||
"/subtask <text> to change it, /ask <question> to query the policy, "
|
||||
"/reset to return to initial position, /stop to shut down."
|
||||
)
|
||||
self._running.set()
|
||||
try:
|
||||
self._strategy.run(self._ctx)
|
||||
finally:
|
||||
self._running.clear()
|
||||
engine.pause()
|
||||
if engine.failed:
|
||||
return # the session loop reports the failure and shuts down
|
||||
if not (
|
||||
self._stop_requested.is_set() or self._reset_requested.is_set() or self._global_shutdown.is_set()
|
||||
):
|
||||
self._print(
|
||||
"Rollout run ended on its own (duration reached). Robot is holding position — "
|
||||
"/start to run again, /reset to return to initial position, /stop to shut down."
|
||||
)
|
||||
|
||||
def _reset_robot(self) -> None:
|
||||
"""Pause inference and return the robot home (the task was restored by ``/reset``)."""
|
||||
self._print("Resetting — returning the robot to its initial position...")
|
||||
self._ctx.policy.inference.pause()
|
||||
log_say("Resetting robot to initial position", self._ctx.runtime.cfg.play_sounds)
|
||||
if self._ctx.hardware.initial_position:
|
||||
self._strategy.return_to_initial_position(self._ctx.hardware)
|
||||
self._print("Robot reset — holding at initial position. /start to run.")
|
||||
else:
|
||||
logger.warning("No initial position captured — skipping the return move")
|
||||
self._print("Robot paused — no initial position captured, holding current pose. /start to run.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Command handlers (called from the listener thread; only set flags)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_line(self, line: str) -> None:
|
||||
cmd = parse_command(line)
|
||||
if cmd is None:
|
||||
self._print("Input not recognized — commands start with '/'. Type /help for the list.")
|
||||
return
|
||||
entry = self._commands.get(cmd.name)
|
||||
if entry is None:
|
||||
self._print(f"Unknown command '/{cmd.name}'. Type /help for the list.")
|
||||
return
|
||||
handler = entry[0]
|
||||
handler(cmd)
|
||||
|
||||
def _handle_eof(self) -> None:
|
||||
self._print("Input stream closed — stopping the session.")
|
||||
self._request_stop()
|
||||
|
||||
def _cmd_start(self, cmd: InteractiveCommand) -> None:
|
||||
if self._running.is_set():
|
||||
self._print("Already running — /reset to pause first, or /stop to shut down.")
|
||||
return
|
||||
if self._text_query.busy:
|
||||
self._print("A policy question is still finishing — wait for it before /start.")
|
||||
return
|
||||
self._start_requested.set()
|
||||
self._wake.set()
|
||||
|
||||
def _cmd_subtask(self, cmd: InteractiveCommand) -> None:
|
||||
engine = self._ctx.policy.inference
|
||||
if not cmd.args:
|
||||
self._print(f"Current task: {_format_task(engine.task)}")
|
||||
return
|
||||
task = _strip_quotes(cmd.args)
|
||||
previous = engine.task
|
||||
# Publishing the string is all this thread does: the engine applies the
|
||||
# switch on its own inference thread.
|
||||
if engine.set_task(task):
|
||||
self._print(
|
||||
f"Task: {_format_task(previous)} → {_format_task(task)} "
|
||||
"(applies from the next policy inference)"
|
||||
)
|
||||
else:
|
||||
self._print(f"Task unchanged: {_format_task(task)}")
|
||||
|
||||
def _cmd_ask(self, cmd: InteractiveCommand) -> None:
|
||||
question = _strip_quotes(cmd.args)
|
||||
if not question:
|
||||
self._print("Usage: /ask <question>")
|
||||
return
|
||||
engine = self._ctx.policy.inference
|
||||
if not engine.supports_text_generation():
|
||||
self._print("This policy does not support /ask (it has no text-generation head).")
|
||||
return
|
||||
if not self._running.is_set():
|
||||
self._print("The rollout is not running — /start it before using /ask.")
|
||||
return
|
||||
observation = engine.snapshot_text_observation()
|
||||
if observation is None:
|
||||
self._print("No policy observation is available yet — /start the rollout and try again.")
|
||||
return
|
||||
request = TextQueryRequest(question=question, observation=observation)
|
||||
if not self._text_query.submit(request):
|
||||
self._print("A policy question is already being answered — try again when it finishes.")
|
||||
return
|
||||
self._print(f"Question queued: {question!r} (the rollout keeps running)")
|
||||
|
||||
def _answer_text_query(self, request: TextQueryRequest) -> str:
|
||||
return self._ctx.policy.inference.generate_text(
|
||||
request.observation,
|
||||
kind=TextKind.VQA,
|
||||
user_text=request.question,
|
||||
)
|
||||
|
||||
def _report_text_answer(self, request: TextQueryRequest, answer: str) -> None:
|
||||
if answer:
|
||||
self._print(f"[policy] {answer}")
|
||||
else:
|
||||
self._print(f"The policy returned no answer for {request.question!r}.")
|
||||
|
||||
def _report_text_error(self, request: TextQueryRequest, exc: Exception) -> None:
|
||||
self._print(f"Policy question failed ({type(exc).__name__}): {exc}")
|
||||
|
||||
def _cmd_reset(self, cmd: InteractiveCommand) -> None:
|
||||
# Last command wins: a /start still waiting to be serviced is cancelled
|
||||
# so the robot never starts moving after the operator asked it not to.
|
||||
# Flag first, segment-stop second (see the ordering note in _run_segment).
|
||||
self._start_requested.clear()
|
||||
# Restore the task here rather than in _reset_robot (which runs later, on
|
||||
# the main thread) so that both task writers run on this thread and are
|
||||
# ordered by command order — otherwise a /subtask typed right after
|
||||
# /reset would be silently reverted by the deferred restore.
|
||||
engine = self._ctx.policy.inference
|
||||
if engine.set_task(self._initial_task):
|
||||
self._print(f"Task restored to {_format_task(self._initial_task)}")
|
||||
# Homing changes the scene outside normal inference. Invalidate the
|
||||
# cached VLM input synchronously so a following /ask cannot capture
|
||||
# the pre-reset view while the main thread is still unwinding.
|
||||
engine.invalidate_text_observation()
|
||||
self._reset_requested.set()
|
||||
self._segment_stop.set()
|
||||
self._wake.set()
|
||||
|
||||
def _cmd_stop(self, cmd: InteractiveCommand) -> None:
|
||||
self._request_stop()
|
||||
|
||||
def _request_stop(self) -> None:
|
||||
# Suppress a queued/finishing answer as soon as /stop or EOF is
|
||||
# observed; stop() in the session's finally block gives the model call
|
||||
# bounded time to finish before hardware teardown continues.
|
||||
self._text_query.cancel()
|
||||
self._start_requested.clear() # last command wins, see _cmd_reset
|
||||
self._stop_requested.set()
|
||||
self._segment_stop.set()
|
||||
self._wake.set()
|
||||
|
||||
def _cmd_help(self, cmd: InteractiveCommand) -> None:
|
||||
self._print(self._render_help())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _render_help(self) -> str:
|
||||
usages = {name: f"/{name}{entry[1]}" for name, entry in self._commands.items()}
|
||||
width = max(len(usage) for usage in usages.values())
|
||||
lines = [f" {usages[name]:<{width}} {entry[2]}" for name, entry in self._commands.items()]
|
||||
return "Available commands:\n" + "\n".join(lines)
|
||||
|
||||
def _render_banner(self) -> str:
|
||||
return (
|
||||
f"{_BANNER_RULE}\n"
|
||||
"Interactive rollout session — the robot will NOT move until you type /start.\n"
|
||||
f"Task: {_format_task(self._initial_task)}\n"
|
||||
f"{self._render_help()}\n"
|
||||
"System logs and warnings are muted during the session; they resume when it ends.\n"
|
||||
f"{_BANNER_RULE}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _print(message: str) -> None:
|
||||
"""User-facing chat output; logging stays on stderr, replies on stdout."""
|
||||
print(message, flush=True)
|
||||
@@ -63,12 +63,25 @@ class RolloutStrategy(abc.ABC):
|
||||
self._interpolator = ActionInterpolator(multiplier=ctx.runtime.cfg.interpolation_multiplier)
|
||||
self._engine = ctx.policy.inference
|
||||
logger.info("Starting inference engine...")
|
||||
self._engine.reset()
|
||||
self.reset_control_state()
|
||||
self._engine.start()
|
||||
self._warmup_flushed = False
|
||||
self._cached_obs_processed = None
|
||||
logger.info("Inference engine started")
|
||||
|
||||
def reset_control_state(self) -> None:
|
||||
"""Clear episode-scoped control state so a paused session can restart cleanly.
|
||||
|
||||
Resets the inference engine (policy hidden state, action queues), the
|
||||
action interpolator, and the cached processed observation. Used by the
|
||||
interactive session between run segments; only call while the control
|
||||
loop is not running.
|
||||
"""
|
||||
if self._engine is not None:
|
||||
self._engine.reset()
|
||||
if self._interpolator is not None:
|
||||
self._interpolator.reset()
|
||||
self._cached_obs_processed = None
|
||||
|
||||
def _process_observation_and_notify(self, processors: ProcessorContext, obs_raw: dict) -> dict:
|
||||
"""Run the observation processor and notify the engine — throttled to policy ticks.
|
||||
|
||||
@@ -125,7 +138,7 @@ class RolloutStrategy(abc.ABC):
|
||||
if robot.is_connected:
|
||||
if return_to_initial_position and hw.initial_position:
|
||||
logger.info("Returning robot to initial position before shutdown...")
|
||||
self._return_to_initial_position(hw)
|
||||
self.return_to_initial_position(hw)
|
||||
elif not return_to_initial_position:
|
||||
logger.info(
|
||||
"Skipping return-to-initial-position (disabled by config); leaving robot in final pose."
|
||||
@@ -138,7 +151,7 @@ class RolloutStrategy(abc.ABC):
|
||||
teleop.disconnect()
|
||||
|
||||
@staticmethod
|
||||
def _return_to_initial_position(hw: HardwareContext, duration_s: float = 3.0, fps: int = 50) -> None:
|
||||
def return_to_initial_position(hw: HardwareContext, duration_s: float = 3.0, fps: int = 50) -> None:
|
||||
"""Smoothly interpolate the robot back to its initial position."""
|
||||
robot = hw.robot_wrapper
|
||||
target = hw.initial_position
|
||||
|
||||
@@ -165,7 +165,7 @@ class EpisodicStrategy(RolloutStrategy):
|
||||
|
||||
elif self.config.reset_to_initial_position:
|
||||
# No teleop: return the robot to its startup position.
|
||||
self._return_to_initial_position(hw=ctx.hardware, duration_s=1)
|
||||
self.return_to_initial_position(hw=ctx.hardware, duration_s=1)
|
||||
|
||||
self._reset_loop(
|
||||
ctx=ctx,
|
||||
@@ -187,7 +187,7 @@ class EpisodicStrategy(RolloutStrategy):
|
||||
|
||||
# returns to its initial joint positions captured at startup
|
||||
if not teleop and self.config.reset_to_initial_position:
|
||||
self._return_to_initial_position(hw=ctx.hardware, duration_s=1)
|
||||
self.return_to_initial_position(hw=ctx.hardware, duration_s=1)
|
||||
|
||||
continue
|
||||
|
||||
|
||||
@@ -44,6 +44,18 @@ Usage examples
|
||||
--robot.port=/dev/ttyACM0 \\
|
||||
--task="pick up cube" --duration=30
|
||||
|
||||
# Base mode — interactive session: the robot stays idle until /start is
|
||||
# typed; /subtask <text> re-instructs the policy mid-run; /ask <question>
|
||||
# queries a supported policy text head in the background; /reset returns
|
||||
# to the initial position (hardware and policy stay warm); /stop shuts down
|
||||
lerobot-rollout \\
|
||||
--strategy.type=base \\
|
||||
--policy.path=lerobot/act_koch_real \\
|
||||
--robot.type=koch_follower \\
|
||||
--robot.port=/dev/ttyACM0 \\
|
||||
--task="pick up cube" \\
|
||||
--interactive=true
|
||||
|
||||
# Base mode — RTC inference for slow VLAs (Pi0, Pi0.5, SmolVLA)
|
||||
lerobot-rollout \\
|
||||
--strategy.type=base \\
|
||||
@@ -173,7 +185,13 @@ from lerobot.robots import ( # noqa: F401
|
||||
so_follower,
|
||||
unitree_g1 as unitree_g1_robot,
|
||||
)
|
||||
from lerobot.rollout import RolloutConfig, build_rollout_context, create_strategy
|
||||
from lerobot.rollout import (
|
||||
InteractiveSession,
|
||||
LinkedEvent,
|
||||
RolloutConfig,
|
||||
build_rollout_context,
|
||||
create_strategy,
|
||||
)
|
||||
from lerobot.teleoperators import ( # noqa: F401
|
||||
Teleoperator,
|
||||
TeleoperatorConfig,
|
||||
@@ -215,6 +233,10 @@ def rollout(cfg: RolloutConfig):
|
||||
|
||||
signal_handler = ProcessSignalHandler(use_threads=True, display_pid=False)
|
||||
shutdown_event = signal_handler.shutdown_event
|
||||
if cfg.interactive:
|
||||
# Session commands (/reset, /stop) end the running control loop by setting
|
||||
# the local flag; process signals still propagate through the parent event.
|
||||
shutdown_event = LinkedEvent(shutdown_event)
|
||||
|
||||
logger.info("Building rollout context...")
|
||||
ctx = build_rollout_context(cfg, shutdown_event)
|
||||
@@ -230,8 +252,12 @@ def rollout(cfg: RolloutConfig):
|
||||
|
||||
try:
|
||||
strategy.setup(ctx)
|
||||
logger.info("Rollout setup complete, starting rollout...")
|
||||
strategy.run(ctx)
|
||||
if cfg.interactive:
|
||||
logger.info("Rollout setup complete — starting interactive session (robot idle until /start)")
|
||||
InteractiveSession(strategy, ctx).run()
|
||||
else:
|
||||
logger.info("Rollout setup complete, starting rollout...")
|
||||
strategy.run(ctx)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Interrupted by user")
|
||||
finally:
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
"""Doctest plumbing so the examples in our docstrings actually run.
|
||||
|
||||
Adapted from `transformers.testing_utils`. Two stdlib limitations make this necessary:
|
||||
|
||||
1. Ruff is configured with `docstring-code-format = true`, which reformats code inside docstrings and
|
||||
removes the blank line before the closing fence. stdlib's `_EXAMPLE_RE` then swallows the ` ``` ` into
|
||||
the expected-output group, so every example that has output fails. [`LeRobotDocTestParser`] patches the
|
||||
regex to stop at a fence.
|
||||
2. `doctest.DocTestFinder` reports the wrong line number for `@property` and `functools.wraps` objects
|
||||
(https://bugs.python.org/issue17446). Our hardware API is property-heavy — `observation_features`,
|
||||
`action_features`, `is_connected`, `is_calibrated` are all abstract properties — so
|
||||
[`LeRobotDoctestModule`] unwraps them before locating the example.
|
||||
|
||||
Two environment variables skip whole example blocks by content:
|
||||
|
||||
- `SKIP_CUDA_DOCTEST=1` skips examples that need a GPU.
|
||||
- `SKIP_HARDWARE_DOCTEST=1` skips examples that need a physical robot or a Hub download.
|
||||
|
||||
Both are heuristics over the example source. They are deliberately blunt: an example that is skipped
|
||||
needlessly costs nothing, whereas one that runs on a machine without the hardware hangs or fails.
|
||||
"""
|
||||
|
||||
import doctest
|
||||
import functools
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Iterable
|
||||
|
||||
from _pytest.doctest import (
|
||||
DoctestItem,
|
||||
DoctestModule,
|
||||
_get_checker,
|
||||
_get_continue_on_failure,
|
||||
_get_runner,
|
||||
get_optionflags,
|
||||
)
|
||||
from _pytest.nodes import Collector
|
||||
from _pytest.outcomes import skip
|
||||
|
||||
# Calls whose progress bars would otherwise be compared against the expected output. The lookahead leaves
|
||||
# lines that already carry a directive alone.
|
||||
_NOISY_CALL_PATTERN = re.compile(r"(>>> (?!.*# doctest:).*(?:load_dataset|LeRobotDataset)\(.*)")
|
||||
|
||||
_CUDA_PATTERN = re.compile(r"cuda|to\(0\)|device=0")
|
||||
|
||||
# Serial ports, video devices, and the connect/scan calls that talk to real hardware.
|
||||
_HARDWARE_PATTERN = re.compile(r"/dev/tty|/dev/video|COM\d|\.connect\(|find_cameras\(|find_port\(")
|
||||
|
||||
# Anything that reaches the Hub over the network.
|
||||
_HUB_PATTERN = re.compile(r"from_pretrained\(|push_to_hub\(|snapshot_download\(|load_dataset\(")
|
||||
|
||||
|
||||
def preprocess_string(string: str, skip_cuda_tests: bool, skip_hardware_tests: bool) -> str:
|
||||
"""Prepare a docstring or `.mdx` file to be run by doctest.
|
||||
|
||||
Args:
|
||||
string (`str`):
|
||||
A whole file's contents for `.mdx`, or a single docstring for a Python file. Either may hold
|
||||
several fenced examples.
|
||||
skip_cuda_tests (`bool`):
|
||||
Whether to drop examples that look like they need a GPU.
|
||||
skip_hardware_tests (`bool`):
|
||||
Whether to drop examples that look like they need a robot or a Hub download.
|
||||
|
||||
Returns:
|
||||
`str`: The input with `# doctest: +IGNORE_RESULT` injected on noisy calls, or an empty string if
|
||||
the examples were skipped — in which case no doctest is collected for it at all.
|
||||
"""
|
||||
# Match against the example lines only, not the surrounding prose, so that a docstring merely
|
||||
# *describing* CUDA or a serial port is not mistaken for one that uses them.
|
||||
example_lines = "\n".join(
|
||||
line for line in string.splitlines() if line.lstrip().startswith((">>>", "..."))
|
||||
)
|
||||
if not example_lines:
|
||||
return string
|
||||
|
||||
if skip_cuda_tests and _CUDA_PATTERN.search(example_lines):
|
||||
return ""
|
||||
if skip_hardware_tests and (
|
||||
_HARDWARE_PATTERN.search(example_lines) or _HUB_PATTERN.search(example_lines)
|
||||
):
|
||||
return ""
|
||||
|
||||
return _NOISY_CALL_PATTERN.sub(r"\1 # doctest: +IGNORE_RESULT", string)
|
||||
|
||||
|
||||
class LeRobotDocTestParser(doctest.DocTestParser):
|
||||
"""A `DocTestParser` that understands fenced, auto-formatted code blocks.
|
||||
|
||||
Ruff's `docstring-code-format` removes the blank line before a closing fence, after which stdlib's
|
||||
`_EXAMPLE_RE` reads the fence itself as part of the expected output and every example with output
|
||||
fails. The regex below is the stdlib one plus a clause that stops matching at a fence.
|
||||
"""
|
||||
|
||||
# fmt: off
|
||||
_EXAMPLE_RE = re.compile(r'''
|
||||
# Source consists of a PS1 line followed by zero or more PS2 lines.
|
||||
(?P<source>
|
||||
(?:^(?P<indent> [ ]*) >>> .*) # PS1 line
|
||||
(?:\n [ ]* \.\.\. .*)*) # PS2 lines
|
||||
\n?
|
||||
# Want consists of any non-blank lines that do not start with PS1.
|
||||
(?P<want> (?:(?![ ]*$) # Not a blank line
|
||||
(?![ ]*>>>) # Not a line starting with PS1
|
||||
(?:(?!```).)* # Stop at a closing fence: formatting drops the blank line before it
|
||||
(?:\n|$) # Match a new line or end of string
|
||||
)*)
|
||||
''', re.MULTILINE | re.VERBOSE
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
skip_cuda_tests: bool = os.environ.get("SKIP_CUDA_DOCTEST", "0") == "1"
|
||||
skip_hardware_tests: bool = os.environ.get("SKIP_HARDWARE_DOCTEST", "0") == "1"
|
||||
|
||||
def parse(self, string, name="<string>"):
|
||||
"""Preprocess `string`, then parse it as stdlib would.
|
||||
|
||||
Args:
|
||||
string (`str`):
|
||||
The docstring or file contents to parse.
|
||||
name (`str`, *optional*, defaults to `"<string>"`):
|
||||
Name used in failure messages.
|
||||
|
||||
Returns:
|
||||
`list`: The examples and interleaved text, as returned by `doctest.DocTestParser.parse`.
|
||||
"""
|
||||
string = preprocess_string(string, self.skip_cuda_tests, self.skip_hardware_tests)
|
||||
return super().parse(string, name)
|
||||
|
||||
|
||||
class LeRobotDoctestModule(DoctestModule):
|
||||
"""A pytest `DoctestModule` that collects with [`LeRobotDocTestParser`].
|
||||
|
||||
`doctest.DocTestFinder` binds its default parser at class-definition time, so patching
|
||||
`doctest.DocTestParser` in `conftest.py` does not reach the finder pytest builds. The parser has to be
|
||||
passed in explicitly, which means reimplementing `collect`. It mirrors pytest's own implementation.
|
||||
"""
|
||||
|
||||
def collect(self) -> Iterable[DoctestItem]:
|
||||
"""Collect the doctests in this module.
|
||||
|
||||
Returns:
|
||||
`Iterable[DoctestItem]`: One item per example-bearing docstring. Docstrings whose examples were
|
||||
dropped by `preprocess_string` yield nothing.
|
||||
"""
|
||||
|
||||
class MockAwareDocTestFinder(doctest.DocTestFinder):
|
||||
"""A doctest finder that reports correct line numbers for properties and wrapped callables."""
|
||||
|
||||
# Fixed upstream in CPython 3.11.9 / 3.12.3; kept for older interpreters. Our hardware API is
|
||||
# property-heavy (`observation_features`, `is_connected`, ...), so a wrong line number here
|
||||
# would point every failure at the decorator. https://github.com/python/cpython/issues/61648
|
||||
def _find_lineno(self, obj, source_lines):
|
||||
if isinstance(obj, property):
|
||||
obj = getattr(obj, "fget", obj)
|
||||
if hasattr(obj, "__wrapped__"):
|
||||
obj = inspect.unwrap(obj)
|
||||
return super()._find_lineno(obj, source_lines)
|
||||
|
||||
if sys.version_info < (3, 13):
|
||||
# `cached_property` is otherwise never considered part of the current module and its
|
||||
# examples are silently skipped. https://github.com/python/cpython/issues/107995
|
||||
def _from_module(self, module, object):
|
||||
if isinstance(object, functools.cached_property):
|
||||
object = object.func
|
||||
return super()._from_module(module, object)
|
||||
|
||||
try:
|
||||
module = self.obj
|
||||
except Collector.CollectError:
|
||||
if self.config.getvalue("doctest_ignore_import_errors"):
|
||||
skip(f"unable to import module {self.path!r}")
|
||||
else:
|
||||
raise
|
||||
|
||||
# Doctests support fixtures via `getfixture` and autouse.
|
||||
self.session._fixturemanager.parsefactories(self)
|
||||
|
||||
finder = MockAwareDocTestFinder(parser=LeRobotDocTestParser())
|
||||
optionflags = get_optionflags(self.config)
|
||||
runner = _get_runner(
|
||||
verbose=False,
|
||||
optionflags=optionflags,
|
||||
checker=_get_checker(),
|
||||
continue_on_failure=_get_continue_on_failure(self.config),
|
||||
)
|
||||
for test in finder.find(module, module.__name__):
|
||||
if test.examples: # Skip docstrings with no examples, and blocks dropped by the parser.
|
||||
yield DoctestItem.from_parent(self, name=test.name, runner=runner, dtest=test)
|
||||
@@ -16,6 +16,7 @@ from conftest import (
|
||||
make_config,
|
||||
set_seed_all,
|
||||
) # noqa: E402
|
||||
|
||||
from lerobot.policies.vla_jepa.action_head import ( # noqa: E402
|
||||
VLAJEPAActionHead,
|
||||
)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import ACTION_DIM, ACTION_HORIZON, IMAGE_SIZE, NUM_VIDEO_FRAMES, STATE_DIM, make_config
|
||||
|
||||
from lerobot.configs.types import FeatureType, PolicyFeature
|
||||
from lerobot.policies.vla_jepa.configuration_vla_jepa import VLAJEPAConfig
|
||||
from lerobot.utils.constants import ACTION, OBS_IMAGES, OBS_STATE
|
||||
|
||||
@@ -32,6 +32,7 @@ from conftest import ( # noqa: E402
|
||||
make_train_batch,
|
||||
set_seed_all,
|
||||
)
|
||||
|
||||
from lerobot.policies.vla_jepa.configuration_vla_jepa import VLAJEPAConfig # noqa: E402
|
||||
from lerobot.policies.vla_jepa.modeling_vla_jepa import VLAJEPAPolicy # noqa: E402
|
||||
from lerobot.utils.constants import ACTION # noqa: E402
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
"""Test script to verify Wall-X policy integration with LeRobot"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
@@ -24,11 +26,15 @@ pytest.importorskip("peft")
|
||||
pytest.importorskip("transformers")
|
||||
pytest.importorskip("torchdiffeq")
|
||||
|
||||
from lerobot.configs import TextKind # noqa: E402
|
||||
from lerobot.policies.factory import make_policy_config # noqa: E402
|
||||
from lerobot.policies.wall_x import (
|
||||
WallXConfig, # noqa: E402
|
||||
)
|
||||
from lerobot.policies.wall_x.modeling_wall_x import WallXPolicy # noqa: E402
|
||||
from lerobot.policies.wall_x.modeling_wall_x import ( # noqa: E402
|
||||
Qwen2_5_VLMoEForAction,
|
||||
WallXPolicy,
|
||||
)
|
||||
from lerobot.policies.wall_x.processor_wall_x import make_wall_x_pre_post_processors # noqa: E402
|
||||
from lerobot.policies.wall_x.qwen_model import Qwen2_5_VLMoEModel, Qwen2_5_VLTextConfig # noqa: E402
|
||||
from lerobot.utils.random_utils import set_seed # noqa: E402
|
||||
@@ -76,6 +82,80 @@ def test_moe_model_captures_requested_hidden_states_and_attentions():
|
||||
assert len(output.attentions) == config.num_hidden_layers
|
||||
|
||||
|
||||
def _make_unloaded_policy():
|
||||
policy = WallXPolicy.__new__(WallXPolicy)
|
||||
torch.nn.Module.__init__(policy)
|
||||
policy.config = SimpleNamespace(
|
||||
text_temperature=0.0,
|
||||
text_top_p=1.0,
|
||||
)
|
||||
return policy
|
||||
|
||||
|
||||
def test_policy_exposes_grounded_text_generation(monkeypatch):
|
||||
class Inputs(dict):
|
||||
__getattr__ = dict.__getitem__
|
||||
|
||||
class Tokenizer:
|
||||
eos_token_id = 2
|
||||
pad_token_id = 0
|
||||
|
||||
@staticmethod
|
||||
def batch_decode(token_ids, **kwargs):
|
||||
del kwargs
|
||||
assert torch.equal(token_ids, torch.tensor([[7, 8]]))
|
||||
return ["The mug is beside the bowl."]
|
||||
|
||||
class Model:
|
||||
processor = SimpleNamespace(tokenizer=Tokenizer())
|
||||
|
||||
@staticmethod
|
||||
def generate(input_ids, **kwargs):
|
||||
del kwargs
|
||||
return torch.cat([input_ids, torch.tensor([[7, 8]])], dim=1)
|
||||
|
||||
policy = _make_unloaded_policy()
|
||||
policy.model = Model()
|
||||
inputs = Inputs(input_ids=torch.tensor([[1, 2, 3]]), attention_mask=torch.ones(1, 3))
|
||||
monkeypatch.setattr(policy, "_build_text_inputs", lambda *args, **kwargs: inputs)
|
||||
|
||||
batch = {"observation.state": torch.zeros(1, 7), "task": "pick up the cup"}
|
||||
assert (
|
||||
policy.generate_text(batch, kind=TextKind.VQA, user_text="Where is the mug?")
|
||||
== "The mug is beside the bowl."
|
||||
)
|
||||
assert policy.supports_text_generation()
|
||||
|
||||
prompt = policy._format_text_prompt(
|
||||
"Where is the mug?",
|
||||
TextKind.VQA,
|
||||
["observation.images.face_view"],
|
||||
)
|
||||
assert "Observation: front view:" in prompt
|
||||
assert "Instruction: Where is the mug?" in prompt
|
||||
assert prompt.endswith("<|im_start|>assistant\n")
|
||||
|
||||
|
||||
def test_text_generation_synthesizes_missing_cache_position():
|
||||
class Cache:
|
||||
@staticmethod
|
||||
def get_seq_length():
|
||||
return 3
|
||||
|
||||
pixel_values = torch.ones(1, 3, 4, 4)
|
||||
inputs = Qwen2_5_VLMoEForAction.prepare_inputs_for_generation(
|
||||
object(),
|
||||
torch.tensor([[9]]),
|
||||
past_key_values=Cache(),
|
||||
pixel_values=pixel_values,
|
||||
)
|
||||
|
||||
assert torch.equal(inputs["cache_position"], torch.tensor([3]))
|
||||
assert torch.equal(inputs["input_ids"], torch.tensor([[9]]))
|
||||
# A continuation token reuses the KV cache, so the image is not encoded again.
|
||||
assert inputs["pixel_values"] is None
|
||||
|
||||
|
||||
@require_cuda
|
||||
@require_hf_token
|
||||
def test_policy_instantiation():
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@ N_EPISODES = 2
|
||||
EPISODE_LENGTH = 12
|
||||
|
||||
|
||||
def test_ema_config_defaults_match_the_reference():
|
||||
def test_ema_config_defaults_match_reference():
|
||||
cfg = EMAConfig()
|
||||
assert not cfg.enable
|
||||
assert cfg.inv_gamma == 1.0
|
||||
|
||||
@@ -21,10 +21,8 @@ This module tests multi-GPU training functionality with accelerate.
|
||||
These tests are designed to run on machines with 2+ GPUs and are executed
|
||||
in the nightly CI workflow.
|
||||
|
||||
The tests launch `lerobot-train` through `accelerate launch` in a subprocess to properly test the
|
||||
distributed training environment. Accelerate is used as a plain launcher only: the topology comes
|
||||
from `--parallelism.*` flags, never from an accelerate YAML config (see
|
||||
`lerobot.distributed.factory.guard_against_env_interference`).
|
||||
The tests automatically generate accelerate configs and launch training
|
||||
with subprocess to properly test the distributed training environment.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -60,25 +58,73 @@ def download_dataset(repo_id, episodes):
|
||||
print(f"Dataset {repo_id} downloaded successfully")
|
||||
|
||||
|
||||
def run_accelerate_training(config_args, num_processes=4):
|
||||
def _write_multi_gpu_config(f, num_processes):
|
||||
f.write("compute_environment: LOCAL_MACHINE\n")
|
||||
f.write("distributed_type: MULTI_GPU\n")
|
||||
f.write("mixed_precision: 'no'\n")
|
||||
f.write(f"num_processes: {num_processes}\n")
|
||||
f.write("use_cpu: false\n")
|
||||
f.write("gpu_ids: all\n")
|
||||
f.write("downcast_bf16: 'no'\n")
|
||||
f.write("machine_rank: 0\n")
|
||||
f.write("main_training_function: main\n")
|
||||
f.write("num_machines: 1\n")
|
||||
f.write("rdzv_backend: static\n")
|
||||
f.write("same_network: true\n")
|
||||
|
||||
|
||||
def _write_fsdp_config(f, num_processes):
|
||||
# FSDP1 with FULL_SHARD (ZeRO-3-equivalent) and FULL_STATE_DICT, matching
|
||||
# docs/source/multi_gpu_training.mdx. ACT's repeated transformer blocks are the wrap units;
|
||||
# fsdp_use_orig_params is required because LeRobot builds the optimizer before prepare().
|
||||
f.write("compute_environment: LOCAL_MACHINE\n")
|
||||
f.write("distributed_type: FSDP\n")
|
||||
f.write("mixed_precision: 'no'\n")
|
||||
f.write(f"num_processes: {num_processes}\n")
|
||||
f.write("use_cpu: false\n")
|
||||
f.write("gpu_ids: all\n")
|
||||
f.write("machine_rank: 0\n")
|
||||
f.write("main_training_function: main\n")
|
||||
f.write("num_machines: 1\n")
|
||||
f.write("rdzv_backend: static\n")
|
||||
f.write("same_network: true\n")
|
||||
f.write("fsdp_config:\n")
|
||||
f.write(" fsdp_version: 1\n")
|
||||
f.write(" fsdp_sharding_strategy: FULL_SHARD\n")
|
||||
f.write(" fsdp_auto_wrap_policy: TRANSFORMER_BASED_WRAP\n")
|
||||
f.write(" fsdp_transformer_layer_cls_to_wrap: ACTEncoderLayer,ACTDecoderLayer\n")
|
||||
f.write(" fsdp_use_orig_params: true\n")
|
||||
f.write(" fsdp_state_dict_type: FULL_STATE_DICT\n")
|
||||
|
||||
|
||||
def run_accelerate_training(config_args, num_processes=4, temp_dir=None, distributed_type="MULTI_GPU"):
|
||||
"""
|
||||
Helper function to run training with accelerate launch.
|
||||
|
||||
`accelerate launch` is used as a plain launcher (no `--config_file`): it only sets the
|
||||
rendezvous env vars, and the layout — DDP by default, FSDP with `--parallelism.dp_shard` —
|
||||
comes from `config_args`.
|
||||
|
||||
Args:
|
||||
config_args: List of config arguments to pass to lerobot_train.py
|
||||
num_processes: Number of processes (GPUs) to use
|
||||
temp_dir: Temporary directory for outputs
|
||||
distributed_type: "MULTI_GPU" (DDP) or "FSDP" — selects the generated accelerate config.
|
||||
|
||||
Returns:
|
||||
subprocess.CompletedProcess result
|
||||
"""
|
||||
|
||||
config_path = Path(temp_dir) / "accelerate_config.yaml"
|
||||
|
||||
# Write YAML config
|
||||
with open(config_path, "w") as f:
|
||||
if distributed_type == "FSDP":
|
||||
_write_fsdp_config(f, num_processes)
|
||||
else:
|
||||
_write_multi_gpu_config(f, num_processes)
|
||||
|
||||
cmd = [
|
||||
"accelerate",
|
||||
"launch",
|
||||
f"--num_processes={num_processes}",
|
||||
"--config_file",
|
||||
str(config_path),
|
||||
"-m",
|
||||
"lerobot.scripts.lerobot_train",
|
||||
] + config_args
|
||||
@@ -127,7 +173,7 @@ class TestMultiGPUTraining:
|
||||
"--num_workers=0",
|
||||
]
|
||||
|
||||
result = run_accelerate_training(config_args, num_processes=4)
|
||||
result = run_accelerate_training(config_args, num_processes=4, temp_dir=temp_dir)
|
||||
|
||||
# Check that training completed successfully
|
||||
assert result.returncode == 0, (
|
||||
@@ -170,7 +216,7 @@ class TestMultiGPUTraining:
|
||||
"--num_workers=0",
|
||||
]
|
||||
|
||||
result = run_accelerate_training(config_args, num_processes=2)
|
||||
result = run_accelerate_training(config_args, num_processes=2, temp_dir=temp_dir)
|
||||
|
||||
assert result.returncode == 0, (
|
||||
f"Training failed:\nSTDOUT:\n{result.stdout}\n\nSTDERR:\n{result.stderr}"
|
||||
@@ -200,12 +246,11 @@ class TestMultiGPUTraining:
|
||||
|
||||
def test_fsdp_optimizer_save_and_resume(self):
|
||||
"""
|
||||
Test that FSDP saves the sharded optimizer state and can resume from it.
|
||||
Test that FSDP saves the (gathered) optimizer state and can resume from it.
|
||||
|
||||
Trains a few steps under FSDP2 (`--parallelism.dp_shard=2`), verifies the DCP optimizer
|
||||
shards are written next to the rest of the training state, then resumes from the
|
||||
checkpoint for more steps and checks it completes without shape/key errors in the
|
||||
resharding optimizer load path.
|
||||
Trains a few steps under FSDP, verifies the gathered optimizer state is written next to the
|
||||
rest of the training state, then resumes from the checkpoint for more steps and checks it
|
||||
completes without shape/key errors in the FSDP optimizer load path.
|
||||
"""
|
||||
# Pre-download dataset to avoid race conditions
|
||||
download_dataset("lerobot/pusht", episodes=[0])
|
||||
@@ -220,7 +265,6 @@ class TestMultiGPUTraining:
|
||||
"--policy.device=cuda",
|
||||
"--policy.push_to_hub=false",
|
||||
f"--output_dir={output_dir}",
|
||||
"--parallelism.dp_shard=2",
|
||||
"--batch_size=4",
|
||||
"--steps=10",
|
||||
"--env_eval_freq=-1",
|
||||
@@ -230,33 +274,34 @@ class TestMultiGPUTraining:
|
||||
"--num_workers=0",
|
||||
]
|
||||
|
||||
result = run_accelerate_training(config_args, num_processes=2)
|
||||
result = run_accelerate_training(
|
||||
config_args, num_processes=2, temp_dir=temp_dir, distributed_type="FSDP"
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"FSDP training failed:\nSTDOUT:\n{result.stdout}\n\nSTDERR:\n{result.stderr}"
|
||||
)
|
||||
|
||||
# Under sharding the optimizer state is written as DCP shards (proves the save
|
||||
# collective ran); the model artifact stays a gathered model.safetensors at the
|
||||
# default --checkpoint_format=safetensors.
|
||||
checkpoint_dir = output_dir / "checkpoints" / "last"
|
||||
training_state_dir = checkpoint_dir / "training_state"
|
||||
optimizer_shards = training_state_dir / "optimizer_0"
|
||||
assert optimizer_shards.is_dir(), f"FSDP optimizer shards not saved in {training_state_dir}"
|
||||
assert any(optimizer_shards.iterdir()), f"FSDP optimizer shard dir is empty: {optimizer_shards}"
|
||||
assert (checkpoint_dir / "pretrained_model" / "model.safetensors").exists(), (
|
||||
f"Gathered model weights not saved in {checkpoint_dir}"
|
||||
# The gathered optimizer state must be written under FSDP (proves the save collective ran),
|
||||
# in the same safetensors format as single-GPU training.
|
||||
training_state_dir = output_dir / "checkpoints" / "last" / "training_state"
|
||||
optimizer_state = training_state_dir / "optimizer_state.safetensors"
|
||||
optimizer_param_groups = training_state_dir / "optimizer_param_groups.json"
|
||||
assert optimizer_state.exists(), f"FSDP optimizer state not saved in {training_state_dir}"
|
||||
assert optimizer_param_groups.exists(), (
|
||||
f"FSDP optimizer param groups not saved in {training_state_dir}"
|
||||
)
|
||||
|
||||
# Resume from the checkpoint for more steps. A successful run proves the DCP optimizer
|
||||
# load accepts the saved state and reshards it without shape/key errors. The topology
|
||||
# is restored from train_config.json, so --parallelism.* is not repeated here.
|
||||
resume_config = checkpoint_dir / "pretrained_model" / "train_config.json"
|
||||
# Resume from the checkpoint for more steps. A successful run proves load_fsdp_optimizer
|
||||
# accepts the saved state and reshards it without shape/key errors.
|
||||
resume_config = output_dir / "checkpoints" / "last" / "pretrained_model" / "train_config.json"
|
||||
resume_args = [
|
||||
f"--config_path={resume_config}",
|
||||
"--resume=true",
|
||||
"--steps=20",
|
||||
]
|
||||
resume_result = run_accelerate_training(resume_args, num_processes=2)
|
||||
resume_result = run_accelerate_training(
|
||||
resume_args, num_processes=2, temp_dir=temp_dir, distributed_type="FSDP"
|
||||
)
|
||||
assert resume_result.returncode == 0, (
|
||||
f"FSDP resume failed:\nSTDOUT:\n{resume_result.stdout}\n\nSTDERR:\n{resume_result.stderr}"
|
||||
)
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
# Copyright 2026 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 doctest
|
||||
|
||||
from lerobot.utils.doctest_utils import LeRobotDocTestParser, preprocess_string
|
||||
|
||||
# An example with expected output, formatted the way ruff's `docstring-code-format` leaves it: no blank
|
||||
# line between the last output line and the closing fence. This is the exact shape that breaks stdlib.
|
||||
FORMATTED_EXAMPLE = """Summary.
|
||||
|
||||
Example:
|
||||
```python
|
||||
>>> 1 + 1
|
||||
2
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
def test_stdlib_parser_swallows_the_closing_fence():
|
||||
"""Guards the premise of the port: without the patch, the fence lands in the expected output.
|
||||
|
||||
Uses the base class rather than `doctest.DocTestParser`, which the root `conftest.py` has already
|
||||
replaced with ours by the time this runs.
|
||||
"""
|
||||
stdlib_parser = LeRobotDocTestParser.__bases__[0]()
|
||||
(example,) = (e for e in stdlib_parser.parse(FORMATTED_EXAMPLE) if isinstance(e, doctest.Example))
|
||||
assert "```" in example.want
|
||||
|
||||
|
||||
def test_parser_stops_at_the_closing_fence():
|
||||
"""The whole reason `LeRobotDocTestParser` exists: `want` must be the output and nothing else."""
|
||||
(example,) = (
|
||||
e for e in LeRobotDocTestParser().parse(FORMATTED_EXAMPLE) if isinstance(e, doctest.Example)
|
||||
)
|
||||
assert example.source == "1 + 1\n"
|
||||
assert example.want == "2\n"
|
||||
|
||||
|
||||
def test_example_with_output_passes_end_to_end():
|
||||
"""A formatted example with output should actually run green."""
|
||||
runner = doctest.DocTestRunner()
|
||||
test = LeRobotDocTestParser().get_doctest(FORMATTED_EXAMPLE, {}, "formatted", None, 0)
|
||||
results = runner.run(test, out=lambda _: None)
|
||||
assert results.failed == 0
|
||||
assert results.attempted == 1
|
||||
|
||||
|
||||
def test_noisy_calls_get_ignore_result():
|
||||
string = """
|
||||
```python
|
||||
>>> ds = load_dataset("lerobot/pusht")
|
||||
```
|
||||
"""
|
||||
assert "# doctest: +IGNORE_RESULT" in preprocess_string(string, False, False)
|
||||
|
||||
|
||||
def test_ignore_result_is_not_added_twice():
|
||||
string = """
|
||||
```python
|
||||
>>> ds = load_dataset("lerobot/pusht") # doctest: +IGNORE_RESULT
|
||||
```
|
||||
"""
|
||||
assert preprocess_string(string, False, False).count("# doctest: +IGNORE_RESULT") == 1
|
||||
|
||||
|
||||
def test_cuda_examples_are_dropped_when_requested():
|
||||
string = """
|
||||
```python
|
||||
>>> model.to("cuda")
|
||||
```
|
||||
"""
|
||||
assert preprocess_string(string, True, False) == ""
|
||||
assert preprocess_string(string, False, False) != ""
|
||||
|
||||
|
||||
def test_hardware_examples_are_dropped_when_requested():
|
||||
"""Serial ports, connect calls and Hub downloads all need real resources."""
|
||||
for source in [
|
||||
'>>> robot = SO101Follower(SO101FollowerConfig(port="/dev/ttyACM0"))',
|
||||
">>> robot.connect()",
|
||||
'>>> policy = ACTPolicy.from_pretrained("lerobot/act")',
|
||||
]:
|
||||
string = f"""
|
||||
```python
|
||||
{source}
|
||||
```
|
||||
"""
|
||||
assert preprocess_string(string, False, True) == "", source
|
||||
assert preprocess_string(string, False, False) != "", source
|
||||
|
||||
|
||||
def test_plain_examples_survive_both_skips():
|
||||
string = """
|
||||
```python
|
||||
>>> 1 + 1
|
||||
2
|
||||
```
|
||||
"""
|
||||
assert preprocess_string(string, True, True) == string
|
||||
@@ -1,153 +0,0 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
"""Check that every registered hardware config documents the fields users have to get right.
|
||||
|
||||
Modelled on `transformers/utils/check_config_docstrings.py`, which checks that every model config links a
|
||||
checkpoint. LeRobot's equivalent question is the one every new user hits: which port is the device on, and
|
||||
what happens on calibration. A config that leaves those undocumented sends people to the source.
|
||||
|
||||
Only fields the config actually declares are required — a config without a `port` is not asked to document
|
||||
one.
|
||||
|
||||
```bash
|
||||
python utils/check_config_docstrings.py
|
||||
```
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from check_docstrings import _re_args, _re_parse_arg, find_indent, iter_objects_to_check # noqa: E402
|
||||
|
||||
# Fields whose semantics are not obvious from the name and that a user must set correctly on first run.
|
||||
REQUIRED_FIELDS = ["port"]
|
||||
|
||||
# A config must say something about calibration if it participates in it at all.
|
||||
CALIBRATION_PATTERN = re.compile(r"calibrat", re.IGNORECASE)
|
||||
|
||||
MODULES_TO_CHECK = ["lerobot.robots"]
|
||||
|
||||
# Configs that document their fields with `#` comments above each field, which doc-builder cannot see.
|
||||
# Each entry is removed as that config's comments are converted to an `Args:` block.
|
||||
OBJECTS_TO_IGNORE: set[str] = {
|
||||
"BiOpenArmFollowerConfig",
|
||||
"BiRebotB601FollowerConfig",
|
||||
"BiSOFollowerConfig",
|
||||
"EarthRoverMiniPlusConfig",
|
||||
"HopeJrArmConfig",
|
||||
"HopeJrHandConfig",
|
||||
"KochFollowerConfig",
|
||||
"LeKiwiConfig",
|
||||
"OmxFollowerConfig",
|
||||
"OpenArmFollowerConfig",
|
||||
"Reachy2RobotConfig",
|
||||
"RebotB601FollowerRobotConfig",
|
||||
"SOFollowerRobotConfig",
|
||||
}
|
||||
|
||||
|
||||
def documented_args(obj: object) -> set[str]:
|
||||
"""Return the argument names documented in an object's `Args:` block.
|
||||
|
||||
Args:
|
||||
obj (`object`):
|
||||
The class to inspect.
|
||||
|
||||
Returns:
|
||||
`set[str]`: The documented argument names, empty if there is no `Args:` section.
|
||||
"""
|
||||
doc = getattr(obj, "__doc__", None)
|
||||
if not doc:
|
||||
return set()
|
||||
|
||||
lines = doc.split("\n")
|
||||
idx = 0
|
||||
while idx < len(lines) and _re_args.search(lines[idx]) is None:
|
||||
idx += 1
|
||||
if idx == len(lines):
|
||||
return set()
|
||||
|
||||
indent = find_indent(lines[idx])
|
||||
names = set()
|
||||
idx += 1
|
||||
while idx < len(lines) and (len(lines[idx].strip()) == 0 or find_indent(lines[idx]) > indent):
|
||||
if find_indent(lines[idx]) == indent + 4:
|
||||
match = _re_parse_arg.search(lines[idx])
|
||||
if match is not None:
|
||||
names.add(match.groups()[1])
|
||||
idx += 1
|
||||
return names
|
||||
|
||||
|
||||
def check_config_docstrings() -> list[str]:
|
||||
"""Check every registered config in `MODULES_TO_CHECK`.
|
||||
|
||||
Returns:
|
||||
`list[str]`: One message per config that is missing a required field or calibration semantics.
|
||||
"""
|
||||
from lerobot.robots import RobotConfig
|
||||
|
||||
failures = []
|
||||
for module_name in MODULES_TO_CHECK:
|
||||
for obj in iter_objects_to_check(module_name):
|
||||
if not inspect.isclass(obj) or not issubclass(obj, RobotConfig) or obj is RobotConfig:
|
||||
continue
|
||||
if inspect.isabstract(obj) or obj.__qualname__ in OBJECTS_TO_IGNORE:
|
||||
continue
|
||||
|
||||
try:
|
||||
fields = set(inspect.signature(obj).parameters)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
doc = getattr(obj, "__doc__", "") or ""
|
||||
documented = documented_args(obj)
|
||||
name = f"{obj.__module__}.{obj.__qualname__}"
|
||||
|
||||
for field in REQUIRED_FIELDS:
|
||||
if field in fields and field not in documented:
|
||||
failures.append(f"{name}: does not document `{field}`")
|
||||
|
||||
if "calibration_dir" in fields and CALIBRATION_PATTERN.search(doc) is None:
|
||||
failures.append(f"{name}: says nothing about calibration")
|
||||
|
||||
return failures
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the check.
|
||||
|
||||
Returns:
|
||||
`int`: `0` when every registered config is documented, `1` otherwise.
|
||||
"""
|
||||
failures = check_config_docstrings()
|
||||
if failures:
|
||||
print(
|
||||
"The following robot configs are missing documentation a user needs on first run. See "
|
||||
"docs/source/writing_docstrings.mdx:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for failure in failures:
|
||||
print(f"- {failure}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,566 +0,0 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
"""Check that documented arguments match the real signature.
|
||||
|
||||
Adapted from the core of `transformers/utils/check_docstrings.py`. The parts of that file bound to
|
||||
transformers internals — the `@auto_docstring` decorator system, modular-file propagation, `ModelArgs`,
|
||||
GitPython — are deliberately not ported.
|
||||
|
||||
What this enforces, for every public object in `MODULES_TO_CHECK`:
|
||||
|
||||
- every parameter in the signature has an `Args:` entry, in signature order;
|
||||
- no `Args:` entry names a parameter that does not exist;
|
||||
- the `*optional*, defaults to `X`` clause matches the real default.
|
||||
|
||||
That last one is why the clause is not decorative. See docs/source/writing_docstrings.mdx.
|
||||
|
||||
Check, as CI does:
|
||||
|
||||
```bash
|
||||
python utils/check_docstrings.py
|
||||
```
|
||||
|
||||
Rewrite the `Args:` blocks to match the signatures, inserting `<fill_docstring>` placeholders for
|
||||
parameters that are missing entirely:
|
||||
|
||||
```bash
|
||||
python utils/check_docstrings.py --fix_and_overwrite
|
||||
```
|
||||
|
||||
`MODULES_TO_CHECK` is the ratchet: add a module once its docstrings are converted.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import enum
|
||||
import importlib
|
||||
import inspect
|
||||
import operator as op
|
||||
import pkgutil
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
PATH_TO_REPO = Path(__file__).resolve().parent.parent
|
||||
PATH_TO_LEROBOT = PATH_TO_REPO / "src" / "lerobot"
|
||||
|
||||
# Modules whose public objects are checked. Add a module here once its docstrings follow the standard.
|
||||
MODULES_TO_CHECK = [
|
||||
"lerobot.robots",
|
||||
]
|
||||
|
||||
# Objects that do not yet follow the standard, so the check can be green from day one. Removing an entry
|
||||
# and running `--fix_and_overwrite` is how a module gets converted.
|
||||
#
|
||||
# Every entry below has a bare `Attributes:` section, which this checker reads as an argument section (the
|
||||
# same aliasing doc-builder does) and therefore compares against the signature. They are converted in the
|
||||
# docstring PR that follows this one, which empties this set.
|
||||
OBJECTS_TO_IGNORE: set[str] = {
|
||||
"ChannelFactoryInitialize",
|
||||
"EarthRoverMiniPlus",
|
||||
"EarthRoverMiniPlusConfig",
|
||||
"EEBoundsAndSafety",
|
||||
"EEReferenceAndDelta",
|
||||
"ForwardKinematicsJointsToEEAction",
|
||||
"ForwardKinematicsJointsToEEObservation",
|
||||
"GripperVelocityToJoint",
|
||||
"InverseKinematicsEEToJoints",
|
||||
"Robot",
|
||||
}
|
||||
|
||||
OPTIONAL_KEYWORD = "*optional*"
|
||||
|
||||
_re_args = re.compile(r"^\s*(Args?|Arguments?|Attributes?|Params?|Parameters?):\s*$")
|
||||
_re_parse_arg = re.compile(r"^(\s*)(\S+)\s+\((.+)\)(?:\:|$)")
|
||||
_re_parse_description = re.compile(r"\*optional\*, defaults to (.*)$")
|
||||
|
||||
MATH_OPERATORS = {
|
||||
ast.Add: op.add,
|
||||
ast.Sub: op.sub,
|
||||
ast.Mult: op.mul,
|
||||
ast.Div: op.truediv,
|
||||
ast.Pow: op.pow,
|
||||
ast.BitXor: op.xor,
|
||||
ast.USub: op.neg,
|
||||
}
|
||||
|
||||
|
||||
def find_indent(line: str) -> int:
|
||||
"""Return the number of spaces a line is indented by.
|
||||
|
||||
Args:
|
||||
line (`str`):
|
||||
The line to measure.
|
||||
|
||||
Returns:
|
||||
`int`: The indentation width.
|
||||
"""
|
||||
search = re.search(r"^(\s*)(?:\S|$)", line)
|
||||
return 0 if search is None else len(search.groups()[0])
|
||||
|
||||
|
||||
def is_dataclass_factory_default(default: Any) -> bool:
|
||||
"""Whether a signature default came from a dataclass `field(default_factory=...)`.
|
||||
|
||||
`inspect.signature` renders those as a `<factory>` sentinel, which must not be written into a
|
||||
docstring as a literal default.
|
||||
|
||||
Args:
|
||||
default (`Any`):
|
||||
The default value taken from the signature.
|
||||
|
||||
Returns:
|
||||
`bool`: `True` for the factory sentinel.
|
||||
"""
|
||||
return repr(default) == "<factory>"
|
||||
|
||||
|
||||
def stringify_default(default: Any) -> str:
|
||||
"""Render a default value the way a docstring should show it.
|
||||
|
||||
Args:
|
||||
default (`Any`):
|
||||
The default value to process.
|
||||
|
||||
Returns:
|
||||
`str`: Numbers are left bare, everything else is wrapped in backticks.
|
||||
"""
|
||||
if isinstance(default, bool):
|
||||
# Must precede the int check: a bool passes isinstance(x, int).
|
||||
return f"`{default}`"
|
||||
elif isinstance(default, enum.Enum):
|
||||
# Must also precede the int check: an IntEnum passes isinstance(x, int).
|
||||
return f"`{str(default)}`"
|
||||
elif isinstance(default, int):
|
||||
return str(default)
|
||||
elif isinstance(default, float):
|
||||
result = str(default)
|
||||
return str(round(default, 2)) if len(result) > 6 else result
|
||||
elif isinstance(default, str):
|
||||
return str(default) if default.isnumeric() else f'`"{default}"`'
|
||||
elif isinstance(default, type):
|
||||
return f"`{default.__name__}`"
|
||||
else:
|
||||
return f"`{default}`"
|
||||
|
||||
|
||||
def eval_node(node):
|
||||
"""Evaluate one node of a arithmetic-only AST.
|
||||
|
||||
Args:
|
||||
node (`ast.AST`):
|
||||
The node to evaluate.
|
||||
|
||||
Returns:
|
||||
`float | int | complex`: The node's value.
|
||||
|
||||
Raises:
|
||||
TypeError: If the node is not a number or a supported arithmetic operation.
|
||||
"""
|
||||
if isinstance(node, ast.Constant) and type(node.value) in (int, float, complex):
|
||||
return node.value
|
||||
elif isinstance(node, ast.BinOp):
|
||||
return MATH_OPERATORS[type(node.op)](eval_node(node.left), eval_node(node.right))
|
||||
elif isinstance(node, ast.UnaryOp):
|
||||
return MATH_OPERATORS[type(node.op)](eval_node(node.operand))
|
||||
else:
|
||||
raise TypeError(node)
|
||||
|
||||
|
||||
def eval_math_expression(expression: str) -> float | int | None:
|
||||
"""Safely evaluate an arithmetic expression found in a docstring.
|
||||
|
||||
Docstrings often document a default as an expression (`1 / 255` is the classic), which should be left
|
||||
alone rather than replaced by its computed value.
|
||||
|
||||
Args:
|
||||
expression (`str`):
|
||||
The expression to evaluate.
|
||||
|
||||
Returns:
|
||||
`float | int | None`: The value, or `None` if it is not a plain arithmetic expression.
|
||||
"""
|
||||
try:
|
||||
return eval_node(ast.parse(expression, mode="eval").body)
|
||||
except (TypeError, SyntaxError, KeyError, ZeroDivisionError):
|
||||
return None
|
||||
|
||||
|
||||
def replace_default_in_arg_description(description: str, default: Any) -> str:
|
||||
"""Rewrite the `*optional*, defaults to X` clause of one argument description.
|
||||
|
||||
Args:
|
||||
description (`str`):
|
||||
The argument description from the docstring, without the name.
|
||||
default (`Any`):
|
||||
The real default from the signature, or `inspect._empty` if the argument is required.
|
||||
|
||||
Returns:
|
||||
`str`: The description with its optional/default clause matching the signature.
|
||||
"""
|
||||
# Plenty of docstrings use `optional` or **optional** instead of *optional*.
|
||||
description = description.replace("`optional`", OPTIONAL_KEYWORD)
|
||||
description = description.replace("**optional**", OPTIONAL_KEYWORD)
|
||||
|
||||
if default is inspect._empty:
|
||||
# Required: the description must not claim otherwise.
|
||||
idx = description.find(OPTIONAL_KEYWORD)
|
||||
if idx != -1:
|
||||
description = description[:idx].rstrip().removesuffix(",").rstrip()
|
||||
elif default is None or is_dataclass_factory_default(default):
|
||||
# A `None` default is not spelled out, and a `default_factory` has no literal value to show.
|
||||
idx = description.find(OPTIONAL_KEYWORD)
|
||||
if idx == -1:
|
||||
description = f"{description}, {OPTIONAL_KEYWORD}"
|
||||
elif re.search(r"defaults to `?None`?", description) is not None:
|
||||
description = description[: idx + len(OPTIONAL_KEYWORD)]
|
||||
else:
|
||||
str_default = None
|
||||
documented_match = re.search("defaults to `?(.*?)(?:`|$)", description)
|
||||
if isinstance(default, (int, float)) and documented_match is not None:
|
||||
documented = documented_match.groups()[0]
|
||||
if default == eval_math_expression(documented):
|
||||
try:
|
||||
# Directly convertible means it was a plain literal.
|
||||
str_default = str(type(default)(documented))
|
||||
except (TypeError, ValueError):
|
||||
# Otherwise it was an expression; keep it as written.
|
||||
str_default = f"`{documented}`"
|
||||
|
||||
if str_default is None:
|
||||
str_default = stringify_default(default)
|
||||
|
||||
if OPTIONAL_KEYWORD not in description:
|
||||
description = f"{description}, {OPTIONAL_KEYWORD}, defaults to {str_default}"
|
||||
elif _re_parse_description.search(description) is None:
|
||||
idx = description.find(OPTIONAL_KEYWORD)
|
||||
description = f"{description[: idx + len(OPTIONAL_KEYWORD)]}, defaults to {str_default}"
|
||||
else:
|
||||
description = _re_parse_description.sub(f"*optional*, defaults to {str_default}", description)
|
||||
|
||||
return description
|
||||
|
||||
|
||||
def get_default_description(arg: inspect.Parameter) -> str:
|
||||
"""Build the parenthesised type-and-default part for an undocumented parameter.
|
||||
|
||||
Args:
|
||||
arg (`inspect.Parameter`):
|
||||
The parameter to describe.
|
||||
|
||||
Returns:
|
||||
`str`: Something like ``` `int`, *optional*, defaults to 3 ```.
|
||||
"""
|
||||
if arg.annotation is inspect._empty:
|
||||
arg_type = "<fill_type>"
|
||||
elif hasattr(arg.annotation, "__name__"):
|
||||
arg_type = arg.annotation.__name__
|
||||
else:
|
||||
arg_type = str(arg.annotation)
|
||||
|
||||
if arg.default is inspect._empty:
|
||||
return f"`{arg_type}`"
|
||||
elif arg.default is None or is_dataclass_factory_default(arg.default):
|
||||
return f"`{arg_type}`, {OPTIONAL_KEYWORD}"
|
||||
else:
|
||||
return f"`{arg_type}`, {OPTIONAL_KEYWORD}, defaults to {stringify_default(arg.default)}"
|
||||
|
||||
|
||||
def find_source_file(obj: Any) -> Path:
|
||||
"""Locate the file an object is defined in.
|
||||
|
||||
Args:
|
||||
obj (`Any`):
|
||||
The object to locate.
|
||||
|
||||
Returns:
|
||||
`Path`: The source file.
|
||||
"""
|
||||
obj_file = PATH_TO_LEROBOT
|
||||
for part in obj.__module__.split(".")[1:]:
|
||||
obj_file = obj_file / part
|
||||
return obj_file.with_suffix(".py")
|
||||
|
||||
|
||||
def match_docstring_with_signature(obj: Any) -> tuple[str, str] | None:
|
||||
"""Compare an object's documented arguments against its signature.
|
||||
|
||||
Dataclasses need no special handling: `inspect.signature` resolves the generated `__init__`, inherited
|
||||
fields included, which is exactly the set a reader sees on the rendered page.
|
||||
|
||||
Args:
|
||||
obj (`Any`):
|
||||
The class or function to check.
|
||||
|
||||
Returns:
|
||||
`tuple[str, str] | None`: The current `Args:` block and the one matching the signature, or `None`
|
||||
when there is nothing to compare — no docstring, no documented arguments, or an unsupported
|
||||
signature.
|
||||
"""
|
||||
if not getattr(obj, "__doc__", None):
|
||||
return None
|
||||
|
||||
try:
|
||||
source, _ = inspect.getsourcelines(obj)
|
||||
except (OSError, TypeError):
|
||||
source = []
|
||||
|
||||
idx = 0
|
||||
while idx < len(source) and '"""' not in source[idx]:
|
||||
idx += 1
|
||||
|
||||
ignore_order = False
|
||||
if idx < len(source) and idx > 0:
|
||||
line_before_docstring = source[idx - 1]
|
||||
if re.search(r"^\s*#\s*no-format\s*$", line_before_docstring):
|
||||
return None
|
||||
elif re.search(r"^\s*#\s*ignore-order\s*$", line_before_docstring):
|
||||
ignore_order = True
|
||||
|
||||
try:
|
||||
signature = inspect.signature(obj).parameters
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
obj_doc_lines = obj.__doc__.split("\n")
|
||||
idx = 0
|
||||
while idx < len(obj_doc_lines) and _re_args.search(obj_doc_lines[idx]) is None:
|
||||
idx += 1
|
||||
if idx == len(obj_doc_lines):
|
||||
# No arguments documented; coverage is interrogate's job, not this check's.
|
||||
return None
|
||||
|
||||
if "kwargs" in signature and signature["kwargs"].annotation != inspect._empty:
|
||||
# Typed **kwargs are not introspectable in a useful way here.
|
||||
return None
|
||||
|
||||
indent = find_indent(obj_doc_lines[idx])
|
||||
arguments: dict[str, Any] = {}
|
||||
current_arg = None
|
||||
idx += 1
|
||||
start_idx = idx
|
||||
# Consume until a non-empty line returns to the section's own indent, or the docstring ends.
|
||||
while idx < len(obj_doc_lines) and (
|
||||
len(obj_doc_lines[idx].strip()) == 0 or find_indent(obj_doc_lines[idx]) > indent
|
||||
):
|
||||
if find_indent(obj_doc_lines[idx]) == indent + 4:
|
||||
re_search_arg = _re_parse_arg.search(obj_doc_lines[idx])
|
||||
if re_search_arg is not None:
|
||||
_, name, description = re_search_arg.groups()
|
||||
current_arg = name
|
||||
if name in signature:
|
||||
default = signature[name].default
|
||||
if signature[name].kind is inspect._ParameterKind.VAR_KEYWORD:
|
||||
default = None
|
||||
new_description = replace_default_in_arg_description(description, default)
|
||||
else:
|
||||
new_description = description
|
||||
arguments[current_arg] = [
|
||||
_re_parse_arg.sub(rf"\1\2 ({new_description}):", obj_doc_lines[idx])
|
||||
]
|
||||
elif current_arg is not None:
|
||||
arguments[current_arg].append(obj_doc_lines[idx])
|
||||
idx += 1
|
||||
|
||||
# Walk back over the trailing blank lines we consumed.
|
||||
idx -= 1
|
||||
if current_arg:
|
||||
while len(obj_doc_lines[idx].strip()) == 0:
|
||||
arguments[current_arg] = arguments[current_arg][:-1]
|
||||
idx -= 1
|
||||
idx += 1
|
||||
|
||||
old_doc_arg = "\n".join(obj_doc_lines[start_idx:idx])
|
||||
|
||||
old_arguments = list(arguments.keys())
|
||||
arguments = {name: "\n".join(doc) for name, doc in arguments.items()}
|
||||
for name in set(signature.keys()) - set(arguments.keys()):
|
||||
arg = signature[name]
|
||||
# Private parameters and *args/**kwargs are only documented if the author chose to.
|
||||
if name.startswith("_") or arg.kind in [
|
||||
inspect._ParameterKind.VAR_KEYWORD,
|
||||
inspect._ParameterKind.VAR_POSITIONAL,
|
||||
]:
|
||||
arguments[name] = ""
|
||||
else:
|
||||
arguments[name] = (
|
||||
" " * (indent + 4) + f"{name} ({get_default_description(arg)}): <fill_docstring>"
|
||||
)
|
||||
|
||||
if ignore_order:
|
||||
new_param_docs = [arguments[name] for name in old_arguments if name in signature]
|
||||
missing = set(signature.keys()) - set(old_arguments)
|
||||
new_param_docs.extend([arguments[name] for name in missing if len(arguments[name]) > 0])
|
||||
else:
|
||||
new_param_docs = [arguments[name] for name in signature if len(arguments[name]) > 0]
|
||||
|
||||
return old_doc_arg, "\n".join(new_param_docs)
|
||||
|
||||
|
||||
def fix_docstring(obj: Any, old_doc_args: str, new_doc_args: str) -> None:
|
||||
"""Rewrite an object's `Args:` block in its source file.
|
||||
|
||||
Args:
|
||||
obj (`Any`):
|
||||
The object whose docstring is being fixed.
|
||||
old_doc_args (`str`):
|
||||
The current `Args:` block, as returned by [`match_docstring_with_signature`].
|
||||
new_doc_args (`str`):
|
||||
The replacement block, as returned by [`match_docstring_with_signature`].
|
||||
|
||||
Raises:
|
||||
ValueError: If the block found in the source does not match the one parsed from `__doc__`, which
|
||||
means the boundaries were identified wrongly and rewriting would corrupt the file.
|
||||
"""
|
||||
source, line_number = inspect.getsourcelines(obj)
|
||||
|
||||
idx = 0
|
||||
while idx < len(source) and _re_args.search(source[idx]) is None:
|
||||
idx += 1
|
||||
if idx == len(source):
|
||||
# Inherited docstring: do not rewrite it on the child.
|
||||
return
|
||||
|
||||
indent = find_indent(source[idx])
|
||||
idx += 1
|
||||
start_idx = idx
|
||||
while idx < len(source) and (len(source[idx].strip()) == 0 or find_indent(source[idx]) > indent):
|
||||
idx += 1
|
||||
idx -= 1
|
||||
while len(source[idx].strip()) == 0:
|
||||
idx -= 1
|
||||
idx += 1
|
||||
|
||||
# `old_doc_args` comes from `__doc__`, whose indentation differs from the raw source lines.
|
||||
source_args_as_str = "".join(source[start_idx:idx])
|
||||
if inspect.cleandoc(source_args_as_str) != inspect.cleandoc(old_doc_args):
|
||||
raise ValueError(
|
||||
f"Cannot fix the docstring of {obj.__name__} in {find_source_file(obj)}: the argument section "
|
||||
f"in the source does not match the one parsed from __doc__, so the block boundaries are "
|
||||
f"wrong and rewriting it would corrupt the file.\n\n"
|
||||
f"Parsed:\n{old_doc_args!r}\n\nFound in source:\n{source_args_as_str.rstrip()!r}\n"
|
||||
)
|
||||
|
||||
obj_file = find_source_file(obj)
|
||||
lines = obj_file.read_text(encoding="utf-8").split("\n")
|
||||
# `new_doc_args` is built from `__doc__`, and Python keeps every line after the first at its exact
|
||||
# source indentation, so the block is already correctly indented for the file. transformers re-indents
|
||||
# here because its docstrings are often assembled by decorators and no longer match the source.
|
||||
lines = lines[: line_number + start_idx - 1] + [new_doc_args] + lines[line_number + idx - 1 :]
|
||||
|
||||
print(f"Fixing the docstring of {obj.__name__} in {obj_file}.")
|
||||
obj_file.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def iter_objects_to_check(module_name: str):
|
||||
"""Yield the public classes and functions defined in a package.
|
||||
|
||||
Args:
|
||||
module_name (`str`):
|
||||
An importable package name, e.g. `"lerobot.robots"`.
|
||||
|
||||
Yields:
|
||||
`Any`: Each public class or function whose `__module__` is inside the package, deduplicated so
|
||||
that aliases (`SO101Follower = SOFollower`) are visited once.
|
||||
"""
|
||||
package = importlib.import_module(module_name)
|
||||
module_names = [module_name]
|
||||
if hasattr(package, "__path__"):
|
||||
module_names += [
|
||||
name for _, name, _ in pkgutil.walk_packages(package.__path__, prefix=f"{module_name}.")
|
||||
]
|
||||
|
||||
seen = set()
|
||||
for name in module_names:
|
||||
try:
|
||||
module = importlib.import_module(name)
|
||||
except Exception as error: # An optional extra is missing; not this check's problem.
|
||||
print(f"Skipping {name}: {type(error).__name__}: {error}", file=sys.stderr)
|
||||
continue
|
||||
for attr_name, obj in vars(module).items():
|
||||
if attr_name.startswith("_") or not (inspect.isclass(obj) or inspect.isfunction(obj)):
|
||||
continue
|
||||
if not getattr(obj, "__module__", "").startswith(module_name):
|
||||
continue
|
||||
key = f"{obj.__module__}.{obj.__qualname__}"
|
||||
if key in seen or obj.__qualname__ in OBJECTS_TO_IGNORE or key in OBJECTS_TO_IGNORE:
|
||||
continue
|
||||
seen.add(key)
|
||||
yield obj
|
||||
|
||||
|
||||
def check_docstrings(overwrite: bool = False) -> list[str]:
|
||||
"""Check every object in `MODULES_TO_CHECK`.
|
||||
|
||||
Args:
|
||||
overwrite (`bool`, *optional*, defaults to `False`):
|
||||
Whether to rewrite mismatched `Args:` blocks in place.
|
||||
|
||||
Returns:
|
||||
`list[str]`: The names of objects whose documented arguments do not match their signature. Empty
|
||||
when everything is consistent.
|
||||
"""
|
||||
failures = []
|
||||
hard_failures = []
|
||||
for module_name in MODULES_TO_CHECK:
|
||||
for obj in iter_objects_to_check(module_name):
|
||||
try:
|
||||
result = match_docstring_with_signature(obj)
|
||||
except Exception as error:
|
||||
hard_failures.append(f"{obj.__qualname__}: {type(error).__name__}: {error}")
|
||||
continue
|
||||
if result is None:
|
||||
continue
|
||||
old_doc, new_doc = result
|
||||
if old_doc == new_doc:
|
||||
continue
|
||||
if overwrite:
|
||||
fix_docstring(obj, old_doc, new_doc)
|
||||
else:
|
||||
failures.append(f"{obj.__module__}.{obj.__qualname__}")
|
||||
|
||||
if hard_failures:
|
||||
print("The following objects could not be processed:", file=sys.stderr)
|
||||
for failure in hard_failures:
|
||||
print(f"- {failure}", file=sys.stderr)
|
||||
return failures
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the check.
|
||||
|
||||
Returns:
|
||||
`int`: `0` when every documented argument matches its signature, `1` otherwise.
|
||||
"""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--fix_and_overwrite", action="store_true", help="Whether to fix inconsistencies.")
|
||||
args = parser.parse_args()
|
||||
|
||||
failures = check_docstrings(overwrite=args.fix_and_overwrite)
|
||||
if failures:
|
||||
print(
|
||||
"The docstrings of the following objects do not match their signature. Run "
|
||||
"`make fix-docstrings` to rewrite them, then fill in any `<fill_docstring>` placeholders:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for failure in failures:
|
||||
print(f"- {failure}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,109 +0,0 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
"""Keep the doctest list honest: every path exists, and the file stays sorted.
|
||||
|
||||
Adapted from `transformers/utils/check_doctest_list.py`. It is agnostic to whether the list is an allowlist
|
||||
(what we have now) or a denylist (where transformers ended up), so it survives that inversion unchanged.
|
||||
|
||||
Check, as CI does:
|
||||
|
||||
```bash
|
||||
python utils/check_doctest_list.py
|
||||
```
|
||||
|
||||
Sort in place:
|
||||
|
||||
```bash
|
||||
python utils/check_doctest_list.py --fix_and_overwrite
|
||||
```
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_PATH = Path(__file__).resolve().parent.parent
|
||||
DOCTEST_FILE_PATHS = ["documentation_tests.txt"]
|
||||
|
||||
|
||||
def split_header(lines: list[str]) -> tuple[list[str], list[str]]:
|
||||
"""Split a list file into its leading comment header and its path entries.
|
||||
|
||||
Args:
|
||||
lines (`list[str]`):
|
||||
The file's lines, without trailing newlines.
|
||||
|
||||
Returns:
|
||||
`tuple[list[str], list[str]]`: The leading comment/blank lines, and the remaining lines.
|
||||
"""
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() and not line.lstrip().startswith("#"):
|
||||
return lines[:i], lines[i:]
|
||||
return lines, []
|
||||
|
||||
|
||||
def clean_doctest_list(doctest_file: Path, overwrite: bool = False) -> None:
|
||||
"""Check, and optionally fix, one doctest list file.
|
||||
|
||||
Args:
|
||||
doctest_file (`Path`):
|
||||
The list file to check or clean.
|
||||
overwrite (`bool`, *optional*, defaults to `False`):
|
||||
Whether to fix problems in place. When `False`, raises instead.
|
||||
|
||||
Raises:
|
||||
ValueError: If the file lists a path that does not exist, or is not alphabetically sorted and
|
||||
`overwrite` is `False`.
|
||||
"""
|
||||
lines = doctest_file.read_text(encoding="utf-8").splitlines()
|
||||
header, entries = split_header(lines)
|
||||
paths = [line.strip().split(" ")[0] for line in entries if line.strip()]
|
||||
|
||||
non_existent = [p for p in paths if not (REPO_PATH / p).exists()]
|
||||
if non_existent:
|
||||
listed = "\n".join(f"- {p}" for p in non_existent)
|
||||
raise ValueError(f"`{doctest_file.name}` contains non-existent paths:\n{listed}")
|
||||
|
||||
if paths != sorted(paths):
|
||||
if not overwrite:
|
||||
raise ValueError(
|
||||
f"Files in `{doctest_file.name}` are not in alphabetical order, run "
|
||||
"`make fix-docstrings` to fix this automatically."
|
||||
)
|
||||
doctest_file.write_text("\n".join(header + sorted(paths)) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the check over every doctest list file.
|
||||
|
||||
Returns:
|
||||
`int`: A process exit code — `0` when every file is clean, `1` otherwise.
|
||||
"""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--fix_and_overwrite", action="store_true", help="Whether to fix inconsistencies.")
|
||||
args = parser.parse_args()
|
||||
|
||||
failed = False
|
||||
for name in DOCTEST_FILE_PATHS:
|
||||
try:
|
||||
clean_doctest_list(REPO_PATH / "utils" / name, args.fix_and_overwrite)
|
||||
except ValueError as error:
|
||||
print(error, file=sys.stderr)
|
||||
failed = True
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,12 +0,0 @@
|
||||
# Files whose docstring examples are executed by `make doctest`.
|
||||
#
|
||||
# This is an ALLOWLIST: only the paths below are collected. transformers started the same way and has since
|
||||
# inverted to a denylist (`utils/not_doctested.txt`), which is the better end state — it makes a new file
|
||||
# tested by default. LeRobot cannot start there: at the time of writing, public docstring coverage is under
|
||||
# 50% and only a handful of files carry any example at all, so a denylist would need hundreds of entries on
|
||||
# day one and would say nothing about what is actually verified.
|
||||
#
|
||||
# Invert once coverage is high enough that the exclusions are the short list.
|
||||
# `utils/check_doctest_list.py` does not care which way round it is.
|
||||
#
|
||||
# Keep alphabetically sorted: `make check-doctest-list` enforces it, `make fix-docstrings` sorts it.
|
||||
Reference in New Issue
Block a user