| |
| """Patch an installed lerobot==0.5.1 to be Python 3.11-compatible. |
| |
| lerobot 0.5.1 uses PEP 695 syntax (generic class/function parameter lists |
| and the `type` alias statement, both 3.12-only) at 4 sites. Python 3.11's |
| parser rejects all of them. This script rewrites them to pre-PEP-695 |
| forms (typing.TypeVar + Generic, plain assignments) so the package can |
| be imported under Python 3.11. |
| |
| Idempotent: safe to run multiple times. Verifies all touched files parse |
| under Python 3.11 grammar at the end (fails loud if anything regressed). |
| |
| Use in a Dockerfile: |
| RUN pip install --ignore-requires-python "lerobot==0.5.1" && \\ |
| python /tmp/patch_lerobot_for_py311.py |
| |
| Why --ignore-requires-python: lerobot pins requires-python>=3.12 in its |
| metadata. After this patch the code itself runs on 3.11; the pin is no |
| longer accurate for the patched copy. |
| """ |
| from __future__ import annotations |
|
|
| import ast |
| import importlib.util |
| import sys |
| from pathlib import Path |
|
|
|
|
| def _locate_lerobot() -> Path: |
| spec = importlib.util.find_spec("lerobot") |
| if spec is None or spec.origin is None: |
| sys.exit("error: lerobot is not installed in this environment") |
| return Path(spec.origin).parent |
|
|
|
|
| def _replace_in_file(path: Path, replacements: list[tuple[str, str]]) -> bool: |
| """Apply each (old, new) replacement to `path`. Returns True if any |
| change was made. Idempotent: if `new` is already present, treat as |
| already-patched even when `old` is also (substring-)present, since |
| several of our replacements append to a block where `old` is a |
| prefix of `new`.""" |
| src = path.read_text() |
| original = src |
| for old, new in replacements: |
| if new in src: |
| continue |
| if old in src: |
| src = src.replace(old, new, 1) |
| else: |
| sys.exit( |
| f"error: {path} does not contain expected old or new text:\n" |
| f" old: {old!r}\n new: {new!r}" |
| ) |
| if src != original: |
| path.write_text(src) |
| return True |
| return False |
|
|
|
|
| def patch(root: Path) -> None: |
| print(f"patching lerobot at {root}") |
|
|
| |
| changed = _replace_in_file( |
| root / "motors" / "motors_bus.py", |
| [ |
| ("type NameOrID = str | int\ntype Value = int | float", |
| "NameOrID = str | int\nValue = int | float"), |
| ], |
| ) |
| print(f" motors_bus.py : {'patched' if changed else 'already patched'}") |
|
|
| |
| changed = _replace_in_file( |
| root / "datasets" / "streaming_dataset.py", |
| [ |
| ("from collections import deque\n" |
| "from collections.abc import Callable, Generator, Iterable, Iterator\n" |
| "from pathlib import Path", |
| "from collections import deque\n" |
| "from collections.abc import Callable, Generator, Iterable, Iterator\n" |
| "from pathlib import Path\n" |
| "from typing import Generic, TypeVar\n\n" |
| "T = TypeVar(\"T\")"), |
| ("class Backtrackable[T]:", "class Backtrackable(Generic[T]):"), |
| ], |
| ) |
| print(f" streaming_dataset.py : {'patched' if changed else 'already patched'}") |
|
|
| |
| changed = _replace_in_file( |
| root / "utils" / "io_utils.py", |
| [ |
| ("import json\nimport warnings\nfrom pathlib import Path\n\n" |
| "import imageio\n\n" |
| "JsonLike = ", |
| "import json\nimport warnings\nfrom pathlib import Path\n" |
| "from typing import TypeVar\n\n" |
| "import imageio\n\n" |
| "JsonLike = "), |
| |
| ("JsonLike = str | int | float | bool | None | " |
| "list[\"JsonLike\"] | dict[str, \"JsonLike\"] | tuple[\"JsonLike\", ...]\n\n\n" |
| "def write_video", |
| "JsonLike = str | int | float | bool | None | " |
| "list[\"JsonLike\"] | dict[str, \"JsonLike\"] | tuple[\"JsonLike\", ...]\n" |
| "T = TypeVar(\"T\", bound=JsonLike)\n\n\n" |
| "def write_video"), |
| ("def deserialize_json_into_object[T: JsonLike](fpath: Path, obj: T) -> T:", |
| "def deserialize_json_into_object(fpath: Path, obj: T) -> T:"), |
| ], |
| ) |
| print(f" io_utils.py : {'patched' if changed else 'already patched'}") |
|
|
| |
| changed = _replace_in_file( |
| root / "processor" / "pipeline.py", |
| [ |
| ("from typing import Any, TypedDict, TypeVar, cast", |
| "from typing import Any, Generic, TypedDict, TypeVar, cast"), |
| ("class DataProcessorPipeline[TInput, TOutput](HubMixin):", |
| "class DataProcessorPipeline(HubMixin, Generic[TInput, TOutput]):"), |
| ], |
| ) |
| print(f" processor/pipeline.py : {'patched' if changed else 'already patched'}") |
|
|
|
|
| def verify(root: Path) -> None: |
| print("verifying all lerobot .py files parse under Python 3.11 grammar...") |
| fails: list[tuple[Path, int, str]] = [] |
| for p in root.rglob("*.py"): |
| try: |
| ast.parse(p.read_text(), filename=str(p), feature_version=(3, 11)) |
| except SyntaxError as e: |
| fails.append((p, e.lineno or 0, e.msg or "")) |
| if fails: |
| print(f" ✗ {len(fails)} file(s) still fail 3.11 parse:") |
| for p, ln, msg in fails: |
| print(f" {p.relative_to(root)}:{ln} {msg}") |
| sys.exit(1) |
| print(" ✓ all files compatible with Python 3.11") |
|
|
|
|
| def main() -> None: |
| root = _locate_lerobot() |
| patch(root) |
| verify(root) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|