mirror of
https://github.com/huggingface/lerobot.git
synced 2026-08-08 17:39:44 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22bd7a2f48 | |||
| 6c73c413eb | |||
| 3aabd135d3 | |||
| 2c1adc378e |
@@ -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@6108e850ae1cf2f71bb0815a600bcd50c39abfa7 # main
|
||||
uses: huggingface/doc-builder/.github/workflows/upload_pr_documentation.yml@931031bf2b54aabb134ceb54980a6a2860a00f11 # main
|
||||
with:
|
||||
package_name: lerobot
|
||||
secrets:
|
||||
|
||||
@@ -24,19 +24,24 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
# Triggers the workflow on push events to main for the docs folder
|
||||
# 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.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "src/**"
|
||||
|
||||
# Triggers the workflow on pull request events targeting main for the docs folder
|
||||
# Same for pull requests, so a docstring change gets a preview build and a broken `[[autodoc]]` path
|
||||
# fails the PR rather than main.
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "src/**"
|
||||
|
||||
release:
|
||||
types: [published]
|
||||
@@ -55,16 +60,29 @@ jobs:
|
||||
github.repository == 'huggingface/lerobot'
|
||||
permissions:
|
||||
contents: read
|
||||
uses: huggingface/doc-builder/.github/workflows/build_main_documentation.yml@6108e850ae1cf2f71bb0815a600bcd50c39abfa7 # main
|
||||
uses: huggingface/doc-builder/.github/workflows/build_main_documentation.yml@931031bf2b54aabb134ceb54980a6a2860a00f11 # 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 }}
|
||||
@@ -78,9 +96,12 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
uses: huggingface/doc-builder/.github/workflows/build_pr_documentation.yml@6108e850ae1cf2f71bb0815a600bcd50c39abfa7 # main
|
||||
uses: huggingface/doc-builder/.github/workflows/build_pr_documentation.yml@931031bf2b54aabb134ceb54980a6a2860a00f11 # main
|
||||
with:
|
||||
commit_sha: ${{ github.event.pull_request.head.sha }}
|
||||
pr_number: ${{ github.event.number }}
|
||||
package: lerobot
|
||||
additional_args: --not_python_module
|
||||
# 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]"
|
||||
|
||||
@@ -56,3 +56,41 @@ 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
|
||||
|
||||
+11
-2
@@ -67,7 +67,11 @@ 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).
|
||||
exclude: ^src/lerobot/templates/.*\.md$
|
||||
#
|
||||
# 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)$
|
||||
|
||||
##### Security #####
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
@@ -104,8 +108,13 @@ 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: ["-vv", "--config=pyproject.toml"]
|
||||
# args: ["--config=pyproject.toml"]
|
||||
# pass_filenames: false
|
||||
|
||||
@@ -50,6 +50,10 @@ 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**:
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
# Interactive Rollout — Design Notes
|
||||
|
||||
Branch: `feat/add_interactive_rollout` · Status: Phases 1–2 committed; Round 2
|
||||
(programmatic API, sentry support, muting v2, stdin move) implemented and tested,
|
||||
uncommitted.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
`lerobot-rollout` runs inference on a real robot: it connects hardware, loads the policy,
|
||||
builds the processor pipelines, optionally records a dataset, and spins the control loop.
|
||||
Today that is a **one-shot, fire-and-forget** program. You pass `--task="pick up the cube"`
|
||||
on the command line, the robot starts moving immediately, and the only interaction left is
|
||||
Ctrl-C. If you want a different instruction, you kill the process and pay the full startup
|
||||
cost again — reconnecting motors, re-homing, re-loading a multi-GB VLA onto the GPU.
|
||||
|
||||
Since LeRobot gained subtask annotation and language conditioning, that model is the
|
||||
bottleneck. The **north star** is a chat-style CLI over stdin, where the operator issues
|
||||
commands *concurrently with the robot moving*:
|
||||
|
||||
```
|
||||
/start begin (or resume) the policy control loop
|
||||
/subtask Grab the red cube re-instruct the policy on the fly
|
||||
/ask what's the capital of France? query an LLM while the robot keeps moving
|
||||
/reset stop movement, return home, clear the subtask —
|
||||
but keep hardware and policy warm
|
||||
/stop graceful shutdown
|
||||
```
|
||||
|
||||
The unifying idea: **the expensive things (hardware, policy weights, processors) stay warm
|
||||
across commands.** Only the cheap things — the instruction, the control loop — start and
|
||||
stop. That turns a rollout from a batch job into a session you can steer.
|
||||
|
||||
## 2. Objective (scoped)
|
||||
|
||||
Phased, so each phase lands as a reviewable unit:
|
||||
|
||||
| Phase | Scope | Status |
|
||||
|---|---|---|
|
||||
| **1** | `--interactive` flag, non-blocking stdin listener, command parser, `/start` `/reset` `/stop` `/help` | ✅ done |
|
||||
| **1.5** | Mute system logs so they stop fighting the prompt for the terminal | ✅ done |
|
||||
| **2** | `/subtask <text>` — change the policy's instruction mid-run | ✅ done |
|
||||
| **2.5** | Round 2: `RolloutController` public API, sentry recording support, muting v2 (errors surface), stdin listener → `lerobot/utils` | ✅ done (see §5) |
|
||||
| **3** | `/ask` + hierarchical task-vs-subtask semantics (LLM in the loop) | not started |
|
||||
|
||||
An explicit constraint through Phases 1–2: **do not couple this to the language runtime yet.**
|
||||
Build the mechanism; keep the door open.
|
||||
|
||||
## 3. Inspiration — three reference PRs
|
||||
|
||||
We read all three and deliberately implemented none of them verbatim.
|
||||
|
||||
**PR #4108 — online subtask switching.** Introduces a `PromptBroker` + `PromptListenerBase`
|
||||
+ `StdinPromptListener`, a `RuntimeContext.prompt_broker` field, `register_on_change`
|
||||
callbacks, an `--online_task_switching_flush` config flag, and `flush_action_queue()` /
|
||||
`_apply_pending_flush()` on `PreTrainedPolicy` — **with edits to 14 policy files** to call
|
||||
the flush at the top of `select_action`. Its architecture is designed for pluggable input
|
||||
sources (network, voice), which is the right long-term shape but more machinery than we
|
||||
need. *What we took:* the core insight that a mid-run instruction change must invalidate
|
||||
actions precomputed under the old instruction, and that the flush must happen on a thread
|
||||
that is safe to touch policy state from.
|
||||
|
||||
**PR #4183 — experimental full-UX draft.** Achieves the whole north-star vision, but does
|
||||
so by adding a `lerobot.runtime` / `language_runtime.py` that **duplicates** `BaseStrategy`,
|
||||
`send_next_action`, and the rollout control loop. *What we took:* the UX target and the
|
||||
command vocabulary. *What we rejected:* the parallel runtime — a second control loop is a
|
||||
second thing to keep correct, and everything it does is already in `rollout/strategies/`.
|
||||
|
||||
**PR #4234 — policy-side edits enabling #4183's runtime.** Read for context on where the
|
||||
language plumbing lands inside a policy. Relevant to Phase 3, not to what we built.
|
||||
|
||||
## 4. What we built, and why
|
||||
|
||||
Three commits on the branch:
|
||||
|
||||
```
|
||||
072c697c0 feat(rollout): interactive v1
|
||||
d3ee0b820 feat(rollout): mute logs in interactive mode
|
||||
39c4e746f feat(rollout): add subtask command
|
||||
```
|
||||
|
||||
Cumulative footprint — one new module, one new test file, small surgical edits elsewhere:
|
||||
|
||||
```
|
||||
src/lerobot/rollout/interactive.py | 580 +++++ (new)
|
||||
tests/test_interactive_rollout.py | 788 +++++ (new)
|
||||
docs/source/inference.mdx | 87 +++
|
||||
src/lerobot/rollout/inference/base.py | 66 +++
|
||||
src/lerobot/rollout/inference/rtc.py | 61 +-
|
||||
src/lerobot/rollout/inference/sync.py | 21 +-
|
||||
src/lerobot/scripts/lerobot_rollout.py | 32 +-
|
||||
src/lerobot/policies/pretrained.py | 24 +
|
||||
src/lerobot/rollout/strategies/core.py | 21 +-
|
||||
src/lerobot/rollout/configs.py | 18 +
|
||||
src/lerobot/rollout/__init__.py | 16 +-
|
||||
src/lerobot/rollout/strategies/episodic.py | 4 +-
|
||||
```
|
||||
|
||||
The ratio matters: **~1400 of ~1680 added lines are the new module and its tests.** The
|
||||
existing rollout architecture was reused, not reshaped.
|
||||
|
||||
### 4.1 Segments over a linked event — the load-bearing idea
|
||||
|
||||
Every rollout strategy's control loop already polls `ctx.runtime.shutdown_event.is_set()`
|
||||
to know when to stop. So instead of teaching strategies about interactivity, we **swap in a
|
||||
smarter event**:
|
||||
|
||||
```python
|
||||
class LinkedEvent(Event):
|
||||
"""is_set() reflects the local flag OR a parent event."""
|
||||
def is_set(self) -> bool:
|
||||
return super().is_set() or self.parent.is_set()
|
||||
```
|
||||
|
||||
`lerobot-rollout` wraps the `ProcessSignalHandler`'s shutdown event in a `LinkedEvent` when
|
||||
`--interactive=true`. The session sets the **local** flag to end a run *segment*; SIGINT /
|
||||
SIGTERM still arrive through the **parent**, so Ctrl-C behaves exactly as before.
|
||||
|
||||
`InteractiveSession.run()` then drives `strategy.run(ctx)` in restartable segments:
|
||||
|
||||
```
|
||||
setup(ctx) → [idle] → /start → run(ctx) → /reset → [idle] → /start → run(ctx) → /stop → teardown(ctx)
|
||||
↑ hardware + policy stay warm throughout
|
||||
```
|
||||
|
||||
**Zero strategy code changed** to support this. The only additions to `strategies/core.py`
|
||||
were `reset_control_state()` (engine + interpolator + cached-observation reset, factored
|
||||
out of `_init_engine` so a segment can restart cleanly) and making
|
||||
`_return_to_initial_position` public.
|
||||
|
||||
### 4.2 Threading model
|
||||
|
||||
```
|
||||
listener thread ──publishes flags / strings──▶ main thread
|
||||
(stdin reader) never touches hardware (session loop → strategy.run → control loop)
|
||||
never mutates policy state
|
||||
```
|
||||
|
||||
The listener only ever writes `threading.Event` flags and a lock-guarded string. Everything
|
||||
that touches hardware or policy state happens on the thread that already owns it. This
|
||||
mirrors the existing DAgger events pattern rather than inventing a new concurrency idiom.
|
||||
|
||||
### 4.3 stdin must be read with `os.read`, not `readline`
|
||||
|
||||
Non-obvious and load-bearing. The first implementation used `select()` + `stream.readline()`
|
||||
and **two tests failed**: a buffered file object slurps *several* lines off the file
|
||||
descriptor in one syscall, after which `select` reports the drained fd as not-ready and the
|
||||
buffered lines are never delivered. Pasted or piped command batches got stuck. The reader
|
||||
now does `select()` + `os.read(fd, 4096)` + manual `\n` splitting, with a
|
||||
blocking-`readline` fallback for streams without a `fileno()` (non-POSIX, test doubles).
|
||||
|
||||
Also: unlike `TerminalKeyListener`, this reader leaves the terminal in **canonical mode** —
|
||||
the operator is typing chat commands, not pressing hotkeys.
|
||||
|
||||
### 4.4 EOF means stop
|
||||
|
||||
A closed stdin means there is no way left to command the robot, so EOF (Ctrl-D, or an
|
||||
exhausted piped script) stops the session. An unexpected read error is treated the same way,
|
||||
for the same reason. Consequence, documented: piped scripts must hold stdin open —
|
||||
|
||||
```bash
|
||||
(printf '/start\n'; sleep 60; printf '/stop\n') | lerobot-rollout ... --interactive=true
|
||||
```
|
||||
|
||||
### 4.5 Commands are last-write-wins
|
||||
|
||||
`/reset` and `/stop` cancel a still-pending `/start`, so the robot never starts moving after
|
||||
the operator's most recent command said not to. Handlers set their intent flag *first* and
|
||||
the segment-stop event *second*; `_run_segment` clears the segment-stop flag *before*
|
||||
re-checking the intent flags. A `/reset` racing a `/start` is therefore either seen before
|
||||
the segment begins or ends it on its first tick.
|
||||
|
||||
### 4.6 Base strategy only (enforced by config validation)
|
||||
|
||||
`--interactive=true` with a recording strategy raises a `ValueError`. Two reasons: recording
|
||||
strategies finalize their dataset inside `run()` (so `run()` is not restartable), and their
|
||||
keyboard listeners contend with the command reader for the same TTY. This is a deliberate,
|
||||
documented limitation — not an oversight.
|
||||
|
||||
### 4.7 Log muting (Phase 1.5)
|
||||
|
||||
Policy, robot and control-loop logs at every level interleave with the chat prompt and
|
||||
destroy the typing UX. Simplest workable answer, per explicit request: **mute console output
|
||||
for the duration of the session.**
|
||||
|
||||
- Every logger's console `StreamHandler` is raised above `CRITICAL` — **not just root**,
|
||||
because `transformers` and `datasets` attach their own stderr handlers with
|
||||
`propagate=False`.
|
||||
- `warnings.simplefilter("ignore")`, with `warnings.filters` saved and restored.
|
||||
- **File handlers are untouched** — anyone wanting a persistent log can attach one.
|
||||
- Restored in `run()`'s `finally`, *before* the closing `log_say`, so teardown logs are visible.
|
||||
|
||||
The obvious hazard: muting hides fatal errors. So `InferenceEngine` gained a
|
||||
`failure_traceback` property, RTC captures its traceback in the fatal handler, and the
|
||||
session prints it on failure. **Do not remove that when touching the failure path.**
|
||||
|
||||
"See both logs and prompt" — a pinned input line, `prompt_toolkit`-style — was deliberately
|
||||
deferred: it needs a new dependency and a real TUI layer.
|
||||
|
||||
### 4.8 `/subtask` — the engine *is* the broker
|
||||
|
||||
The pivotal call on Phase 2: **skip PR #4108's `PromptBroker`.** After Phase 1, the session
|
||||
already owns the stdin thread and the parser, so a broker + listener base + on-change
|
||||
callbacks + a new `RuntimeContext` field would be duplicate machinery — and callbacks firing
|
||||
on the listener thread are exactly the cross-thread hazard we designed against.
|
||||
|
||||
Instead, `InferenceEngine` (the ABC every backend already implements) became the thread-safe
|
||||
task holder:
|
||||
|
||||
```python
|
||||
@property
|
||||
def task(self) -> str: ... # lock-guarded read
|
||||
|
||||
def set_task(self, task) -> bool: # callable from ANY thread; True if it changed
|
||||
...
|
||||
|
||||
def _take_task(self) -> tuple[str, bool]: # consumed on the INFERENCE thread;
|
||||
... # returns (task, changed) and clears the edge
|
||||
```
|
||||
|
||||
`/subtask` is then three lines: read `engine.task`, call `engine.set_task(text)`, print the
|
||||
transition. No new module, no new context field, no callbacks.
|
||||
|
||||
**The flush problem, and why it got small.** When the instruction changes, a chunking policy
|
||||
is still serving actions computed under the old one — up to `chunk_size` ticks of stale
|
||||
behavior. PR #4108 solved this by adding `flush_action_queue()` / `_apply_pending_flush()`
|
||||
to `PreTrainedPolicy` **and editing 14 policy files**, because its flush request arrived from
|
||||
a foreign thread and had to be deferred to a safe point inside `select_action`.
|
||||
|
||||
Ours already runs *on* the thread that calls `select_action`. So: one concrete method on
|
||||
`PreTrainedPolicy` and **zero per-policy edits**.
|
||||
|
||||
```python
|
||||
def drop_queued_actions(self) -> None:
|
||||
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()
|
||||
```
|
||||
|
||||
Two `getattr`s cover the repo's two queue idioms across all ~18 policies
|
||||
(`_queues[ACTION]`: diffusion, smolvla, tdmpc, vqbet, wall_x, xvla, multi_task_dit, vla_jepa;
|
||||
`_action_queue`: act, pi0, pi05, pi0_fast, eo1, evo1, groot, molmoact2, fastwam, lingbot_va).
|
||||
Policies with no queue inherit a no-op.
|
||||
|
||||
**Why not `policy.reset()`?** That was the first implementation, and review caught it as too
|
||||
blunt. For Diffusion it wipes the observation history, so the next chunk is planned from a
|
||||
history of the current frame repeated — a visible discontinuity mid-motion. And ACT /
|
||||
Diffusion / VQBeT / TDMPC don't read `task` at all, so they'd pay that jerk for nothing.
|
||||
`drop_queued_actions` keeps episode state and drops only what is actually stale.
|
||||
|
||||
**RTC deliberately does *not* flush.** Clearing its queue would leave the robot with no
|
||||
commands for a full inference latency (~1 s on a VLA). Instead the next chunk is generated
|
||||
under the new instruction and merged over the previous chunk's leftover prefix — the switch
|
||||
lands within one inference and the motion stays continuous. That is exactly what RTC's
|
||||
blending exists for. Documented per-backend in `inference.mdx`; no config flag, one sensible
|
||||
default per backend.
|
||||
|
||||
**`/reset` restores the launch task on the listener thread.** Subtle and worth preserving:
|
||||
the restore lives in `_cmd_reset`, not in `_reset_robot` (which runs later, on the main
|
||||
thread). Otherwise `/reset` followed immediately by `/subtask` would be ordered by *service*
|
||||
time rather than *command* time, and the deferred restore would silently revert the new
|
||||
instruction — deterministically so, for pasted or piped input. Both writers now run on the
|
||||
same thread, so command order wins. There is a regression test driving this through a real pipe.
|
||||
|
||||
## 5. Round 2 — the feature becomes a library API
|
||||
|
||||
Four follow-up asks landed together (currently uncommitted on the branch):
|
||||
make the components programmatic-API friendly (the priority), extend interactive
|
||||
to recording where cheap, simplify muting / surface errors, and settle the
|
||||
ssh/headless + `keyboard_input` question.
|
||||
|
||||
### 5.1 `RolloutController` — programmatic control
|
||||
|
||||
`interactive.py` bisected cleanly, so the generic control logic moved to a new
|
||||
`rollout/controller.py`:
|
||||
|
||||
```python
|
||||
controller = RolloutController(strategy, ctx, on_event=my_observer)
|
||||
controller.serve() # blocking loop (run it on whatever thread you like)
|
||||
controller.start() # -> bool: False when a segment is already running
|
||||
controller.set_task(t) # -> bool: re-instruct mid-run, from any thread
|
||||
controller.reset() # -> bool: True when the launch task was restored
|
||||
controller.stop()
|
||||
controller.task / .initial_task / .running / .failed / .failure_traceback
|
||||
```
|
||||
|
||||
- **No I/O of its own** — no stdin, no prints, no log muting, no TTS. Every
|
||||
state transition that used to be a `print` is now a `RolloutEvent`
|
||||
(`SEGMENT_STARTED`, `SEGMENT_ENDED`, `RESET_STARTED/DONE/SKIPPED`,
|
||||
`ENGINE_FAILED`, `STOPPED`) emitted on the serve thread.
|
||||
- **Thread-safe by lock, not by convention.** The old ordering guarantee
|
||||
(`/subtask` right after `/reset` must win) relied on both writes running on
|
||||
the single stdin thread. The controller serializes `start`/`reset`/`stop`/
|
||||
`set_task` with an internal lock, so the guarantee now holds for arbitrary
|
||||
caller threads — the prerequisite for network/voice front-ends.
|
||||
- `InteractiveSession` shrank to a thin adapter: stdin listener + parser +
|
||||
rendering + muting; each command maps 1:1 onto a controller method, and the
|
||||
controller is exposed as `session.controller`.
|
||||
- Exported from `lerobot.rollout`: `RolloutController`, `RolloutEvent`,
|
||||
`LinkedEvent`. `docs/source/inference.mdx` gained a **Programmatic control**
|
||||
section with a complete embedding example.
|
||||
|
||||
### 5.2 Sentry + interactive — recording while you steer
|
||||
|
||||
Decision, per the agreed criteria: the `/record` keyboard-handoff idea is
|
||||
**medium-to-large** (listeners have no suspend/resume API and start at
|
||||
creation, `esc` handlers are hardcoded and collide, pynput captures globally
|
||||
while you type, and each strategy carries per-run stale flags) → rejected.
|
||||
But the investigation showed **sentry has zero keyboard code** — the config
|
||||
comment lumping it with the keyboard strategies was simply wrong — and its
|
||||
only real blocker was one line: `with VideoEncodingManager(dataset)` inside
|
||||
`run()` finalizes the dataset the first time `run()` returns, after which a
|
||||
restarted segment would silently truncate the finalized parquet.
|
||||
|
||||
So `--interactive=true` now supports `--strategy.type=sentry`:
|
||||
|
||||
- **Finalization moved to `teardown()`** (which already called
|
||||
`dataset.finalize()`); `run()` is segment-restartable. Each segment saves
|
||||
complete episodes plus one tail partial episode; on a failed tail save the
|
||||
in-flight streaming encode is cancelled *and* the half-mutated episode
|
||||
buffer is discarded (see §6, round 2).
|
||||
- **Frames are labeled with the live `engine.task`** instead of a config
|
||||
snapshot — the writer already stores a task per frame — so `/subtask`
|
||||
changes the policy conditioning and the recorded label from the same frame
|
||||
onward. This also resolved the "recorded frames ignore `/subtask`" open item
|
||||
for sentry.
|
||||
- `episodes_since_push` hoisted to instance state so upload cadence survives
|
||||
segments.
|
||||
- dagger / highlight / episodic stay excluded: keyboard conflicts plus per-run
|
||||
recording state that does not survive a restart.
|
||||
|
||||
### 5.3 Muting v2 — two lines, and errors surface
|
||||
|
||||
The ~30-line per-handler walk became `logging.disable(logging.WARNING)` with
|
||||
the previous disable level restored afterwards. Strictly better coverage: the
|
||||
gate applies before handler dispatch, so it covers `propagate=False` library
|
||||
loggers *and* loggers created mid-session (the old snapshot missed those) —
|
||||
and **ERROR/CRITICAL now reach the console**, which the audit showed is safe:
|
||||
no ERROR-level emitter fires periodically in healthy operation (the periodic
|
||||
nuisances — slow-loop, camera hiccups — are WARNINGs and stay muted).
|
||||
Documented trade-off: the gate also withholds INFO/WARNING from file handlers
|
||||
during the session; acceptable because no default code path attaches one
|
||||
(only `rl/actor`, `rl/learner`, `async_inference` pass `log_file`). The
|
||||
`warnings` suppression stays (nothing calls `logging.captureWarnings`), and
|
||||
`failure_traceback` surfacing stays as the belt-and-suspenders for fatal
|
||||
engine errors.
|
||||
|
||||
### 5.4 stdin listener → `lerobot/utils/stdin_input.py`
|
||||
|
||||
The ssh/headless audit confirmed the listener was already the right design:
|
||||
`select`+`os.read` works over SSH (the session pty is a normal fd), from
|
||||
pipes, and headless — it's `keyboard_input`'s **pynput** backend that needs a
|
||||
display server. Nothing in `keyboard_input` overlaps enough to reuse
|
||||
(1-byte cbreak hotkey decoding vs canonical-mode line assembly), so
|
||||
`StdinCommandListener` moved to a **new** utils module — deliberately not
|
||||
into `keyboard_input.py`, which attempts a pynput import at module load.
|
||||
Canonical import only: `lerobot.utils.stdin_input` (removed from
|
||||
`lerobot.rollout`'s exports).
|
||||
|
||||
The move fixed a real bug the audit found: with `sys.stdin is None`
|
||||
(daemonized processes), the blocking fallback died with an uncaught
|
||||
`AttributeError` without firing `on_eof` — leaving a session idling with no
|
||||
command channel. `start()` now treats a missing stream as immediate EOF.
|
||||
|
||||
## 6. Bugs the adversarial reviews caught
|
||||
|
||||
Four multi-agent review passes were run across the phases (28 / 5 / 27 / 12 agents;
|
||||
findings adversarially verified before acting). The ones that mattered:
|
||||
|
||||
**Round 2 (2 confirmed, 0 refuted):**
|
||||
|
||||
- **Controller `start()` race → phantom segment.** `start()` gated on `_running`, but the
|
||||
serve loop cleared `_start_requested` *before* setting `_running` — a second `start()`
|
||||
landing in that window (spanning `reset_control_state` and the SEGMENT_STARTED emission)
|
||||
returned `True` and re-armed the flag, which nothing consumed during the segment; the
|
||||
robot would start again, uncommanded, when the segment later ended on its own. Fixed:
|
||||
the serve loop consumes the request and sets `_running` atomically under the control
|
||||
lock, and `_running` spans the whole startup sequence.
|
||||
- **Sentry poisoned episode buffer.** `save_episode` mutates the buffer in place (pops
|
||||
`size`/`task`) *before* the fallible writes; a failed tail save left a half-mutated dict
|
||||
and the next segment's first `add_frame` crashed with `KeyError('size')`. Fixed: the
|
||||
except branch discards the buffer so `add_frame` recreates it.
|
||||
|
||||
**Rounds 1–3 (Phases 1–2):**
|
||||
|
||||
- **RTC stale observation (critical).** `RTCInferenceEngine.reset()` never cleared
|
||||
`_obs_holder["obs"]`. After `/reset` physically moved the robot home, the next `/start`
|
||||
computed its first chunk from the **pre-reset pose** — a lurch back toward where the arm
|
||||
used to be. Fixed by clearing the observation and adding a `_reset_epoch` counter so an
|
||||
in-flight chunk computed across a reset is discarded rather than merged. This also fixes a
|
||||
pre-existing DAgger staleness path.
|
||||
- **Muting hid fatal errors** → `failure_traceback` capture + session print (§4.7).
|
||||
- **Muting scope too narrow** → root-only missed `transformers` / `datasets`; `warnings`
|
||||
output bypassed logging entirely.
|
||||
- **Command ordering** → `/reset` and `/stop` didn't cancel a pending `/start` (§4.5); the
|
||||
`/reset`-then-`/subtask` clobber (§4.8).
|
||||
- **Flush too heavy** → `policy.reset()` → `drop_queued_actions()` (§4.8).
|
||||
- **Empty-task rendering** → `''` replaced with `(none — set one with /subtask <text>)`.
|
||||
- **Silent switch** → the confirmation now says "(applies from the next policy inference)",
|
||||
since the explanatory logs are muted.
|
||||
|
||||
## 7. Verification
|
||||
|
||||
After Round 2:
|
||||
|
||||
```
|
||||
uv run --extra dataset pytest tests/test_interactive_rollout.py \
|
||||
tests/utils/test_stdin_input.py tests/test_rollout.py -q
|
||||
→ 81 passed
|
||||
|
||||
pre-commit (all changed files)
|
||||
→ 0 failures
|
||||
```
|
||||
|
||||
Phase 1–2 numbers (still green at the time): 64 rollout/interactive tests;
|
||||
223 passed / 5 skipped across `tests/policies/rtc`, factory, and common
|
||||
(confirming the shared `pretrained.py` change); pre-commit 0 failures.
|
||||
|
||||
`tests/test_interactive_rollout.py` covers the parser, `LinkedEvent` semantics,
|
||||
`RolloutController` (start/reset/stop/set_task flows, events, startup-race
|
||||
rejection, failure surfacing, broken observers), session flows (start / reset /
|
||||
restart / stop, cancel-pending-start, engine failure with traceback, natural
|
||||
end, EOF, and a real `BaseStrategy` end-to-end), muting (INFO/WARNING blocked,
|
||||
ERROR surfaces, pre-existing disable level restored), `/subtask` semantics,
|
||||
sentry restartability + live labels + failed-tail-save recovery, the engine
|
||||
task holder, the sync flush, and `drop_queued_actions`.
|
||||
`tests/utils/test_stdin_input.py` covers the listener (select path, batched
|
||||
lines, blocking fallback, EOF, handler errors, None-stdin, broken streams).
|
||||
|
||||
## 8. Extension points for Phase 3
|
||||
|
||||
The design was built to make `/ask` an additive change:
|
||||
|
||||
- **Command table.** `InteractiveSession._commands` is `name → (handler, arg hint, help)`.
|
||||
`/help` and the startup banner render from it, so a new command is documented for free.
|
||||
- **Controller API.** New front-ends (network, voice, `/ask`'s LLM worker) call
|
||||
`RolloutController.start/reset/stop/set_task` from their own threads — the internal lock
|
||||
makes that safe — and observe `RolloutEvent`s instead of scraping terminal output.
|
||||
- **Thread discipline.** A command handler runs on the listener thread and must only call
|
||||
controller methods. An LLM call belongs on its own worker thread so the robot keeps
|
||||
moving — precisely the concurrency `/ask` is meant to demonstrate.
|
||||
- **Task holder.** `set_task` / `_take_task` already give any producer a safe way to
|
||||
re-instruct the policy. Hierarchical task-vs-subtask semantics (per #4183 / #4234) layer
|
||||
on top of it rather than replacing it.
|
||||
|
||||
Open items, deliberately not addressed:
|
||||
|
||||
- dagger / highlight / episodic remain non-interactive (keyboard conflicts + per-run
|
||||
recording state); they also still snapshot the task label per run. Sentry is the
|
||||
supported recording path for interactive sessions.
|
||||
- The "see logs and prompt simultaneously" TUI (pinned input line).
|
||||
- Non-stdin input sources (network, voice) — now unblocked by `RolloutController`; #4108's
|
||||
pluggable-listener shape remains the reference for the transport layer.
|
||||
@@ -184,3 +184,29 @@ 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
@@ -0,0 +1,60 @@
|
||||
# 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,6 +191,28 @@
|
||||
- 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"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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
|
||||
@@ -0,0 +1,147 @@
|
||||
# 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
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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
|
||||
+16
-121
@@ -241,129 +241,24 @@ 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) |
|
||||
| `/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)
|
||||
> /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.
|
||||
|
||||
**Logs below ERROR are muted while the session runs** so routine output doesn't interleave with what you're typing; errors and fatal inference failures still show, and normal logging resumes when the session ends. The gate is process-wide (it also withholds INFO/WARNING from any file handler you attached for the duration). Run without `--interactive` to watch the live log.
|
||||
|
||||
Sessions work over SSH and on headless machines — the command reader uses the terminal (or pipe) directly and needs no display server.
|
||||
|
||||
**Recording while interactive.** `--strategy.type=sentry` also supports `--interactive=true`: the session records continuously while you steer it. Each `/start`…`/reset` segment saves complete episodes plus one final partial episode, the dataset stays open until shutdown, and **frames are labeled with the live task** — a `/subtask` changes both the policy conditioning and the recorded label from the same frame onwards.
|
||||
|
||||
```bash
|
||||
lerobot-rollout \
|
||||
--strategy.type=sentry \
|
||||
--policy.path=${HF_USER}/my_smolvla_policy \
|
||||
--robot.type=so100_follower \
|
||||
--robot.port=/dev/ttyACM0 \
|
||||
--dataset.repo_id=${HF_USER}/rollout_cube_sessions \
|
||||
--task="pick up the cube" \
|
||||
--interactive=true
|
||||
```
|
||||
|
||||
The other recording strategies (episodic, DAgger, highlight) are not supported: they bind their own keyboard controls, which would compete with the command prompt for the same terminal.
|
||||
|
||||
### Programmatic control
|
||||
|
||||
Everything the CLI session does is available as a library API: `RolloutController` exposes thread-safe `start()` / `reset()` / `stop()` / `set_task()` methods plus a `RolloutEvent` callback, with no stdin, printing, or log muting attached — embed it in your own application, network server, or notebook:
|
||||
|
||||
```python
|
||||
from threading import Event, Thread
|
||||
|
||||
from lerobot.rollout import (
|
||||
LinkedEvent,
|
||||
RolloutController,
|
||||
RolloutEvent,
|
||||
build_rollout_context,
|
||||
create_strategy,
|
||||
)
|
||||
|
||||
parent = Event() # your application's shutdown signal
|
||||
ctx = build_rollout_context(cfg, LinkedEvent(parent)) # loads policy, connects robot
|
||||
strategy = create_strategy(cfg.strategy)
|
||||
strategy.setup(ctx)
|
||||
|
||||
controller = RolloutController(strategy, ctx, on_event=print) # or your own observer
|
||||
serve_thread = Thread(target=controller.serve) # serve() blocks; run it where you like
|
||||
serve_thread.start()
|
||||
|
||||
controller.start() # robot starts executing the policy
|
||||
controller.set_task("grab the red cube") # re-instruct mid-run
|
||||
controller.reset() # stop movement, return home, stay warm
|
||||
controller.stop() # end serve()
|
||||
|
||||
serve_thread.join()
|
||||
strategy.teardown(ctx) # teardown stays with the caller
|
||||
```
|
||||
|
||||
Set `play_sounds=False` in the config unless you want the vocal announcements, and note that `build_rollout_context` requires the shutdown event to be a `LinkedEvent` (the controller ends run segments through its local flag; your `parent` event still forces a full shutdown). `InteractiveSession` itself is a thin front-end over this controller — commands map 1:1 onto its methods.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
| `--interactive` | Chat-style stdin session (see [Interactive Sessions](#interactive-sessions)); the robot stays idle until `/start`. Base and sentry strategies | false |
|
||||
| `--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 |
|
||||
| `--use_torch_compile` | Enable `torch.compile` for inference | false |
|
||||
| `--resume` | Resume a previous recording session | false |
|
||||
| `--play_sounds` | Vocal synthesis for events | true |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
# 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.
|
||||
+64
-17
@@ -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" #, "A", "S", "D", "RUF"
|
||||
"E", "W", "F", "I", "B", "C4", "T20", "N", "UP", "SIM", "D" #, "A", "S", "RUF"
|
||||
]
|
||||
ignore = [
|
||||
"E501", # Line too long
|
||||
@@ -411,9 +411,53 @@ ignore = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"__init__.py" = ["F401", "F403", "E402"]
|
||||
"__init__.py" = ["F401", "F403", "E402", "D104"]
|
||||
# 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"]
|
||||
@@ -457,21 +501,24 @@ default.extend-ignore-identifiers-re = [
|
||||
"seperated_timestep",
|
||||
]
|
||||
|
||||
# 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"]
|
||||
# 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: Enable mypy gradually module by module across multiple PRs
|
||||
# Uncomment [tool.mypy] first, then uncomment individual module overrides as they get proper type annotations
|
||||
|
||||
@@ -29,7 +29,6 @@ from safetensors.torch import load_model as load_model_as_safetensor
|
||||
from torch import Tensor, nn
|
||||
|
||||
from lerobot.configs import PreTrainedConfig
|
||||
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
|
||||
@@ -211,29 +210,6 @@ 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_rtc(self) -> bool:
|
||||
"""Whether this policy implements Real-Time Chunking inference semantics."""
|
||||
return False
|
||||
|
||||
@@ -38,11 +38,6 @@ from .context import (
|
||||
RuntimeContext,
|
||||
build_rollout_context,
|
||||
)
|
||||
from .controller import (
|
||||
LinkedEvent,
|
||||
RolloutController,
|
||||
RolloutEvent,
|
||||
)
|
||||
from .inference import (
|
||||
InferenceEngine,
|
||||
InferenceEngineConfig,
|
||||
@@ -52,11 +47,6 @@ from .inference import (
|
||||
SyncInferenceEngine,
|
||||
create_inference_engine,
|
||||
)
|
||||
from .interactive import (
|
||||
InteractiveCommand,
|
||||
InteractiveSession,
|
||||
parse_command,
|
||||
)
|
||||
from .strategies import (
|
||||
BaseStrategy,
|
||||
DAggerStrategy,
|
||||
@@ -75,24 +65,19 @@ __all__ = [
|
||||
"DAggerStrategy",
|
||||
"DAggerStrategyConfig",
|
||||
"DatasetContext",
|
||||
"EpisodicStrategy",
|
||||
"EpisodicStrategyConfig",
|
||||
"HardwareContext",
|
||||
"HighlightStrategy",
|
||||
"HighlightStrategyConfig",
|
||||
"EpisodicStrategy",
|
||||
"EpisodicStrategyConfig",
|
||||
"InferenceEngine",
|
||||
"InferenceEngineConfig",
|
||||
"InteractiveCommand",
|
||||
"InteractiveSession",
|
||||
"LinkedEvent",
|
||||
"PolicyContext",
|
||||
"ProcessorContext",
|
||||
"RTCInferenceConfig",
|
||||
"RTCInferenceEngine",
|
||||
"RolloutConfig",
|
||||
"RolloutContext",
|
||||
"RolloutController",
|
||||
"RolloutEvent",
|
||||
"RolloutStrategy",
|
||||
"RolloutStrategyConfig",
|
||||
"RuntimeContext",
|
||||
@@ -103,5 +88,4 @@ __all__ = [
|
||||
"build_rollout_context",
|
||||
"create_inference_engine",
|
||||
"create_strategy",
|
||||
"parse_command",
|
||||
]
|
||||
|
||||
@@ -239,14 +239,6 @@ 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>, /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 logs below ERROR are
|
||||
# muted while the session runs so they don't interleave with the prompt.
|
||||
# Supported with --strategy.type=base (no recording) and sentry
|
||||
# (continuous recording; frames are labeled with the live task).
|
||||
interactive: bool = False
|
||||
interpolation_multiplier: int = 1
|
||||
device: str | None = None
|
||||
task: str = ""
|
||||
@@ -302,17 +294,6 @@ 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. Base and sentry qualify: their run() loops keep no
|
||||
# per-run terminal or dataset-finalization state. The other recording
|
||||
# strategies are excluded for now: they bind their own keyboard controls
|
||||
# (which fight the command prompt for the terminal) and their run() loops
|
||||
# finalize the dataset on exit, so they cannot be restarted.
|
||||
if self.interactive and not isinstance(self.strategy, (BaseStrategyConfig, SentryStrategyConfig)):
|
||||
raise ValueError(
|
||||
f"--interactive=true supports --strategy.type=base or sentry (got '{self.strategy.type}')."
|
||||
)
|
||||
|
||||
# Sentry MUST use streaming encoding to avoid disk I/O blocking the control loop
|
||||
if (
|
||||
isinstance(self.strategy, SentryStrategyConfig)
|
||||
|
||||
@@ -1,382 +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.
|
||||
|
||||
"""Programmatic control of a rollout: start, pause, re-instruct, and stop a
|
||||
policy while hardware and policy stay connected and warm.
|
||||
|
||||
:class:`RolloutController` is the embedding-friendly core of interactive
|
||||
rollouts. It has no I/O of its own — no stdin, no printing, no log
|
||||
manipulation — so it can be driven from any application code: a CLI
|
||||
(:class:`lerobot.rollout.interactive.InteractiveSession` is exactly that), a
|
||||
network server, a voice front-end, or a notebook.
|
||||
|
||||
Typical embedding::
|
||||
|
||||
from threading import Event, Thread
|
||||
from lerobot.rollout import (
|
||||
LinkedEvent,
|
||||
RolloutController,
|
||||
build_rollout_context,
|
||||
create_strategy,
|
||||
)
|
||||
|
||||
parent = Event() # your application's shutdown signal
|
||||
ctx = build_rollout_context(cfg, LinkedEvent(parent))
|
||||
strategy = create_strategy(cfg.strategy)
|
||||
strategy.setup(ctx)
|
||||
|
||||
controller = RolloutController(strategy, ctx)
|
||||
serve_thread = Thread(target=controller.serve)
|
||||
serve_thread.start() # or call serve() on your main thread
|
||||
|
||||
controller.start() # robot starts executing the policy
|
||||
controller.set_task("grab the red cube") # re-instruct mid-run
|
||||
controller.reset() # stop movement, return home, stay warm
|
||||
controller.stop() # end serve()
|
||||
|
||||
serve_thread.join()
|
||||
strategy.teardown(ctx) # teardown stays with the caller
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from threading import Event, Lock
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .context import RolloutContext
|
||||
from .strategies import RolloutStrategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LinkedEvent(Event):
|
||||
"""A ``threading.Event`` whose ``is_set`` also reflects a parent event.
|
||||
|
||||
``set``/``clear`` act only on the local flag, so a controller 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
|
||||
controller 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
|
||||
|
||||
|
||||
class RolloutEvent(Enum):
|
||||
"""Lifecycle notifications emitted by :class:`RolloutController`.
|
||||
|
||||
All events are emitted on the thread running :meth:`RolloutController.serve`;
|
||||
callbacks must be quick and must not call back into the controller's
|
||||
blocking methods.
|
||||
"""
|
||||
|
||||
SEGMENT_STARTED = "segment_started"
|
||||
"""A control-loop segment is about to run (control state freshly reset)."""
|
||||
|
||||
SEGMENT_ENDED = "segment_ended"
|
||||
"""The segment returned on its own (e.g. ``--duration`` elapsed); the
|
||||
controller is idle again and the robot is holding position."""
|
||||
|
||||
RESET_STARTED = "reset_started"
|
||||
"""A reset is being executed: inference paused, robot about to move home."""
|
||||
|
||||
RESET_DONE = "reset_done"
|
||||
"""The robot is back at its initial position, holding."""
|
||||
|
||||
RESET_SKIPPED = "reset_skipped"
|
||||
"""No initial position was captured; the robot holds its current pose."""
|
||||
|
||||
ENGINE_FAILED = "engine_failed"
|
||||
"""The inference engine hit an unrecoverable error; ``serve()`` is about
|
||||
to return. Read :attr:`RolloutController.failure_traceback` for details."""
|
||||
|
||||
STOPPED = "stopped"
|
||||
"""``serve()`` is returning (after :meth:`RolloutController.stop`, EOF of
|
||||
the driving front-end, an engine failure, or a parent shutdown signal)."""
|
||||
|
||||
|
||||
class RolloutController:
|
||||
"""Drive a rollout strategy through thread-safe start/reset/stop/set_task calls.
|
||||
|
||||
The controller owns the outer lifecycle between ``strategy.setup(ctx)``
|
||||
and ``strategy.teardown(ctx)`` (both stay with the caller): the robot is
|
||||
idle until :meth:`start`, each run *segment* executes ``strategy.run(ctx)``
|
||||
on the thread that called :meth:`serve` until interrupted or until the
|
||||
strategy returns on its own (e.g. ``--duration`` elapsed). :meth:`reset`
|
||||
pauses the inference engine, returns the robot to its initial position,
|
||||
and restores the launch task, while hardware and policy stay warm.
|
||||
:meth:`stop` ends :meth:`serve` so the caller can run
|
||||
``strategy.teardown(ctx)``.
|
||||
|
||||
Requires ``ctx.runtime.shutdown_event`` to be a :class:`LinkedEvent`: the
|
||||
controller sets the local flag to end a segment, and process shutdown
|
||||
signals still propagate through the parent. Build the context with
|
||||
``build_rollout_context(cfg, LinkedEvent(shutdown_event))``.
|
||||
|
||||
Thread safety: the control methods (:meth:`start`, :meth:`reset`,
|
||||
:meth:`stop`, :meth:`set_task`) may be called from any thread and are
|
||||
serialized by an internal lock, so calls issued in order from one thread
|
||||
keep that order — e.g. a ``set_task`` right after a ``reset`` is not
|
||||
clobbered by the reset's task restore. Commands are last-write-wins:
|
||||
``reset`` and ``stop`` cancel a still-pending ``start`` so the robot
|
||||
never starts moving after the caller's most recent command asked it not
|
||||
to. Events are emitted on the :meth:`serve` thread via ``on_event``.
|
||||
"""
|
||||
|
||||
_POLL_INTERVAL_S = 0.2
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
strategy: RolloutStrategy,
|
||||
ctx: RolloutContext,
|
||||
on_event: Callable[[RolloutEvent], None] | None = None,
|
||||
) -> None:
|
||||
stop_event = ctx.runtime.shutdown_event
|
||||
if not isinstance(stop_event, LinkedEvent):
|
||||
raise TypeError(
|
||||
"RolloutController 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
|
||||
self._on_event = on_event
|
||||
# The instruction the rollout was launched with; reset() restores it.
|
||||
self._initial_task = ctx.policy.inference.task
|
||||
|
||||
# Serializes the control methods so multi-writer task updates (e.g.
|
||||
# reset()'s restore followed by a set_task()) keep their call order.
|
||||
self._control_lock = Lock()
|
||||
|
||||
# Written by control methods (any thread), consumed by the serve loop.
|
||||
self._start_requested = Event()
|
||||
self._reset_requested = Event()
|
||||
self._stop_requested = Event()
|
||||
self._wake = Event()
|
||||
self._running = Event()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Introspection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def task(self) -> str:
|
||||
"""The language instruction currently conditioning inference."""
|
||||
return self._ctx.policy.inference.task
|
||||
|
||||
@property
|
||||
def initial_task(self) -> str:
|
||||
"""The instruction the rollout was launched with (restored by :meth:`reset`)."""
|
||||
return self._initial_task
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
"""True while a control-loop segment is executing."""
|
||||
return self._running.is_set()
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
"""True if the inference engine hit an unrecoverable error."""
|
||||
return self._ctx.policy.inference.failed
|
||||
|
||||
@property
|
||||
def failure_traceback(self) -> str | None:
|
||||
"""Formatted traceback of the engine failure, when :attr:`failed` is True."""
|
||||
return self._ctx.policy.inference.failure_traceback
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Control methods (callable from any thread)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Request a control-loop segment.
|
||||
|
||||
Returns ``False`` when a segment is already running (the request is
|
||||
ignored); ``True`` when the segment was scheduled. The segment itself
|
||||
executes on the :meth:`serve` thread.
|
||||
"""
|
||||
with self._control_lock:
|
||||
if self._running.is_set():
|
||||
return False
|
||||
self._start_requested.set()
|
||||
self._wake.set()
|
||||
return True
|
||||
|
||||
def reset(self) -> bool:
|
||||
"""Stop movement, return the robot to its initial position, restore the launch task.
|
||||
|
||||
Hardware and policy stay warm; call :meth:`start` to run again.
|
||||
Returns ``True`` when the task was restored to the launch task (i.e.
|
||||
it had been changed), ``False`` when it was already the launch task.
|
||||
"""
|
||||
with self._control_lock:
|
||||
# Last command wins: a start() still waiting to be serviced is
|
||||
# cancelled so the robot never starts moving after the caller
|
||||
# asked it not to. Flag first, segment-stop second (see the
|
||||
# ordering note in _run_segment).
|
||||
self._start_requested.clear()
|
||||
# Restore the task here, under the control lock, rather than in
|
||||
# _reset_robot (which runs later, on the serve thread) so that a
|
||||
# set_task() issued right after this reset() is not silently
|
||||
# reverted by a deferred restore.
|
||||
restored = self._ctx.policy.inference.set_task(self._initial_task)
|
||||
self._reset_requested.set()
|
||||
self._segment_stop.set()
|
||||
self._wake.set()
|
||||
return restored
|
||||
|
||||
def stop(self) -> None:
|
||||
"""End :meth:`serve`; the caller then runs ``strategy.teardown(ctx)``."""
|
||||
with self._control_lock:
|
||||
self._start_requested.clear() # last command wins, see reset()
|
||||
self._stop_requested.set()
|
||||
self._segment_stop.set()
|
||||
self._wake.set()
|
||||
|
||||
def set_task(self, task: str) -> bool:
|
||||
"""Change the instruction the policy follows, effective from the next inference.
|
||||
|
||||
Returns ``True`` when the value actually changed. Safe to call while
|
||||
a segment is running: the engine applies the switch on its own
|
||||
inference thread (sync backends also drop actions precomputed under
|
||||
the previous instruction).
|
||||
"""
|
||||
with self._control_lock:
|
||||
return self._ctx.policy.inference.set_task(task)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Serve loop (blocks the calling thread)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def serve(self) -> None:
|
||||
"""Service control requests until :meth:`stop`, engine failure, or parent shutdown.
|
||||
|
||||
Blocks the calling thread; run segments execute here. Emits
|
||||
:class:`RolloutEvent` notifications through ``on_event``.
|
||||
"""
|
||||
try:
|
||||
while not self._global_shutdown.is_set():
|
||||
if self._ctx.policy.inference.failed:
|
||||
self._emit(RolloutEvent.ENGINE_FAILED)
|
||||
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():
|
||||
# Consume the request and mark the segment running in one
|
||||
# atomic step: start() gates on _running, so a concurrent
|
||||
# start() is rejected for the entire startup sequence
|
||||
# (reset_control_state, SEGMENT_STARTED emission), not just
|
||||
# once strategy.run() begins — otherwise it could re-arm
|
||||
# _start_requested behind the running segment and the robot
|
||||
# would start again, uncommanded, when the segment ends.
|
||||
with self._control_lock:
|
||||
starting = self._start_requested.is_set()
|
||||
if starting:
|
||||
self._start_requested.clear()
|
||||
self._running.set()
|
||||
if starting:
|
||||
self._run_segment()
|
||||
continue
|
||||
self._wake.wait(timeout=self._POLL_INTERVAL_S)
|
||||
self._wake.clear()
|
||||
finally:
|
||||
self._emit(RolloutEvent.STOPPED)
|
||||
|
||||
def _run_segment(self) -> None:
|
||||
"""Execute one ``strategy.run`` segment until interrupted or finished.
|
||||
|
||||
The serve loop has already set ``_running`` (under the control lock),
|
||||
so this method must clear it on every exit path.
|
||||
"""
|
||||
engine = self._ctx.policy.inference
|
||||
try:
|
||||
# Clear the local flag *before* checking the request flags: control
|
||||
# methods 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()
|
||||
self._emit(RolloutEvent.SEGMENT_STARTED)
|
||||
try:
|
||||
self._strategy.run(self._ctx)
|
||||
finally:
|
||||
engine.pause()
|
||||
finally:
|
||||
self._running.clear()
|
||||
if engine.failed:
|
||||
return # the serve loop emits ENGINE_FAILED and shuts down
|
||||
if not (
|
||||
self._stop_requested.is_set() or self._reset_requested.is_set() or self._global_shutdown.is_set()
|
||||
):
|
||||
self._emit(RolloutEvent.SEGMENT_ENDED)
|
||||
|
||||
def _reset_robot(self) -> None:
|
||||
"""Pause inference and return the robot home (the task was restored by :meth:`reset`)."""
|
||||
self._emit(RolloutEvent.RESET_STARTED)
|
||||
self._ctx.policy.inference.pause()
|
||||
if self._ctx.hardware.initial_position:
|
||||
self._strategy.return_to_initial_position(self._ctx.hardware)
|
||||
self._emit(RolloutEvent.RESET_DONE)
|
||||
else:
|
||||
logger.warning("No initial position captured — skipping the return move")
|
||||
self._emit(RolloutEvent.RESET_SKIPPED)
|
||||
|
||||
def _emit(self, event: RolloutEvent) -> None:
|
||||
if self._on_event is None:
|
||||
return
|
||||
try:
|
||||
self._on_event(event)
|
||||
except Exception: # a broken observer must not kill the serve loop
|
||||
logger.exception("Error in RolloutController event callback for %s", event)
|
||||
@@ -22,13 +22,9 @@ or asynchronously in a background thread (RTC).
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import logging
|
||||
from threading import Lock
|
||||
|
||||
import torch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InferenceEngine(abc.ABC):
|
||||
"""Abstract backend for producing actions during rollout.
|
||||
@@ -51,69 +47,12 @@ 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.
|
||||
|
||||
Optional hooks
|
||||
--------------
|
||||
``notify_observation`` / ``pause`` / ``resume`` have a no-op default
|
||||
so rollout strategies can invoke them unconditionally.
|
||||
|
||||
Subclasses must call ``super().__init__(task=...)``; the task holder
|
||||
is set up there.
|
||||
"""
|
||||
|
||||
def __init__(self, task: str = "") -> None:
|
||||
self._task = task
|
||||
self._task_changed = False
|
||||
self._task_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
|
||||
|
||||
@abc.abstractmethod
|
||||
def start(self) -> None:
|
||||
"""Initialise the backend."""
|
||||
@@ -148,8 +87,3 @@ 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,13 @@ class RTCInferenceEngine(InferenceEngine):
|
||||
rtc_queue_threshold: int = 30,
|
||||
shutdown_event: Event | None = None,
|
||||
) -> None:
|
||||
super().__init__(task=task)
|
||||
self._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,14 +140,10 @@ 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
|
||||
|
||||
@@ -194,15 +190,6 @@ 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."""
|
||||
@@ -248,29 +235,13 @@ class RTCInferenceEngine(InferenceEngine):
|
||||
self._policy_active.set()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""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.
|
||||
"""
|
||||
"""Reset the policy, processors, and action queue."""
|
||||
logger.info("Resetting RTC inference state (policy + processors + queue)")
|
||||
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
|
||||
# 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)
|
||||
@@ -310,7 +281,6 @@ 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
|
||||
@@ -324,24 +294,11 @@ 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, task, self._robot.robot_type
|
||||
obs_batch, policy_device, self._task, self._robot.robot_type
|
||||
)
|
||||
obs_batch["task"] = [task]
|
||||
obs_batch["task"] = [self._task]
|
||||
|
||||
preprocessed = self._preprocessor(obs_batch)
|
||||
|
||||
@@ -382,12 +339,7 @@ class RTCInferenceEngine(InferenceEngine):
|
||||
else:
|
||||
latency_tracker.add(new_latency)
|
||||
|
||||
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")
|
||||
queue.merge(original, processed, new_delay, idx_before)
|
||||
|
||||
if (
|
||||
is_warmup
|
||||
@@ -416,9 +368,8 @@ class RTCInferenceEngine(InferenceEngine):
|
||||
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(self._failure_traceback)
|
||||
logger.error(traceback.format_exc())
|
||||
self._rtc_error.set()
|
||||
# Unblock any warmup waiters so the main loop doesn't spin forever
|
||||
self._compile_warmup_done.set()
|
||||
|
||||
@@ -65,12 +65,12 @@ class SyncInferenceEngine(InferenceEngine):
|
||||
device: str | None,
|
||||
robot_type: str,
|
||||
) -> None:
|
||||
super().__init__(task=task)
|
||||
self._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(
|
||||
@@ -93,9 +93,6 @@ class SyncInferenceEngine(InferenceEngine):
|
||||
self._policy.reset()
|
||||
self._preprocessor.reset()
|
||||
self._postprocessor.reset()
|
||||
# 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."""
|
||||
@@ -110,20 +107,10 @@ class SyncInferenceEngine(InferenceEngine):
|
||||
if self._device.type == "cuda" and self._policy.config.use_amp
|
||||
else nullcontext()
|
||||
)
|
||||
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 = prepare_observation_for_inference(
|
||||
observation, self._device, self._task, self._robot_type
|
||||
)
|
||||
observation = self._preprocessor(observation)
|
||||
action = self._policy.select_action(observation)
|
||||
action = self._postprocessor(action)
|
||||
|
||||
@@ -1,304 +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.
|
||||
|
||||
"""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
|
||||
/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
|
||||
|
||||
This module is only the CLI front-end: stdin reading
|
||||
(:class:`lerobot.utils.stdin_input.StdinCommandListener`), command parsing,
|
||||
terminal output, and log muting. All control logic lives in
|
||||
:class:`lerobot.rollout.controller.RolloutController`, which is the public
|
||||
API for driving a rollout programmatically (from an application, a network
|
||||
server, a notebook, ...) without any of this module's terminal I/O.
|
||||
|
||||
Threading model: a daemon stdin-listener thread parses lines and calls the
|
||||
controller's thread-safe methods (``start``/``reset``/``stop``/``set_task``);
|
||||
it never touches hardware or policy state. ``RolloutController.serve()``
|
||||
runs on the main thread and executes ``strategy.run(ctx)`` in *segments*,
|
||||
ended through the session's :class:`LinkedEvent` (installed 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, log records below ERROR and Python warnings are
|
||||
suppressed process-wide (via ``logging.disable``) so routine system output
|
||||
does not interleave with the chat prompt; ERROR and CRITICAL records still
|
||||
reach the console, and a fatal inference-engine error is additionally
|
||||
reported with its captured traceback. Normal logging resumes when the
|
||||
session ends (so teardown logs are visible). 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 (``/ask`` and the rest of the language-runtime
|
||||
work in PR #4183/#4234) can be registered without restructuring the parser,
|
||||
the help output, or the session loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import warnings
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import IO, TYPE_CHECKING
|
||||
|
||||
from lerobot.utils.stdin_input import StdinCommandListener
|
||||
from lerobot.utils.utils import log_say
|
||||
|
||||
from .controller import RolloutController, RolloutEvent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .context import RolloutContext
|
||||
from .strategies import RolloutStrategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BANNER_RULE = "─" * 60
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _mute_system_output() -> Iterator[None]:
|
||||
"""Suppress log records below ERROR and Python warnings, process-wide.
|
||||
|
||||
System logs (policy, robot, control loop) contend with the chat prompt
|
||||
for the terminal. ``logging.disable`` gates records before any handler
|
||||
dispatch, which covers non-propagating library loggers (``transformers``,
|
||||
``datasets``) and loggers created mid-session alike; ERROR and CRITICAL
|
||||
records still get through, so failures stay visible. The gate applies to
|
||||
every handler — including file handlers, which therefore also miss
|
||||
INFO/WARNING records for the duration. Python warnings bypass logging
|
||||
entirely and are silenced separately.
|
||||
"""
|
||||
previous_disable = logging.root.manager.disable
|
||||
saved_warning_filters = warnings.filters[:]
|
||||
logging.disable(logging.WARNING)
|
||||
warnings.simplefilter("ignore")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
logging.disable(previous_disable)
|
||||
warnings.filters[:] = saved_warning_filters
|
||||
|
||||
|
||||
@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 (e.g.
|
||||
``/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 InteractiveSession:
|
||||
"""Drive a rollout from chat-style stdin commands.
|
||||
|
||||
A thin terminal front-end over :class:`RolloutController`: the stdin
|
||||
listener parses lines into commands, each command calls one of the
|
||||
controller's thread-safe methods, and controller events are rendered
|
||||
back as terminal output. The controller is exposed as
|
||||
:attr:`controller` for tests and embedders.
|
||||
|
||||
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``. The session works over SSH
|
||||
and in headless setups — it reads the terminal (or pipe) directly and
|
||||
needs no display server.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
strategy: RolloutStrategy,
|
||||
ctx: RolloutContext,
|
||||
input_stream: IO[str] | None = None,
|
||||
) -> None:
|
||||
self.controller = RolloutController(strategy, ctx, on_event=self._on_event)
|
||||
self._play_sounds = ctx.runtime.cfg.play_sounds
|
||||
self._listener = StdinCommandListener(self._handle_line, on_eof=self._handle_eof, stream=input_stream)
|
||||
|
||||
# name -> (handler, argument hint, help line); /help and the banner
|
||||
# render from this table, so future commands (e.g. /ask) 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"),
|
||||
"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"),
|
||||
}
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the session until ``/stop``, EOF, engine failure, or a shutdown signal."""
|
||||
try:
|
||||
with _mute_system_output():
|
||||
self._print(self._render_banner())
|
||||
self._listener.start()
|
||||
try:
|
||||
self.controller.serve()
|
||||
finally:
|
||||
self._listener.stop()
|
||||
finally:
|
||||
# Outside the muting context so the closing announcement and any
|
||||
# teardown logs are visible again.
|
||||
log_say("Interactive session ended", self._play_sounds)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Controller events (fired on the serve thread) -> terminal output
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_event(self, event: RolloutEvent) -> None:
|
||||
if event is RolloutEvent.SEGMENT_STARTED:
|
||||
log_say("Starting rollout", self._play_sounds)
|
||||
self._print(
|
||||
f"Rollout running — task {_format_task(self.controller.task)}. "
|
||||
"/subtask <text> to change it, /reset to return to initial position, /stop to shut down."
|
||||
)
|
||||
elif event is RolloutEvent.SEGMENT_ENDED:
|
||||
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."
|
||||
)
|
||||
elif event is RolloutEvent.RESET_STARTED:
|
||||
log_say("Resetting robot to initial position", self._play_sounds)
|
||||
self._print("Resetting — returning the robot to its initial position...")
|
||||
elif event is RolloutEvent.RESET_DONE:
|
||||
self._print("Robot reset — holding at initial position. /start to run.")
|
||||
elif event is RolloutEvent.RESET_SKIPPED:
|
||||
self._print("Robot paused — no initial position captured, holding current pose. /start to run.")
|
||||
elif event is RolloutEvent.ENGINE_FAILED:
|
||||
self._report_engine_failure()
|
||||
|
||||
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.controller.failure_traceback
|
||||
if failure_traceback:
|
||||
self._print(failure_traceback)
|
||||
else:
|
||||
self._print("Re-run without --interactive=true to see the error output.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Command handlers (called from the listener thread; the controller's
|
||||
# methods are thread-safe and only publish state)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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.controller.stop()
|
||||
|
||||
def _cmd_start(self, cmd: InteractiveCommand) -> None:
|
||||
if not self.controller.start():
|
||||
self._print("Already running — /reset to pause first, or /stop to shut down.")
|
||||
|
||||
def _cmd_subtask(self, cmd: InteractiveCommand) -> None:
|
||||
if not cmd.args:
|
||||
self._print(f"Current task: {_format_task(self.controller.task)}")
|
||||
return
|
||||
task = _strip_quotes(cmd.args)
|
||||
previous = self.controller.task
|
||||
if self.controller.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_reset(self, cmd: InteractiveCommand) -> None:
|
||||
if self.controller.reset():
|
||||
self._print(f"Task restored to {_format_task(self.controller.initial_task)}")
|
||||
|
||||
def _cmd_stop(self, cmd: InteractiveCommand) -> None:
|
||||
self.controller.stop()
|
||||
|
||||
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.controller.initial_task)}\n"
|
||||
f"{self._render_help()}\n"
|
||||
"System logs and warnings are muted during the session (errors still show).\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,24 +63,11 @@ class RolloutStrategy(abc.ABC):
|
||||
self._interpolator = ActionInterpolator(multiplier=ctx.runtime.cfg.interpolation_multiplier)
|
||||
self._engine = ctx.policy.inference
|
||||
logger.info("Starting inference engine...")
|
||||
self.reset_control_state()
|
||||
self._engine.reset()
|
||||
self._engine.start()
|
||||
self._warmup_flushed = False
|
||||
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
|
||||
logger.info("Inference engine started")
|
||||
|
||||
def _process_observation_and_notify(self, processors: ProcessorContext, obs_raw: dict) -> dict:
|
||||
"""Run the observation processor and notify the engine — throttled to policy ticks.
|
||||
@@ -138,7 +125,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."
|
||||
@@ -151,7 +138,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
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import time
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from threading import Event, Lock
|
||||
|
||||
from lerobot.datasets import VideoEncodingManager
|
||||
from lerobot.datasets.utils import DEFAULT_VIDEO_FILE_SIZE_IN_MB
|
||||
from lerobot.utils.constants import ACTION, OBS_STR
|
||||
from lerobot.utils.feature_utils import build_dataset_frame
|
||||
@@ -54,14 +55,6 @@ class SentryStrategy(RolloutStrategy):
|
||||
|
||||
Requires ``streaming_encoding=True`` (enforced in config validation)
|
||||
to prevent disk I/O from blocking the control loop.
|
||||
|
||||
``run()`` is restartable: each call records complete episodes plus one
|
||||
final partial episode, and the dataset is only finalized in
|
||||
``teardown()`` — this is what lets ``--interactive=true`` drive sentry
|
||||
in start/reset/start segments while the dataset stays open. Frames are
|
||||
labeled with the inference engine's *live* task, so a mid-run
|
||||
``/subtask`` changes both the policy conditioning and the recorded
|
||||
label from the same frame onwards.
|
||||
"""
|
||||
|
||||
config: SentryStrategyConfig
|
||||
@@ -77,9 +70,6 @@ class SentryStrategy(RolloutStrategy):
|
||||
"""Initialise the inference engine and background push executor."""
|
||||
self._init_engine(ctx)
|
||||
self._push_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="sentry-push")
|
||||
# Instance state (not run()-local) so the upload cadence survives
|
||||
# interactive run segments.
|
||||
self._episodes_since_push = 0
|
||||
target_mb = self.config.target_video_file_size_mb or DEFAULT_VIDEO_FILE_SIZE_IN_MB
|
||||
self._episode_duration_s = estimate_max_episode_seconds(
|
||||
ctx.data.dataset_features, ctx.runtime.cfg.fps, target_size_mb=target_mb
|
||||
@@ -107,95 +97,79 @@ class SentryStrategy(RolloutStrategy):
|
||||
|
||||
start_time = time.perf_counter()
|
||||
episode_start = time.perf_counter()
|
||||
episodes_since_push = 0
|
||||
task_str = cfg.dataset.single_task if cfg.dataset else cfg.task
|
||||
logger.info("Sentry recording started (episode_duration=%.0fs)", episode_duration_s)
|
||||
|
||||
# No dataset finalization here: run() must be restartable (interactive
|
||||
# segments), so the dataset stays open until teardown() finalizes it.
|
||||
try:
|
||||
while not ctx.runtime.shutdown_event.is_set():
|
||||
loop_start = time.perf_counter()
|
||||
with VideoEncodingManager(dataset):
|
||||
try:
|
||||
while not ctx.runtime.shutdown_event.is_set():
|
||||
loop_start = time.perf_counter()
|
||||
|
||||
if cfg.duration > 0 and (time.perf_counter() - start_time) >= cfg.duration:
|
||||
logger.info("Duration limit reached (%.0fs)", cfg.duration)
|
||||
break
|
||||
if cfg.duration > 0 and (time.perf_counter() - start_time) >= cfg.duration:
|
||||
logger.info("Duration limit reached (%.0fs)", cfg.duration)
|
||||
break
|
||||
|
||||
obs = robot.get_observation()
|
||||
obs_processed = self._process_observation_and_notify(ctx.processors, obs)
|
||||
obs = robot.get_observation()
|
||||
obs_processed = self._process_observation_and_notify(ctx.processors, obs)
|
||||
|
||||
if self._handle_warmup(cfg.use_torch_compile, loop_start, control_interval):
|
||||
continue
|
||||
if self._handle_warmup(cfg.use_torch_compile, loop_start, control_interval):
|
||||
continue
|
||||
|
||||
action_dict = send_next_action(obs_processed, obs, ctx, interpolator)
|
||||
action_dict = send_next_action(obs_processed, obs, ctx, interpolator)
|
||||
|
||||
if action_dict is not None:
|
||||
self._log_telemetry(obs_processed, action_dict, ctx.runtime)
|
||||
obs_frame = build_dataset_frame(features, obs_processed, prefix=OBS_STR)
|
||||
action_frame = build_dataset_frame(features, action_dict, prefix=ACTION)
|
||||
# The task is read live from the engine (not snapshotted from
|
||||
# config) so an interactive /subtask relabels frames from the
|
||||
# moment it re-instructs the policy; the writer stores a task
|
||||
# per frame. At launch the engine holds the configured task.
|
||||
frame = {**obs_frame, **action_frame, "task": engine.task}
|
||||
# ``add_frame`` writes to the in-progress episode buffer; the
|
||||
# background pusher only ever touches *finalised* episode
|
||||
# artifacts on disk. The two operate on disjoint state, so
|
||||
# ``add_frame`` does not need ``_episode_lock``.
|
||||
dataset.add_frame(frame)
|
||||
if action_dict is not None:
|
||||
self._log_telemetry(obs_processed, action_dict, ctx.runtime)
|
||||
obs_frame = build_dataset_frame(features, obs_processed, prefix=OBS_STR)
|
||||
action_frame = build_dataset_frame(features, action_dict, prefix=ACTION)
|
||||
frame = {**obs_frame, **action_frame, "task": task_str}
|
||||
# ``add_frame`` writes to the in-progress episode buffer; the
|
||||
# background pusher only ever touches *finalised* episode
|
||||
# artifacts on disk. The two operate on disjoint state, so
|
||||
# ``add_frame`` does not need ``_episode_lock``.
|
||||
dataset.add_frame(frame)
|
||||
|
||||
# Episode rotation derived from video file-size target.
|
||||
# The duration is a conservative estimate so the actual
|
||||
# video has crossed DEFAULT_VIDEO_FILE_SIZE_IN_MB by now,
|
||||
# keeping push_to_hub efficient (uploads complete files).
|
||||
elapsed = time.perf_counter() - episode_start
|
||||
if elapsed >= episode_duration_s:
|
||||
# ``save_episode`` finalises the in-progress episode and
|
||||
# flushes it to disk; ``_episode_lock`` serialises this with
|
||||
# ``push_to_hub`` (run in the background executor) so the
|
||||
# pusher never reads a half-written episode.
|
||||
# Episode rotation derived from video file-size target.
|
||||
# The duration is a conservative estimate so the actual
|
||||
# video has crossed DEFAULT_VIDEO_FILE_SIZE_IN_MB by now,
|
||||
# keeping push_to_hub efficient (uploads complete files).
|
||||
elapsed = time.perf_counter() - episode_start
|
||||
if elapsed >= episode_duration_s:
|
||||
# ``save_episode`` finalises the in-progress episode and
|
||||
# flushes it to disk; ``_episode_lock`` serialises this with
|
||||
# ``push_to_hub`` (run in the background executor) so the
|
||||
# pusher never reads a half-written episode.
|
||||
with self._episode_lock:
|
||||
dataset.save_episode()
|
||||
episodes_since_push += 1
|
||||
self._needs_push.set()
|
||||
logger.info(
|
||||
"Episode saved (total: %d, elapsed: %.1fs)",
|
||||
dataset.num_episodes,
|
||||
elapsed,
|
||||
)
|
||||
log_say(f"Episode {dataset.num_episodes} saved", play_sounds)
|
||||
|
||||
if episodes_since_push >= self.config.upload_every_n_episodes:
|
||||
self._background_push(dataset, cfg)
|
||||
episodes_since_push = 0
|
||||
|
||||
episode_start = time.perf_counter()
|
||||
|
||||
dt = time.perf_counter() - loop_start
|
||||
if (sleep_t := control_interval - dt) > 0:
|
||||
precise_sleep(sleep_t)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Record loop is running slower ({1 / dt:.1f} Hz) than the target FPS ({cfg.fps} Hz). Dataset frames might be dropped and robot control might be unstable. Common causes are: 1) Camera FPS not keeping up 2) Policy inference taking too long 3) CPU starvation"
|
||||
)
|
||||
|
||||
finally:
|
||||
logger.info("Sentry control loop ended — saving final episode")
|
||||
with contextlib.suppress(Exception):
|
||||
with self._episode_lock:
|
||||
dataset.save_episode()
|
||||
self._episodes_since_push += 1
|
||||
self._needs_push.set()
|
||||
logger.info(
|
||||
"Episode saved (total: %d, elapsed: %.1fs)",
|
||||
dataset.num_episodes,
|
||||
elapsed,
|
||||
)
|
||||
log_say(f"Episode {dataset.num_episodes} saved", play_sounds)
|
||||
|
||||
if self._episodes_since_push >= self.config.upload_every_n_episodes:
|
||||
self._background_push(dataset, cfg)
|
||||
self._episodes_since_push = 0
|
||||
|
||||
episode_start = time.perf_counter()
|
||||
|
||||
dt = time.perf_counter() - loop_start
|
||||
if (sleep_t := control_interval - dt) > 0:
|
||||
precise_sleep(sleep_t)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Record loop is running slower ({1 / dt:.1f} Hz) than the target FPS ({cfg.fps} Hz). Dataset frames might be dropped and robot control might be unstable. Common causes are: 1) Camera FPS not keeping up 2) Policy inference taking too long 3) CPU starvation"
|
||||
)
|
||||
|
||||
finally:
|
||||
logger.info("Sentry control loop ended — saving final episode")
|
||||
try:
|
||||
with self._episode_lock:
|
||||
dataset.save_episode()
|
||||
self._needs_push.set()
|
||||
except Exception:
|
||||
# The tail episode could not be committed (nothing was
|
||||
# recorded, or the save failed mid-write). Drop the in-flight
|
||||
# streaming encode so teardown()'s finalize does not flush a
|
||||
# half-written video, and discard the episode buffer: a failed
|
||||
# save_episode leaves it half-mutated, which would crash the
|
||||
# first add_frame of a restarted segment. add_frame recreates
|
||||
# a fresh buffer from None.
|
||||
logger.warning("Tail episode was not saved — discarding it", exc_info=True)
|
||||
if dataset.writer is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
dataset.writer.cancel_pending_videos()
|
||||
dataset.writer.episode_buffer = None
|
||||
|
||||
def teardown(self, ctx: RolloutContext) -> None:
|
||||
"""Flush pending pushes, finalise the dataset, and disconnect hardware."""
|
||||
|
||||
@@ -44,19 +44,6 @@ Usage examples
|
||||
--robot.port=/dev/ttyACM0 \\
|
||||
--task="pick up cube" --duration=30
|
||||
|
||||
# Interactive session (base or sentry strategy): the robot stays idle
|
||||
# until /start is typed; /subtask <text> re-instructs the policy mid-run;
|
||||
# /reset returns it to the initial position (hardware and policy stay
|
||||
# warm); /stop shuts down gracefully. With --strategy.type=sentry the
|
||||
# session also records continuously, labeling frames with the live task.
|
||||
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 \\
|
||||
@@ -186,13 +173,7 @@ from lerobot.robots import ( # noqa: F401
|
||||
so_follower,
|
||||
unitree_g1 as unitree_g1_robot,
|
||||
)
|
||||
from lerobot.rollout import (
|
||||
InteractiveSession,
|
||||
LinkedEvent,
|
||||
RolloutConfig,
|
||||
build_rollout_context,
|
||||
create_strategy,
|
||||
)
|
||||
from lerobot.rollout import RolloutConfig, build_rollout_context, create_strategy
|
||||
from lerobot.teleoperators import ( # noqa: F401
|
||||
Teleoperator,
|
||||
TeleoperatorConfig,
|
||||
@@ -234,10 +215,6 @@ 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)
|
||||
@@ -253,12 +230,8 @@ def rollout(cfg: RolloutConfig):
|
||||
|
||||
try:
|
||||
strategy.setup(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)
|
||||
logger.info("Rollout setup complete, starting rollout...")
|
||||
strategy.run(ctx)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Interrupted by user")
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# 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)
|
||||
@@ -1,187 +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.
|
||||
|
||||
"""Non-blocking, line-oriented stdin reading.
|
||||
|
||||
This complements :mod:`lerobot.utils.keyboard_input`, which serves discrete
|
||||
hotkeys: :class:`TerminalKeyListener` reads single raw bytes in cbreak mode,
|
||||
whereas :class:`StdinCommandListener` here assembles whole typed lines and
|
||||
leaves the terminal in canonical (line-buffered, echoing) mode — the operator
|
||||
is typing chat-style commands, not pressing hotkeys. The two cannot share
|
||||
stdin at the same time.
|
||||
|
||||
Environment support: reading works over SSH (the session's pty is a regular
|
||||
TTY file descriptor), in headless setups (no display server is involved,
|
||||
unlike the ``pynput`` keyboard backend), and from piped stdin. End-of-file
|
||||
means "no more commands": an interactive Ctrl-D, an exhausted piped script,
|
||||
or ``stdin`` redirected from ``/dev/null`` all trigger ``on_eof``, as does a
|
||||
missing ``sys.stdin`` (e.g. a daemonized process).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import select
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from threading import Thread
|
||||
from typing import IO
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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`` — a dead command channel must
|
||||
never leave the consumer waiting for input that can no longer arrive.
|
||||
"""
|
||||
|
||||
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
|
||||
# sys.stdin can itself be None (pythonw, daemonized processes);
|
||||
# start() treats that as an immediately-closed stream.
|
||||
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
|
||||
if self._stream is None:
|
||||
logger.warning("No stdin available for command input — treating as EOF")
|
||||
self._emit_eof()
|
||||
return
|
||||
self._running = True
|
||||
self._thread = Thread(target=self._run, daemon=True, name="StdinCommandListener")
|
||||
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, AttributeError):
|
||||
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 input line %r", line)
|
||||
|
||||
def _emit_eof(self) -> None:
|
||||
logger.info("Input stream closed (EOF)")
|
||||
if self._on_eof is not None:
|
||||
try:
|
||||
self._on_eof()
|
||||
except Exception:
|
||||
logger.exception("Error while handling input EOF")
|
||||
|
||||
def _emit_read_error(self) -> None:
|
||||
"""Treat an unexpected read failure like EOF so consumers shut down.
|
||||
|
||||
A dead command channel must not leave the consumer running with no
|
||||
way to reach it. Deliberate ``stop()`` calls clear ``_running``
|
||||
first and do not reach this path.
|
||||
"""
|
||||
if self._running:
|
||||
logger.warning("Input stream failed — treating as EOF")
|
||||
self._emit_eof()
|
||||
@@ -16,7 +16,6 @@ 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 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,7 +32,6 @@ 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
|
||||
|
||||
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_reference():
|
||||
def test_ema_config_defaults_match_the_reference():
|
||||
cfg = EMAConfig()
|
||||
assert not cfg.enable
|
||||
assert cfg.inv_gamma == 1.0
|
||||
|
||||
@@ -21,8 +21,10 @@ 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 automatically generate accelerate configs and launch training
|
||||
with subprocess to properly test the distributed training environment.
|
||||
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`).
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -58,73 +60,25 @@ def download_dataset(repo_id, episodes):
|
||||
print(f"Dataset {repo_id} downloaded successfully")
|
||||
|
||||
|
||||
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"):
|
||||
def run_accelerate_training(config_args, num_processes=4):
|
||||
"""
|
||||
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",
|
||||
"--config_file",
|
||||
str(config_path),
|
||||
f"--num_processes={num_processes}",
|
||||
"-m",
|
||||
"lerobot.scripts.lerobot_train",
|
||||
] + config_args
|
||||
@@ -173,7 +127,7 @@ class TestMultiGPUTraining:
|
||||
"--num_workers=0",
|
||||
]
|
||||
|
||||
result = run_accelerate_training(config_args, num_processes=4, temp_dir=temp_dir)
|
||||
result = run_accelerate_training(config_args, num_processes=4)
|
||||
|
||||
# Check that training completed successfully
|
||||
assert result.returncode == 0, (
|
||||
@@ -216,7 +170,7 @@ class TestMultiGPUTraining:
|
||||
"--num_workers=0",
|
||||
]
|
||||
|
||||
result = run_accelerate_training(config_args, num_processes=2, temp_dir=temp_dir)
|
||||
result = run_accelerate_training(config_args, num_processes=2)
|
||||
|
||||
assert result.returncode == 0, (
|
||||
f"Training failed:\nSTDOUT:\n{result.stdout}\n\nSTDERR:\n{result.stderr}"
|
||||
@@ -246,11 +200,12 @@ class TestMultiGPUTraining:
|
||||
|
||||
def test_fsdp_optimizer_save_and_resume(self):
|
||||
"""
|
||||
Test that FSDP saves the (gathered) optimizer state and can resume from it.
|
||||
Test that FSDP saves the sharded optimizer state and can resume from it.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
# Pre-download dataset to avoid race conditions
|
||||
download_dataset("lerobot/pusht", episodes=[0])
|
||||
@@ -265,6 +220,7 @@ 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",
|
||||
@@ -274,34 +230,33 @@ class TestMultiGPUTraining:
|
||||
"--num_workers=0",
|
||||
]
|
||||
|
||||
result = run_accelerate_training(
|
||||
config_args, num_processes=2, temp_dir=temp_dir, distributed_type="FSDP"
|
||||
)
|
||||
result = run_accelerate_training(config_args, num_processes=2)
|
||||
assert result.returncode == 0, (
|
||||
f"FSDP training failed:\nSTDOUT:\n{result.stdout}\n\nSTDERR:\n{result.stderr}"
|
||||
)
|
||||
|
||||
# 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}"
|
||||
# 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}"
|
||||
)
|
||||
|
||||
# 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 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_args = [
|
||||
f"--config_path={resume_config}",
|
||||
"--resume=true",
|
||||
"--steps=20",
|
||||
]
|
||||
resume_result = run_accelerate_training(
|
||||
resume_args, num_processes=2, temp_dir=temp_dir, distributed_type="FSDP"
|
||||
)
|
||||
resume_result = run_accelerate_training(resume_args, num_processes=2)
|
||||
assert resume_result.returncode == 0, (
|
||||
f"FSDP resume failed:\nSTDOUT:\n{resume_result.stdout}\n\nSTDERR:\n{resume_result.stderr}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# 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,136 +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.
|
||||
|
||||
"""Tests for the non-blocking stdin line reader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from threading import Event
|
||||
|
||||
from lerobot.utils.stdin_input import StdinCommandListener
|
||||
|
||||
|
||||
def _wait_for(predicate, timeout: float = 2.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.005)
|
||||
return predicate()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _pipe_stream():
|
||||
"""A held-open pipe so the listener never sees EOF until we close it."""
|
||||
read_fd, write_fd = os.pipe()
|
||||
reader = os.fdopen(read_fd, "r")
|
||||
writer = os.fdopen(write_fd, "w")
|
||||
try:
|
||||
yield reader, writer
|
||||
finally:
|
||||
with contextlib.suppress(OSError, ValueError):
|
||||
writer.close()
|
||||
with contextlib.suppress(OSError, ValueError):
|
||||
reader.close()
|
||||
|
||||
|
||||
def test_stdin_listener_reads_lines_and_eof():
|
||||
lines: list[str] = []
|
||||
eof = Event()
|
||||
|
||||
with _pipe_stream() as (reader, writer):
|
||||
listener = StdinCommandListener(lines.append, on_eof=eof.set, stream=reader)
|
||||
listener.start()
|
||||
writer.write("/start\n")
|
||||
writer.write(" \n") # blank lines are skipped
|
||||
writer.write("/help\n")
|
||||
writer.flush()
|
||||
assert _wait_for(lambda: len(lines) == 2)
|
||||
assert lines == ["/start", "/help"]
|
||||
|
||||
writer.close()
|
||||
assert _wait_for(eof.is_set)
|
||||
listener.stop()
|
||||
|
||||
|
||||
def test_stdin_listener_delivers_batched_lines():
|
||||
"""Several lines arriving in one chunk (paste, piped script) are all delivered."""
|
||||
lines: list[str] = []
|
||||
|
||||
with _pipe_stream() as (reader, writer):
|
||||
listener = StdinCommandListener(lines.append, stream=reader)
|
||||
listener.start()
|
||||
writer.write("/start\n/subtask grab the cube\n/stop\n")
|
||||
writer.flush()
|
||||
assert _wait_for(lambda: len(lines) == 3)
|
||||
assert lines == ["/start", "/subtask grab the cube", "/stop"]
|
||||
listener.stop()
|
||||
|
||||
|
||||
def test_stdin_listener_handler_errors_do_not_kill_reader():
|
||||
lines: list[str] = []
|
||||
|
||||
def flaky(line: str) -> None:
|
||||
if line == "/boom":
|
||||
raise RuntimeError("boom")
|
||||
lines.append(line)
|
||||
|
||||
with _pipe_stream() as (reader, writer):
|
||||
listener = StdinCommandListener(flaky, stream=reader)
|
||||
listener.start()
|
||||
writer.write("/boom\n/start\n")
|
||||
writer.flush()
|
||||
assert _wait_for(lambda: lines == ["/start"])
|
||||
listener.stop()
|
||||
|
||||
|
||||
def test_stdin_listener_blocking_fallback():
|
||||
"""Streams without a file descriptor (e.g. StringIO) use the blocking readline path."""
|
||||
lines: list[str] = []
|
||||
eof = Event()
|
||||
listener = StdinCommandListener(lines.append, on_eof=eof.set, stream=io.StringIO("/start\n\n/help\n"))
|
||||
assert not listener._use_select
|
||||
listener.start()
|
||||
assert _wait_for(eof.is_set)
|
||||
assert lines == ["/start", "/help"]
|
||||
listener.stop()
|
||||
|
||||
|
||||
def test_stdin_listener_none_stdin_treated_as_eof(monkeypatch):
|
||||
"""A missing sys.stdin (daemonized process) must fire on_eof, not hang silently."""
|
||||
monkeypatch.setattr(sys, "stdin", None)
|
||||
eof = Event()
|
||||
listener = StdinCommandListener(lambda line: None, on_eof=eof.set)
|
||||
listener.start()
|
||||
assert eof.is_set()
|
||||
listener.stop()
|
||||
|
||||
|
||||
def test_stdin_listener_broken_stream_treated_as_eof():
|
||||
"""A stream whose readline blows up mid-run must fire on_eof (dead command channel)."""
|
||||
|
||||
class _BrokenStream:
|
||||
def readline(self):
|
||||
raise AttributeError("broken")
|
||||
|
||||
eof = Event()
|
||||
listener = StdinCommandListener(lambda line: None, on_eof=eof.set, stream=_BrokenStream())
|
||||
listener.start()
|
||||
assert _wait_for(eof.is_set)
|
||||
listener.stop()
|
||||
@@ -0,0 +1,153 @@
|
||||
# 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())
|
||||
@@ -0,0 +1,566 @@
|
||||
# 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())
|
||||
@@ -0,0 +1,109 @@
|
||||
# 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())
|
||||
@@ -0,0 +1,12 @@
|
||||
# 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