diff --git a/.gitignore b/.gitignore index 878769d3b9..444093408d 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,38 @@ results/ /thread_monitor_report.csv # Memory autorecord + +# symlink one of .envrc.* if you'd like to use +.envrc +.claude +.opencode/ +**/CLAUDE.md +.direnv/ +.omo/ + +/logs + +*.so + +/.mypy_cache* + +*mobileclip* +/results +results/ +**/cpp/result + +CLAUDE.MD +/assets/teleop_certs/ + +/.mcp.json +*.speedscope.json + +# Coverage +htmlcov/ +.coverage +.coverage.* + +# Memory2 autorecord recording*.db # Rerun recordings diff --git a/CONTEXT.md b/CONTEXT.md index 514a190138..0b7eb27ba3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -43,3 +43,33 @@ _Avoid_: Teleoperation behavior, solver implementation **IK control context**: The persistent inverse-kinematics state owned by one control-task instance for one robot model, controlled-joint selection, and target-frame selection, including its Pink task stack. Stateful Pink tasks are never shared between control-task instances. _Avoid_: Planning group, teleoperation session + +## Recording + +**Source observation**: +An observation whose publisher call completed successfully. +_Avoid_: Frame, sent message + +**Received observation**: +A source observation emitted by the Recorder's input transport before Recorder scheduling or storage. +_Avoid_: Recorded observation + +**Persisted observation**: +An observation committed to the recording and readable through its configured codec. +_Avoid_: Received observation, saved frame + +**Recording fidelity**: +Exact correspondence between source observations and persisted observations, including membership, order, timestamps, and codec-defined payload content. +_Avoid_: Frame rate, throughput + +**Recorder fidelity**: +Exact correspondence between received observations and persisted observations. Recording fidelity also includes the transport path before the Recorder. +_Avoid_: Recording fidelity + +**Shared loss window**: +A source-time interval in which every recorded data stream is missing observations. +_Avoid_: Freeze, lag spike + +**Tail loss**: +Observations lost between the start of graceful shutdown and the recording's final commit. +_Avoid_: Shared loss window diff --git a/MANIFEST.in b/MANIFEST.in index e562326a07..8da03e3f6f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,6 +9,8 @@ global-exclude .DS_Store # runtime data and must stay out. Add a new extension here when code starts # loading a new data type — never go back to per-package globs. recursive-include dimos *.yaml *.yml *.json *.urdf *.html *.css *.js *.svg *.tcss +include dimos/imitation/policy/lerobot/python/pyproject.toml +include dimos/imitation/policy/lerobot/python/uv.lock # --- Exclusions (must come after the includes above so they win) --- # Test fixtures must never ship. diff --git a/dimos/core/isolated_python_module.py b/dimos/core/isolated_python_module.py index 5243f7e6f9..fa61ccca42 100644 --- a/dimos/core/isolated_python_module.py +++ b/dimos/core/isolated_python_module.py @@ -16,7 +16,6 @@ from __future__ import annotations -import inspect import os from pathlib import Path import pickle @@ -29,6 +28,12 @@ from dimos.core.core import rpc from dimos.core.module import Module from dimos.core.native_module import NativeModule, NativeModuleConfig +from dimos.core.python_native_environment import ( + project_environment_vars, + python_native_project, + uv_run_command, + uv_sync_command, +) from dimos.core.rpc_client import RPCClient from dimos.utils.generic import short_id from dimos.utils.logging_config import setup_logger @@ -92,18 +97,7 @@ def __getattribute__(self, name: str) -> Any: @property def runtime_project(self) -> Path: - source = Path(inspect.getfile(type(self))).resolve() - project = source.parent / "python" - if not project.is_dir(): - raise FileNotFoundError( - f"Isolated Python runtime project is missing: {project}; " - "create a sibling 'python/' directory" - ) - if not (project / "pyproject.toml").is_file(): - raise FileNotFoundError( - f"Isolated Python runtime manifest is missing: {project / 'pyproject.toml'}" - ) - return project + return python_native_project(type(self)) def _uv_command(self, *args: str) -> list[str]: command = ["uv", *args] @@ -112,31 +106,25 @@ def _uv_command(self, *args: str) -> list[str]: return command def _prepare_command(self) -> list[str]: - args = ["sync"] - if (self.runtime_project / "uv.lock").is_file(): - args.append("--frozen") - return self._uv_command(*args) + command = uv_sync_command(self.runtime_project) + return self._uv_command(*command[1:]) def _launch_command(self, handshake_fd: int) -> list[str]: - args = ["run"] - if (self.runtime_project / "uv.lock").is_file(): - args.append("--frozen") - args.extend( - [ - "python", - "-m", - "dimos.core.isolated_python_bootstrap", - "--declaration", - f"{type(self).__module__}:{type(self).__name__}", - "--implementation", - self.implementation, - "--instance-name", - self._new_runtime_name(), - "--handshake-fd", - str(handshake_fd), - ] + command = uv_run_command( + self.runtime_project, + "python", + "-m", + "dimos.core.isolated_python_bootstrap", + "--declaration", + f"{type(self).__module__}:{type(self).__name__}", + "--implementation", + self.implementation, + "--instance-name", + self._new_runtime_name(), + "--handshake-fd", + str(handshake_fd), ) - return self._uv_command(*args) + return self._uv_command(*command[1:]) def _new_runtime_name(self) -> str: public_name = self.config.instance_name or type(self).__name__ @@ -147,6 +135,7 @@ def _runtime_env(self) -> dict[str, str]: env = dict(os.environ) env.pop("VIRTUAL_ENV", None) env.update(self.config.extra_env) + env.update(project_environment_vars(self.runtime_project)) return env def _run_prepare(self) -> None: diff --git a/dimos/core/python_native_environment.py b/dimos/core/python_native_environment.py new file mode 100644 index 0000000000..f0588a7a55 --- /dev/null +++ b/dimos/core/python_native_environment.py @@ -0,0 +1,115 @@ +# Copyright 2026 Dimensional Inc. +# +# 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. + +"""Resolve locked Python-native projects without writing into their package.""" + +from __future__ import annotations + +from collections.abc import Mapping +import hashlib +from importlib.metadata import version +import inspect +from pathlib import Path +import re +from typing import Any + +import dimos +from dimos.constants import CACHE_DIR + +PYTHON_NATIVE_VERSION = "3.12" + + +def require_locked_project(project: Path) -> Path: + """Validate and return a Python-native project shipped with DimOS.""" + project = project.resolve() + manifest = project / "pyproject.toml" + lock = project / "uv.lock" + if not project.is_dir(): + raise FileNotFoundError(f"Python-native runtime project is missing: {project}") + if not manifest.is_file(): + raise FileNotFoundError(f"Python-native runtime manifest is missing: {manifest}") + if not lock.is_file(): + raise FileNotFoundError(f"Python-native runtime lock is missing: {lock}") + return project + + +def python_native_project(module_class: type[Any]) -> Path: + """Return the locked sibling project for a Python-native module contract.""" + source = Path(inspect.getfile(module_class)).resolve() + project = source.parent / "python" + try: + return require_locked_project(project) + except FileNotFoundError as error: + if not project.is_dir(): + raise FileNotFoundError( + f"Python-native runtime project is missing: {project}; " + "create a sibling 'python/' directory" + ) from error + raise + + +def dimos_overlay_args() -> list[str]: + """Overlay the running checkout or exact installed DimOS release.""" + package_root = Path(dimos.__file__).resolve().parent + source_root = package_root.parent + if (source_root / "pyproject.toml").is_file() and (source_root / ".git").exists(): + return ["--with-editable", str(source_root)] + return ["--with", f"dimos=={version('dimos')}"] + + +def project_environment(project: Path) -> Path: + """Return a writable, lock-versioned virtualenv location for ``project``.""" + project = require_locked_project(project) + digest = hashlib.sha256((project / "uv.lock").read_bytes()).hexdigest()[:12] + name = re.sub(r"[^a-zA-Z0-9_.-]+", "-", project.parent.name) + return CACHE_DIR / "python-native" / f"{name}-{digest}" + + +def project_environment_vars( + project: Path, extra: Mapping[str, str] | None = None +) -> dict[str, str]: + """Environment additions used by uv for a packaged project.""" + result = dict(extra or {}) + result["UV_PROJECT_ENVIRONMENT"] = str(project_environment(project)) + return result + + +def uv_sync_command(project: Path) -> list[str]: + """Build the command that installs a locked project's third-party stack.""" + project = require_locked_project(project) + return [ + "uv", + "sync", + "--project", + str(project), + "--locked", + "--python", + PYTHON_NATIVE_VERSION, + ] + + +def uv_run_command(project: Path, *command: str) -> list[str]: + """Run inside a locked project with the current DimOS overlaid.""" + project = require_locked_project(project) + return [ + "uv", + "run", + "--project", + str(project), + "--locked", + "--python", + PYTHON_NATIVE_VERSION, + *dimos_overlay_args(), + *command, + ] diff --git a/dimos/core/test_isolated_python_module.py b/dimos/core/test_isolated_python_module.py index df078a5fbd..5fb7e1e5e1 100644 --- a/dimos/core/test_isolated_python_module.py +++ b/dimos/core/test_isolated_python_module.py @@ -17,6 +17,7 @@ import pytest from pytest_mock import MockerFixture +from dimos.constants import CACHE_DIR from dimos.core.core import rpc from dimos.core.isolated_python_module import ( IsolatedPythonModule, @@ -36,7 +37,9 @@ def value(self) -> int: def test_sibling_project_is_required(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: source = tmp_path / "contract.py" source.touch() - monkeypatch.setattr("dimos.core.isolated_python_module.inspect.getfile", lambda _: str(source)) + monkeypatch.setattr( + "dimos.core.python_native_environment.inspect.getfile", lambda _: str(source) + ) module = Contract() try: with pytest.raises(FileNotFoundError, match="sibling 'python/'"): @@ -52,11 +55,19 @@ def test_uv_lock_enables_frozen_commands(tmp_path: Path, monkeypatch: pytest.Mon project.mkdir() (project / "pyproject.toml").touch() (project / "uv.lock").touch() - monkeypatch.setattr("dimos.core.isolated_python_module.inspect.getfile", lambda _: str(source)) + monkeypatch.setattr( + "dimos.core.python_native_environment.inspect.getfile", lambda _: str(source) + ) module = Contract() try: - assert module._prepare_command() == ["uv", "sync", "--frozen"] - assert module._launch_command(7)[:3] == ["uv", "run", "--frozen"] + prepare = module._prepare_command() + launch = module._launch_command(7) + + assert prepare[:2] == ["uv", "sync"] + assert prepare[2:6] == ["--project", str(project), "--locked", "--python"] + assert launch[:2] == ["uv", "run"] + assert ["--project", str(project), "--locked", "--python", "3.12"] == launch[2:7] + assert "--with-editable" in launch or "--with" in launch finally: module.stop() @@ -69,18 +80,37 @@ def test_pixi_supplies_uv_when_manifest_exists( project = tmp_path / "python" project.mkdir() (project / "pyproject.toml").touch() + (project / "uv.lock").touch() (project / "pixi.toml").touch() - monkeypatch.setattr("dimos.core.isolated_python_module.inspect.getfile", lambda _: str(source)) + monkeypatch.setattr( + "dimos.core.python_native_environment.inspect.getfile", lambda _: str(source) + ) module = Contract() try: - assert module._prepare_command() == ["pixi", "run", "--executable", "uv", "sync"] + assert module._prepare_command()[:5] == [ + "pixi", + "run", + "--executable", + "uv", + "sync", + ] finally: module.stop() def test_runtime_environment_uses_sibling_virtualenv( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: + source = tmp_path / "contract.py" + source.touch() + project = tmp_path / "python" + project.mkdir() + (project / "pyproject.toml").touch() + (project / "uv.lock").write_text("locked") + monkeypatch.setattr( + "dimos.core.python_native_environment.inspect.getfile", lambda _: str(source) + ) monkeypatch.setenv("VIRTUAL_ENV", "/parent/.venv") module = Contract(extra_env={"EXAMPLE_SETTING": "configured"}) try: @@ -88,6 +118,7 @@ def test_runtime_environment_uses_sibling_virtualenv( assert "VIRTUAL_ENV" not in env assert env["EXAMPLE_SETTING"] == "configured" + assert Path(env["UV_PROJECT_ENVIRONMENT"]).is_relative_to(CACHE_DIR / "python-native") finally: module.stop() diff --git a/dimos/core/test_python_native_environment.py b/dimos/core/test_python_native_environment.py new file mode 100644 index 0000000000..16622df1c8 --- /dev/null +++ b/dimos/core/test_python_native_environment.py @@ -0,0 +1,78 @@ +# Copyright 2026 Dimensional Inc. +# +# 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. + +from pathlib import Path + +import pytest + +from dimos.core import python_native_environment + + +def test_source_checkout_is_overlaid_editably( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + package = tmp_path / "dimos" + package.mkdir() + (package / "__init__.py").touch() + (tmp_path / "pyproject.toml").touch() + (tmp_path / ".git").touch() + monkeypatch.setattr(python_native_environment.dimos, "__file__", str(package / "__init__.py")) + + assert python_native_environment.dimos_overlay_args() == ["--with-editable", str(tmp_path)] + + +def test_installed_release_is_overlaid_at_exact_version( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + package = tmp_path / "site-packages" / "dimos" + package.mkdir(parents=True) + (package / "__init__.py").touch() + monkeypatch.setattr(python_native_environment.dimos, "__file__", str(package / "__init__.py")) + monkeypatch.setattr(python_native_environment, "version", lambda _: "1.2.3") + + assert python_native_environment.dimos_overlay_args() == ["--with", "dimos==1.2.3"] + + +def test_locked_project_is_required(tmp_path: Path) -> None: + project = tmp_path / "python" + project.mkdir() + (project / "pyproject.toml").touch() + + with pytest.raises(FileNotFoundError, match="runtime lock is missing"): + python_native_environment.require_locked_project(project) + + +def test_python_native_project_resolves_sibling_project( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "contract.py" + source.touch() + project = tmp_path / "python" + project.mkdir() + (project / "pyproject.toml").touch() + (project / "uv.lock").touch() + monkeypatch.setattr(python_native_environment.inspect, "getfile", lambda _: str(source)) + + assert python_native_environment.python_native_project(type("Contract", (), {})) == project + + +def test_python_native_project_requires_sibling_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "contract.py" + source.touch() + monkeypatch.setattr(python_native_environment.inspect, "getfile", lambda _: str(source)) + + with pytest.raises(FileNotFoundError, match="sibling 'python/'"): + python_native_environment.python_native_project(type("Contract", (), {})) diff --git a/dimos/core/transport.py b/dimos/core/transport.py index 378a1d15f5..12c9596b04 100644 --- a/dimos/core/transport.py +++ b/dimos/core/transport.py @@ -196,13 +196,17 @@ def stop(self) -> None: class pSHMTransport(PubSubTransport[T]): _started: bool = False - def __init__(self, topic: str, **kwargs) -> None: # type: ignore[no-untyped-def] + def __init__(self, topic: str, *, queue_size: int = 256, **kwargs: Any) -> None: super().__init__(topic) - self.shm = PickleSharedMemory(**kwargs) + self.shm = PickleSharedMemory(queue_size=queue_size, **kwargs) def __reduce__(self): # type: ignore[no-untyped-def] return ( - functools.partial(pSHMTransport, default_capacity=self.shm.config.default_capacity), + functools.partial( + pSHMTransport, + queue_size=self.shm.queue_size, + default_capacity=self.shm.config.default_capacity, + ), (self.topic,), ) @@ -212,10 +216,19 @@ def broadcast(self, _, msg) -> None: # type: ignore[no-untyped-def] self.shm.publish(self.topic, msg) - def subscribe(self, callback: Callable[[T], None], selfstream: In[T] = None) -> None: # type: ignore[assignment, override] + def subscribe( # type: ignore[override] + self, + callback: Callable[[T], None], + selfstream: In[T] = None, # type: ignore[assignment] + ) -> Callable[[], None]: + if not self._started: + self.start() + return self.shm.subscribe(self.topic, lambda msg, topic: callback(msg)) + + def subscribe_errors(self, callback: Callable[[BaseException], None]) -> Callable[[], None]: if not self._started: self.start() - return self.shm.subscribe(self.topic, lambda msg, topic: callback(msg)) # type: ignore[return-value] + return self.shm.subscribe_errors(self.topic, callback) def start(self) -> None: self.shm.start() diff --git a/dimos/hardware/sensors/camera/module.py b/dimos/hardware/sensors/camera/module.py index fe7bd48792..90bbef5097 100644 --- a/dimos/hardware/sensors/camera/module.py +++ b/dimos/hardware/sensors/camera/module.py @@ -25,7 +25,7 @@ from dimos.core.module import Module, ModuleConfig from dimos.core.stream import Out from dimos.hardware.sensors.camera.spec import CameraHardware -from dimos.hardware.sensors.camera.webcam import Webcam +from dimos.hardware.sensors.camera.webcam import Webcam, WebcamConfig from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.geometry_msgs.Transform import Transform from dimos.msgs.geometry_msgs.Vector3 import Vector3 @@ -48,7 +48,8 @@ def default_transform() -> Transform: class CameraModuleConfig(ModuleConfig): frame_id: str = "camera_link" transform: Transform | None = Field(default_factory=default_transform) - hardware: Callable[[], CameraHardware] | CameraHardware = Webcam + hardware: Callable[[], CameraHardware] | CameraHardware | None = None + webcam: WebcamConfig = Field(default_factory=WebcamConfig) frequency: float = 0.0 # Hz, 0 means no limit @@ -65,7 +66,9 @@ class CameraModule(Module, perception.Camera): def start(self) -> None: super().start() - if callable(self.config.hardware): + if self.config.hardware is None: + self.hardware = Webcam(**self.config.webcam.model_dump()) + elif callable(self.config.hardware): self.hardware = self.config.hardware() else: self.hardware = self.config.hardware diff --git a/dimos/hardware/sensors/camera/webcam.py b/dimos/hardware/sensors/camera/webcam.py index e647f32d32..674765e82b 100644 --- a/dimos/hardware/sensors/camera/webcam.py +++ b/dimos/hardware/sensors/camera/webcam.py @@ -15,9 +15,9 @@ from functools import cache import threading import time -from typing import Literal +from typing import Annotated, Literal -from pydantic import Field +from pydantic import BeforeValidator, Field from reactivex import create from reactivex.observable import Observable @@ -27,8 +27,17 @@ from dimos.utils.reactive import backpressure +def _parse_camera_device(value: object) -> object: + if isinstance(value, str) and value.isdecimal(): + return int(value) + return value + + +CameraDevice = Annotated[int | str, BeforeValidator(_parse_camera_device)] + + class WebcamConfig(CameraConfig): - camera_index: int = 0 # /dev/videoN + camera_index: CameraDevice = 0 # Index or device path such as /dev/v4l/by-id/... width: int = 640 height: int = 480 fps: float = 15.0 diff --git a/dimos/imitation/README.md b/dimos/imitation/README.md index 995f372913..222667f466 100644 --- a/dimos/imitation/README.md +++ b/dimos/imitation/README.md @@ -1,12 +1,17 @@ -# Teleop Data Collection → Dataset +# Imitation Learning -End-to-end: teleoperate an arm, record episodes to a session DB, then convert +Collect demonstrations, build training datasets, and run trained policies in +DimOS. Teleoperation records episodes to a session DB, and DataPrep converts that DB into a LeRobot or HDF5 dataset for imitation learning. ``` teleop (Quest) ─▶ CollectionRecorder ─▶ session__.db ─▶ dimos dataprep ─▶ dataset ``` +After training, use the production +[`LeRobotPolicyModule`](policy/lerobot/README.md) to run a checkpoint against +live camera and joint-state observations. + --- ## 1. Record a session @@ -74,6 +79,12 @@ dimos dataprep build \ # HDF5 instead dimos dataprep build -s -c -f hdf5 + +# Physical OpenYam + Quest + /dev/video0 wrist camera +dimos run learning-collect-quest-openyam --can-port can0 +dimos dataprep build \ + --source \ + --config dimos/imitation/dataprep/openyam_lerobot.json ``` `--source` / `--output` / `--format` override whatever the config specifies, so diff --git a/dimos/imitation/collection/blueprint.py b/dimos/imitation/collection/blueprint.py index 4039e78836..bc6db69d73 100644 --- a/dimos/imitation/collection/blueprint.py +++ b/dimos/imitation/collection/blueprint.py @@ -23,12 +23,17 @@ from datetime import datetime -from dimos.constants import STATE_DIR +from dimos.constants import DEFAULT_CAPACITY_COLOR_IMAGE, STATE_DIR from dimos.core.coordination.blueprints import Blueprint, autoconnect from dimos.core.global_config import global_config +from dimos.core.transport import pSHMTransport +from dimos.hardware.sensors.camera.module import CameraModule from dimos.hardware.sensors.camera.realsense.camera import RealSenseCamera +from dimos.hardware.sensors.camera.webcam import WebcamConfig from dimos.imitation.collection.episode_monitor import EpisodeMonitorModule from dimos.imitation.collection.recorder import CollectionRecorder +from dimos.msgs.sensor_msgs.Image import Image +from dimos.robot.manipulators.openyam.blueprints.teleop import teleop_quest_openyam from dimos.teleop.quest.blueprints import ( teleop_quest_piper, teleop_quest_xarm7, @@ -57,7 +62,6 @@ def _camera_if_real() -> tuple[Blueprint, ...]: learning_collect_quest_xarm7 = autoconnect( CollectionRecorder.blueprint( db_path=_session_db("xarm7"), - poseless_streams=["color_image", "coordinator_joint_state", "status"], record_tf=False, ), EpisodeMonitorModule.blueprint(), # default button_map: toggle=B, discard=Y @@ -69,10 +73,33 @@ def _camera_if_real() -> tuple[Blueprint, ...]: learning_collect_quest_piper = autoconnect( CollectionRecorder.blueprint( db_path=_session_db("piper"), - poseless_streams=["color_image", "coordinator_joint_state", "status"], record_tf=False, ), EpisodeMonitorModule.blueprint(), # default button_map: toggle=B, discard=Y teleop_quest_piper, *_camera_if_real(), ) + + +learning_collect_quest_openyam = autoconnect( + teleop_quest_openyam, + CameraModule.blueprint( + instance_name="WristCamera", + webcam=WebcamConfig( + camera_index=0, + width=640, + height=480, + fps=30.0, + frame_id_prefix="wrist", + ), + frame_id="wrist_camera_link", + ), + EpisodeMonitorModule.blueprint(default_task_label="openyam_task"), + CollectionRecorder.blueprint(db_path=_session_db("openyam")), +).transports( + { + ("color_image", Image): pSHMTransport( + "/color_image", default_capacity=DEFAULT_CAPACITY_COLOR_IMAGE + ) + } +) diff --git a/dimos/imitation/collection/recorder.py b/dimos/imitation/collection/recorder.py index 6379464db4..f444dbe1f3 100644 --- a/dimos/imitation/collection/recorder.py +++ b/dimos/imitation/collection/recorder.py @@ -28,7 +28,6 @@ from dimos.core.stream import In from dimos.imitation.collection.episode_monitor import EpisodeStatus from dimos.memory.module import Recorder, RecorderConfig -from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.sensor_msgs.Image import Image from dimos.msgs.sensor_msgs.JointState import JointState @@ -45,8 +44,3 @@ class CollectionRecorder(Recorder): color_image: In[Image] # observation (camera) coordinator_joint_state: In[JointState] # observation + action (measured/next state) status: In[EpisodeStatus] # episode start/save/discard segmentation - - async def _resolve_pose(self, name: str, msg: object, ts: float) -> Pose | None: - if name in self.config.poseless_streams: - return None - return await super()._resolve_pose(name, msg, ts) diff --git a/dimos/imitation/collection/test_blueprint.py b/dimos/imitation/collection/test_blueprint.py index caa2197b0c..f5d27bc25f 100644 --- a/dimos/imitation/collection/test_blueprint.py +++ b/dimos/imitation/collection/test_blueprint.py @@ -16,14 +16,22 @@ import pytest +from dimos.constants import DEFAULT_CAPACITY_COLOR_IMAGE +from dimos.core.coordination.blueprint_config.parser import BlueprintConfigParser from dimos.core.coordination.blueprints import Blueprint +from dimos.core.transport import pSHMTransport +from dimos.hardware.sensors.camera.module import CameraModule from dimos.imitation.collection.blueprint import ( + learning_collect_quest_openyam, learning_collect_quest_piper, learning_collect_quest_xarm7, ) from dimos.imitation.collection.episode_monitor import EpisodeMonitorModule from dimos.imitation.collection.recorder import CollectionRecorder +from dimos.msgs.sensor_msgs.Image import Image from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.robot.manipulators.openyam.blueprints.teleop import _openyam_quest_hardware +from dimos.robot.manipulators.openyam.config import OPENYAM_JOINTS AGGREGATE = "coordinator_joint_state" @@ -35,11 +43,6 @@ def test_collection_streams_are_poseless(blueprint: Blueprint) -> None: recorder = next(atom for atom in blueprint.blueprints if atom.module is CollectionRecorder) - assert recorder.kwargs["poseless_streams"] == [ - "color_image", - "coordinator_joint_state", - "status", - ] assert recorder.kwargs["record_tf"] is False @@ -69,7 +72,10 @@ def _joint_streams(blueprint: Blueprint) -> dict[tuple[str, str], str]: } -@pytest.mark.parametrize("blueprint", [learning_collect_quest_xarm7, learning_collect_quest_piper]) +@pytest.mark.parametrize( + "blueprint", + [learning_collect_quest_xarm7, learning_collect_quest_piper, learning_collect_quest_openyam], +) def test_recorder_reads_aggregate_joint_state(blueprint: Blueprint) -> None: streams = _joint_streams(blueprint) @@ -77,4 +83,47 @@ def test_recorder_reads_aggregate_joint_state(blueprint: Blueprint) -> None: # atom carries its explicit instance_name (the RPC lookup contract). assert streams[("collectionrecorder", AGGREGATE)] == AGGREGATE assert streams[("ControlCoordinator", AGGREGATE)] == AGGREGATE - assert not [port for _instance, port in streams if port.endswith("_joints")] + + +def test_openyam_collection_has_one_wrist_webcam_and_all_joints() -> None: + camera_atoms = [ + atom + for atom in learning_collect_quest_openyam.active_blueprints + if atom.module is CameraModule + ] + coordinator = next( + atom + for atom in learning_collect_quest_openyam.active_blueprints + if atom.instance_name == "ControlCoordinator" + ) + + assert len(camera_atoms) == 1 + assert camera_atoms[0].instance_name == "WristCamera" + webcam = camera_atoms[0].kwargs["webcam"] + assert webcam.width == 640 + assert webcam.height == 480 + assert webcam.fps == 30.0 + assert "hardware" not in coordinator.kwargs + assert _openyam_quest_hardware(None).joints == OPENYAM_JOINTS + + +def test_openyam_collection_records_wrist_camera_over_shared_memory() -> None: + transport = learning_collect_quest_openyam.transport_map[("color_image", Image)] + + assert isinstance(transport, pSHMTransport) + assert transport.shm.config.default_capacity == DEFAULT_CAPACITY_COLOR_IMAGE + + +@pytest.mark.parametrize( + ("argument", "expected"), + [("2", 2), ("/dev/v4l/by-id/usb-wrist-camera", "/dev/v4l/by-id/usb-wrist-camera")], +) +def test_openyam_wrist_camera_device_is_configurable_from_cli( + argument: str, expected: int | str +) -> None: + parsed = BlueprintConfigParser(learning_collect_quest_openyam).parse( + ["--WristCamera.webcam.camera-index", argument], + environ={}, + ) + + assert parsed.module_kwargs("WristCamera")["webcam"]["camera_index"] == expected diff --git a/dimos/imitation/dataprep/build.py b/dimos/imitation/dataprep/build.py index f3e000184c..66b8dca2a4 100644 --- a/dimos/imitation/dataprep/build.py +++ b/dimos/imitation/dataprep/build.py @@ -23,6 +23,7 @@ from __future__ import annotations from collections.abc import Iterator +from itertools import chain import json from pathlib import Path from typing import Any @@ -32,6 +33,7 @@ Episode, EpisodeExtractor, Sample, + Writer, extract_episodes, get_inspector, get_writer, @@ -75,7 +77,7 @@ def _write_dimos_meta(dataset_path: Path, config: DataPrepConfig, episodes: list json.dump(meta, f, indent=2, default=str) -def run_dataprep(config: DataPrepConfig) -> Path: +def run_dataprep(config: DataPrepConfig, *, writer: Writer | None = None) -> Path: """Build a dataset from a recording and return the dataset path. Opens the source store, extracts episodes, streams samples through the @@ -124,7 +126,7 @@ def run_dataprep(config: DataPrepConfig) -> Path: sorted(action_keys), config.sync.model_dump(), ) - writer = get_writer(config.output.format) + selected_writer = writer or get_writer(config.output.format) # fps drives written timestamps + video rate, so tie it to the resample # rate; an explicit metadata.fps still wins. output = config.output @@ -165,7 +167,22 @@ def _all_samples() -> Iterator[Sample]: produced.append(ep) episodes_done += 1 - dataset_path = Path(writer(_all_samples(), output)) + samples = _all_samples() + try: + first_sample = next(samples) + except StopIteration as error: + recorded_streams = sorted({ref.stream for ref in streams.values()}) + counts = ", ".join( + f"{stream_name}={store.stream(stream_name).count()}" + for stream_name in recorded_streams + ) + raise RuntimeError( + f"No synchronized samples were produced from {total} successful episode(s). " + f"Recorded stream counts: {counts}. Check that every configured stream records " + "data during each episode before adjusting sync tolerance or action_shift." + ) from error + + dataset_path = Path(selected_writer(chain((first_sample,), samples), output)) written = [e.model_copy(update={"id": f"ep_{i:06d}"}) for i, e in enumerate(produced)] _write_dimos_meta(dataset_path, config, written) logger.info( diff --git a/dimos/imitation/dataprep/cli.py b/dimos/imitation/dataprep/cli.py index 9227bebb2a..2d094aacfa 100644 --- a/dimos/imitation/dataprep/cli.py +++ b/dimos/imitation/dataprep/cli.py @@ -67,8 +67,6 @@ def build( output: Path | None, output_format: Literal["lerobot", "hdf5"] | None, ) -> None: - from dimos.imitation.dataprep.build import run_dataprep - cfg = _load_config(config_path, source, output, output_format) if not cfg.source: typer.echo("error: no source given (use --source or set it in --config)", err=True) @@ -82,7 +80,14 @@ def build( raise typer.Exit(2) try: - path = run_dataprep(cfg) + if cfg.output.format == "lerobot": + from dimos.imitation.dataprep.lerobot import run_lerobot_dataprep + + path = run_lerobot_dataprep(cfg) + else: + from dimos.imitation.dataprep.build import run_dataprep + + path = run_dataprep(cfg) except Exception as e: # CLI boundary: any failure becomes a clean message + non-zero exit # instead of a traceback. run_dataprep raises specific errors internally. diff --git a/dimos/imitation/dataprep/core.py b/dimos/imitation/dataprep/core.py index 7db49cfcc1..7525e1a522 100644 --- a/dimos/imitation/dataprep/core.py +++ b/dimos/imitation/dataprep/core.py @@ -389,7 +389,10 @@ def _build_frames() -> Iterator[Sample]: def get_writer(format_name: str) -> Writer: """Lazy-import the format writer's `write` function.""" if format_name == "lerobot": - from dimos.imitation.dataprep.formats.lerobot.writer import write + raise RuntimeError( + "LeRobot conversion requires its isolated environment; " + "run it through `dimos dataprep build`" + ) elif format_name == "hdf5": from dimos.imitation.dataprep.formats.hdf5.writer import write else: diff --git a/dimos/imitation/dataprep/formats/lerobot/reader.py b/dimos/imitation/dataprep/formats/lerobot/reader.py index 68705991a3..ee42329196 100644 --- a/dimos/imitation/dataprep/formats/lerobot/reader.py +++ b/dimos/imitation/dataprep/formats/lerobot/reader.py @@ -25,7 +25,11 @@ from typing import Any from dimos.imitation.dataprep.core import summarize_lengths -from dimos.imitation.dataprep.formats.lerobot.writer import CHUNK, EPISODES_DIR, FILE, META_DIR + +META_DIR = "meta" +EPISODES_DIR = "episodes" +CHUNK = "chunk-000" +FILE = "file-000" _META_COLS = {"timestamp", "frame_index", "episode_index", "index", "task_index"} diff --git a/dimos/imitation/dataprep/formats/lerobot/writer.py b/dimos/imitation/dataprep/formats/lerobot/writer.py deleted file mode 100644 index 4f354ad2fc..0000000000 --- a/dimos/imitation/dataprep/formats/lerobot/writer.py +++ /dev/null @@ -1,453 +0,0 @@ -# Copyright 2026 Dimensional Inc. -# -# 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. - -"""LeRobot v3.0 dataset writer. - -v3.0 differs structurally from v2.x: instead of one parquet + one MP4 *per -episode*, episodes are **concatenated** into shared chunked files, and all -per-episode bookkeeping (frame/byte ranges, video time offsets, per-episode -stats) moves into an episodes *parquet*. - -Layout:: - - / - meta/info.json schema, fps, totals, features - meta/tasks.parquet task strings (indexed by `task`) - meta/stats.json aggregated per-feature stats - meta/episodes/chunk-000/file-000.parquet one row per episode (+ stats) - data/chunk-000/file-000.parquet ALL episodes' frames concatenated - videos//chunk-000/file-000.mp4 ALL episodes for a camera, concatenated - -This writer emits a **single** data file and a single MP4 per camera (chunk -000 / file 000); LeRobot supports multi-file rolling at size limits, which we -don't need yet (logged if a soft limit is exceeded). A frame's `timestamp` is -relative to its episode; the episode's `videos//from_timestamp` gives its -offset inside the shared MP4, so `from_timestamp + timestamp` locates the frame. -""" - -from __future__ import annotations - -from collections.abc import Iterator -import json -from pathlib import Path -from typing import Any - -import numpy as np -from numpy.typing import NDArray - -from dimos.imitation.dataprep.core import DEFAULT_FPS, OutputConfig, Sample, is_image_array -from dimos.imitation.dataprep.formats._stats import StreamingStats, stats_from_metadata -from dimos.utils.logging_config import setup_logger - -logger = setup_logger() - -CHUNK = "chunk-000" -FILE = "file-000" -DATA_DIR = "data" -VIDEO_DIR = "videos" -META_DIR = "meta" -EPISODES_DIR = "episodes" - -# LeRobot defaults; we write a single file but warn past these soft limits. -DATA_FILE_SIZE_MB = 100 -VIDEO_FILE_SIZE_MB = 200 -CHUNKS_SIZE = 1000 - - -def _feature_name( - prefix: str, key: str, is_image: bool, single_action: bool, single_state: bool = False -) -> str: - """Translate (prefix, key) into the LeRobot feature name. - - Canonical names lerobot policies (ACT, Diffusion, π₀) expect: - observation.state single proprio vector - action single action vector - observation.images. per-camera RGB - Multi-key fallbacks: ``observation.`` / ``action.``. - """ - if prefix == "action" and single_action: - return "action" - if is_image: - return f"observation.images.{key}" - if prefix == "observation" and single_state: - return "observation.state" - if prefix == "observation": - return f"observation.{key}" - return f"action.{key}" - - -def _nest_image_stat(vals: list[float]) -> list[list[list[float]]]: - """Per-channel [c0,c1,c2] → shape (C,1,1) [[[c0]],[[c1]],[[c2]]] (lerobot image stats).""" - return [[[float(c)]] for c in vals] - - -def _flatten_episode_stats( - final: dict[str, dict[str, Any]], feature_dtypes: dict[str, str] -) -> dict[str, Any]: - """Flatten a per-episode StreamingStats result into ``stats//`` columns. - - Image features get the (C,1,1) nesting lerobot expects; low-dim stay flat. - """ - out: dict[str, Any] = {} - for feat, entry in final.items(): - is_video = feature_dtypes.get(feat) == "video" - for k in ("mean", "std", "min", "max"): - v = entry.get(k) - if v is None: - continue - out[f"stats/{feat}/{k}"] = _nest_image_stat(v) if is_video else v - out[f"stats/{feat}/count"] = int(entry["count"]) - for q in ("q01", "q99"): - if q in entry: - out[f"stats/{feat}/{q}"] = _nest_image_stat(entry[q]) if is_video else entry[q] - return out - - -class _LeRobotV3Writer: - """Streaming writer for the LeRobot v3.0 on-disk layout. - - One instance per dataset. Drive it as ``append`` per sample, ``flush_episode`` - at each episode boundary (and once at the end), ``close`` to release the - parquet footer + MP4 handles, then ``finalize`` to emit the meta files. State - that the old single-function version threaded through ``nonlocal`` closures - lives here as instance fields, and the writer holds the lazily-imported - pyarrow/pandas/cv2 handles so the meta step needs no module-passing params. - """ - - def __init__(self, output: OutputConfig) -> None: - try: - import cv2 - except ImportError as e: - raise RuntimeError( - "LeRobot writer requires opencv-python (cv2) for MP4 encoding" - ) from e - try: - import pyarrow as pa - import pyarrow.parquet as pq - except ImportError as e: - raise RuntimeError("LeRobot writer requires pyarrow for parquet writes") from e - try: - import pandas as pd - except ImportError as e: - raise RuntimeError("LeRobot writer requires pandas for tasks.parquet") from e - - self._cv2 = cv2 - self._pa = pa - self._pq = pq - self._pd = pd - - self.output = output - self.root = Path(output.path) - (self.root / META_DIR / EPISODES_DIR / CHUNK).mkdir(parents=True, exist_ok=True) - (self.root / DATA_DIR / CHUNK).mkdir(parents=True, exist_ok=True) - - self.fps = float(output.metadata.get("fps", DEFAULT_FPS)) - self._fourcc = cv2.VideoWriter.fourcc(*"mp4v") - self.default_task_label = output.metadata.get("default_task_label", "task") - - self.global_stats = self._new_stats() # aggregated across all frames → meta/stats.json - - # Schema discovery (filled as samples flow). - self.image_keys: list[str] = [] - self.state_keys: list[str] = [] - self.action_keys: list[str] = [] - self.feature_shapes: dict[str, tuple[int, ...]] = {} - self.feature_dtypes: dict[str, str] = {} - - self.tasks_index: dict[str, int] = {} - self.episode_rows: list[dict[str, Any]] = [] - - # Single concatenated data file (opened on first flush). - self.data_path = self.root / DATA_DIR / CHUNK / f"{FILE}.parquet" - self.data_writer: Any = None - - # One MP4 per camera, persisting across episodes; from/to timestamps per episode. - self.video_writers: dict[str, Any] = {} - self.video_cum_frames: dict[str, int] = {} # frames written per camera so far - - self.global_index = 0 - self.episode_index = -1 - - # Per-episode buffers. - self.cur_id: str | None = None - self.cur_rows: list[dict[str, Any]] = [] - self.cur_ep_stats = self._new_stats() - self.cur_task = self.default_task_label # actual label for the in-progress episode - - def _new_stats(self) -> StreamingStats: - return stats_from_metadata(self.output.metadata) - - def _video_path(self, image_key: str) -> Path: - feat = _feature_name("observation", image_key, is_image=True, single_action=False) - d = self.root / VIDEO_DIR / feat / CHUNK - d.mkdir(parents=True, exist_ok=True) - return d / f"{FILE}.mp4" - - def _open_video(self, image_key: str, frame: NDArray[Any]) -> Any: - h, w = frame.shape[:2] - path = self._video_path(image_key) - vw = self._cv2.VideoWriter(str(path), self._fourcc, self.fps, (w, h)) - if not vw.isOpened(): - raise RuntimeError(f"Failed to open VideoWriter for {path}") - return vw - - def append(self, sample: Sample) -> None: - """Ingest one sample: roll over the episode if needed, update schema + - stats, append image frames to the per-camera MP4, and buffer the row.""" - cv2 = self._cv2 - if sample.episode_id != self.cur_id: - self.flush_episode() - self.cur_id = sample.episode_id - self.episode_index += 1 - self.cur_ep_stats = self._new_stats() - # Per-episode task label (falls back to the config default). - self.cur_task = sample.task_label or self.default_task_label - if self.cur_task not in self.tasks_index: - self.tasks_index[self.cur_task] = len(self.tasks_index) - - # Schema discovery + stats (global + per-episode). - n_low_dim_obs = sum( - 1 for v in sample.observation.values() if not is_image_array(np.asarray(v)) - ) - single_state = n_low_dim_obs == 1 - for k, arr in sample.observation.items(): - a = np.asarray(arr) - is_image = is_image_array(a) - name = _feature_name("observation", k, is_image, False, single_state=single_state) - if name not in self.feature_shapes: - self.feature_shapes[name] = tuple(a.shape) - self.feature_dtypes[name] = "video" if is_image else str(a.dtype) - if is_image: - if k not in self.image_keys: - self.image_keys.append(k) - elif k not in self.state_keys: - self.state_keys.append(k) - self.global_stats.update(name, a) - self.cur_ep_stats.update(name, a) - single_action = len(sample.action) == 1 - for k, arr in sample.action.items(): - a = np.asarray(arr) - name = _feature_name("action", k, is_image=False, single_action=single_action) - if name not in self.feature_shapes: - self.feature_shapes[name] = tuple(a.shape) - self.feature_dtypes[name] = str(a.dtype) - if k not in self.action_keys: - self.action_keys.append(k) - self.global_stats.update(name, a) - self.cur_ep_stats.update(name, a) - - # Append image frames to the per-camera MP4 (RGB→BGR; cv2 is BGR-native). - for k, arr in sample.observation.items(): - a = np.asarray(arr) - if is_image_array(a): - if k not in self.video_writers: - self.video_writers[k] = self._open_video(k, a) - if a.ndim == 2: # grayscale → 3-channel BGR for the MP4 - bgr = cv2.cvtColor(a, cv2.COLOR_GRAY2BGR) - elif a.shape[-1] == 3: # RGB → BGR (cv2 is BGR-native) - bgr = cv2.cvtColor(a, cv2.COLOR_RGB2BGR) - else: - bgr = a - self.video_writers[k].write(bgr) - self.video_cum_frames[k] = self.video_cum_frames.get(k, 0) + 1 - - frame_index = len(self.cur_rows) - self.cur_rows.append( - { - "timestamp": frame_index / self.fps, # relative to this episode - "frame_index": frame_index, - "episode_index": self.episode_index, - "index": self.global_index, - "task_index": self.tasks_index[self.cur_task], - "obs": { - k: np.asarray(v) - for k, v in sample.observation.items() - if not is_image_array(np.asarray(v)) - }, - "act": {k: np.asarray(v) for k, v in sample.action.items()}, - } - ) - self.global_index += 1 - - def flush_episode(self) -> None: - """Write the buffered episode's rows to the concatenated data parquet and - append its metadata row. No-op when the buffer is empty.""" - if not self.cur_rows: - return - pa = self._pa - cur_rows = self.cur_rows - length = len(cur_rows) - single_state = len(self.state_keys) == 1 - single_action = len(self.action_keys) == 1 - - cols: dict[str, Any] = { - "timestamp": pa.array([r["timestamp"] for r in cur_rows], pa.float32()), - "frame_index": pa.array([r["frame_index"] for r in cur_rows], pa.int64()), - "episode_index": pa.array([r["episode_index"] for r in cur_rows], pa.int64()), - "index": pa.array([r["index"] for r in cur_rows], pa.int64()), - "task_index": pa.array([r["task_index"] for r in cur_rows], pa.int64()), - } - f32_list = pa.list_(pa.float32()) - for k in self.state_keys: - name = _feature_name("observation", k, False, False, single_state=single_state) - cols[name] = pa.array([r["obs"][k].tolist() for r in cur_rows], type=f32_list) - for k in self.action_keys: - name = _feature_name("action", k, False, single_action=single_action) - cols[name] = pa.array([r["act"][k].tolist() for r in cur_rows], type=f32_list) - table = pa.Table.from_pydict(cols) - if self.data_writer is None: - self.data_writer = self._pq.ParquetWriter( - self.data_path, table.schema, compression="snappy" - ) - self.data_writer.write_table(table) - - # Episode metadata row. - row: dict[str, Any] = { - "episode_index": self.episode_index, - "tasks": [list(self.tasks_index.keys())[cur_rows[0]["task_index"]]], - "length": length, - "data/chunk_index": 0, - "data/file_index": 0, - "dataset_from_index": self.global_index - length, - "dataset_to_index": self.global_index, - "meta/episodes/chunk_index": 0, - "meta/episodes/file_index": 0, - } - for k in self.image_keys: - feat = _feature_name("observation", k, is_image=True, single_action=False) - cum = self.video_cum_frames.get(k, 0) - row[f"videos/{feat}/chunk_index"] = 0 - row[f"videos/{feat}/file_index"] = 0 - row[f"videos/{feat}/from_timestamp"] = (cum - length) / self.fps - row[f"videos/{feat}/to_timestamp"] = cum / self.fps - row.update(_flatten_episode_stats(self.cur_ep_stats.finalize(), self.feature_dtypes)) - self.episode_rows.append(row) - cur_rows.clear() - - def close(self) -> None: - """Release the parquet footer and MP4 handles. Safe to call on partial - writes — without this the data file has no footer and is unreadable.""" - if self.data_writer is not None: - self.data_writer.close() - self.data_writer = None - for vw in self.video_writers.values(): - vw.release() - self.video_writers.clear() - - def finalize(self) -> None: - """Write info.json, tasks.parquet, episodes parquet, and aggregated stats.json.""" - pa, pq, pd = self._pa, self._pq, self._pd - total_episodes = len(self.episode_rows) - total_frames = self.global_index - if self.data_path.exists() and self.data_path.stat().st_size > DATA_FILE_SIZE_MB * 1e6: - logger.warning( - "[dataprep] data file exceeds %d MB (single-file writer, no rolling): %s", - DATA_FILE_SIZE_MB, - self.data_path, - ) - - features: dict[str, Any] = {} - for name, shape in self.feature_shapes.items(): - if self.feature_dtypes[name] == "video": - features[name] = { - "dtype": "video", - "shape": list(shape), - "names": ["height", "width", "channel"], - "info": { - "video.fps": self.fps, - "video.height": int(shape[0]), - "video.width": int(shape[1]), - "video.channels": int(shape[2]) if len(shape) > 2 else 3, - "video.codec": "mp4v", - "video.pix_fmt": "yuv420p", - "video.is_depth_map": False, - "has_audio": False, - }, - } - else: - n = int(shape[0]) if shape else 0 - base = name.split(".")[-1] - features[name] = { - "dtype": self.feature_dtypes[name], - "shape": list(shape), - "names": [f"{base}_{i}" for i in range(n)], - } - for col, dt in [ - ("timestamp", "float32"), - ("frame_index", "int64"), - ("episode_index", "int64"), - ("index", "int64"), - ("task_index", "int64"), - ]: - features[col] = {"dtype": dt, "shape": [1], "names": None} - - info = { - "codebase_version": "v3.0", - "robot_type": self.output.metadata.get("robot", "unknown"), - "total_episodes": total_episodes, - "total_frames": total_frames, - "total_tasks": len(self.tasks_index), - "chunks_size": CHUNKS_SIZE, - "data_files_size_in_mb": DATA_FILE_SIZE_MB, - "video_files_size_in_mb": VIDEO_FILE_SIZE_MB, - "fps": self.fps, - "splits": {"train": f"0:{total_episodes}"}, - "data_path": "data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet", - "video_path": "videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4", - "features": features, - } - with open(self.root / META_DIR / "info.json", "w") as f: - json.dump(info, f, indent=2) - - # tasks.parquet — task strings as the (named) index + a task_index column. - tasks_df = pd.DataFrame( - {"task_index": list(self.tasks_index.values())}, - index=pd.Index(list(self.tasks_index.keys()), name="task"), - ) - tasks_df.to_parquet(self.root / META_DIR / "tasks.parquet") - - # episodes parquet — one row per episode (+ flattened per-episode stats). - ep_table = pa.Table.from_pylist(self.episode_rows) - pq.write_table( - ep_table, - self.root / META_DIR / EPISODES_DIR / CHUNK / f"{FILE}.parquet", - compression="snappy", - ) - - # Aggregated stats.json (image features nested to (C,1,1)). - final_stats = self.global_stats.finalize() - for name, entry in final_stats.items(): - if self.feature_dtypes.get(name) == "video": - for k in ("mean", "std", "min", "max"): - if entry.get(k) is not None: - entry[k] = _nest_image_stat(entry[k]) - with open(self.root / META_DIR / "stats.json", "w") as f: - json.dump(final_stats, f, indent=2) - - -def write(samples: Iterator[Sample], output: OutputConfig) -> Path: - """Drain `samples`, write a LeRobot v3.0 dataset. Returns the dataset root path.""" - writer = _LeRobotV3Writer(output) - # try/finally so the parquet footer is written and MP4s are released even if - # the drain raises mid-stream — otherwise the data file is unreadable (no - # footer) and the videos lose their index. - try: - for sample in samples: - writer.append(sample) - writer.flush_episode() - finally: - writer.close() - - writer.finalize() - return writer.root diff --git a/dimos/imitation/dataprep/formats/test_lerobot.py b/dimos/imitation/dataprep/formats/test_lerobot.py index 1a26b6ade0..9adc76cba8 100644 --- a/dimos/imitation/dataprep/formats/test_lerobot.py +++ b/dimos/imitation/dataprep/formats/test_lerobot.py @@ -12,209 +12,49 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Smoke tests for the LeRobot v3.0 writer/reader. +"""Read-back coverage independent of the isolated LeRobot writer.""" -Asserts the v3.0 layout: a single concatenated data parquet, parquet meta -(tasks + episodes, no jsonl), and one MP4 per camera under -`videos//chunk-000/`. pyarrow/pandas (the `learning` extra) and cv2 are -test dependencies, so these always run. -""" - -from __future__ import annotations - -from collections.abc import Iterator import json from pathlib import Path -import numpy as np -import pandas as pd +import pyarrow as pa import pyarrow.parquet as pq -import pytest -from dimos.imitation.dataprep.core import OutputConfig, Sample from dimos.imitation.dataprep.formats.lerobot.reader import inspect -from dimos.imitation.dataprep.formats.lerobot.writer import write -def _state_samples(n: int = 4) -> Iterator[Sample]: - for i in range(n): - yield Sample( - ts=float(i), - episode_id="ep_000000", - observation={"state": np.arange(6, dtype=np.float32)}, - action={"action": np.full(6, float(i), dtype=np.float32)}, +def test_inspect_native_lerobot_metadata(tmp_path: Path) -> None: + root = tmp_path / "dataset" + episodes = root / "meta" / "episodes" / "chunk-000" + episodes.mkdir(parents=True) + (root / "meta" / "info.json").write_text( + json.dumps( + { + "codebase_version": "v3.0", + "total_episodes": 2, + "total_frames": 7, + "fps": 15, + "robot_type": "openyam", + "features": { + "observation.images.wrist": {"dtype": "video", "shape": [8, 8, 3]}, + "observation.state": {"dtype": "float32", "shape": [7]}, + "action": {"dtype": "float32", "shape": [7]}, + }, + } ) - - -def _two_episode_samples() -> Iterator[Sample]: - for ep in range(2): - for i in range(3): - yield Sample( - ts=float(ep * 3 + i), - episode_id=f"ep_{ep:06d}", - observation={"state": np.arange(6, dtype=np.float32) + ep}, - action={"action": np.full(6, float(i), dtype=np.float32)}, - ) - - -def _image_samples(n: int = 4) -> Iterator[Sample]: - for i in range(n): - yield Sample( - ts=float(i), - episode_id="ep_000000", - observation={ - "state": np.arange(6, dtype=np.float32), - "cam": np.full((16, 16, 3), i, dtype=np.uint8), - }, - action={"action": np.zeros(6, dtype=np.float32)}, - ) - - -def test_lerobot_v3_state_only_layout_and_naming(tmp_path: Path) -> None: - out = OutputConfig( - format="lerobot", path=tmp_path / "ds", metadata={"fps": 10.0, "robot": "xarm7"} - ) - root = write(_state_samples(), out) - - # v3.0: concatenated single data file + parquet meta (no jsonl, no per-episode parquet) - assert (root / "meta" / "info.json").exists() - assert (root / "meta" / "tasks.parquet").exists() - assert (root / "meta" / "stats.json").exists() - assert (root / "meta" / "episodes" / "chunk-000" / "file-000.parquet").exists() - assert (root / "data" / "chunk-000" / "file-000.parquet").exists() - assert not (root / "meta" / "episodes.jsonl").exists() - assert not (root / "meta" / "tasks.jsonl").exists() - - info = json.loads((root / "meta" / "info.json").read_text()) - assert info["codebase_version"] == "v3.0" - assert info["total_episodes"] == 1 - assert info["total_frames"] == 4 - assert info["fps"] == 10.0 - assert info["data_path"] == "data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet" - # single low-dim state + single action → canonical names - assert "observation.state" in info["features"] - assert "action" in info["features"] - - -def test_lerobot_v3_episode_metadata_columns(tmp_path: Path) -> None: - out = OutputConfig(format="lerobot", path=tmp_path / "ds", metadata={"fps": 10.0}) - # two episodes so dataset_from/to_index advance - root = write(_two_episode_samples(), out) - ep = pq.read_table(root / "meta" / "episodes" / "chunk-000" / "file-000.parquet") - cols = set(ep.column_names) - for required in ( - "episode_index", - "tasks", - "length", - "dataset_from_index", - "dataset_to_index", - "data/chunk_index", - "data/file_index", - "meta/episodes/chunk_index", - "meta/episodes/file_index", - ): - assert required in cols, f"missing episode column {required}" - # per-episode stats are embedded (flattened) - assert any(c.startswith("stats/observation.state/") for c in cols) - rows = ep.to_pylist() - assert [r["episode_index"] for r in rows] == [0, 1] - assert rows[0]["dataset_from_index"] == 0 and rows[0]["dataset_to_index"] == 3 - assert rows[1]["dataset_from_index"] == 3 and rows[1]["dataset_to_index"] == 6 - - -def test_lerobot_v3_writer_closed_on_midstream_error(tmp_path: Path) -> None: - """If the drain raises after an episode was flushed, the data parquet must - still be readable (footer written by the finally), not a headerless stub.""" - - def bad_samples() -> Iterator[Sample]: - for i in range(3): # episode 0 - yield Sample( - ts=float(i), - episode_id="ep_000000", - observation={"state": np.arange(6, dtype=np.float32)}, - action={"action": np.full(6, float(i), dtype=np.float32)}, - ) - # first frame of episode 1 flushes episode 0 (opens + writes the parquet)… - yield Sample( - ts=3.0, - episode_id="ep_000001", - observation={"state": np.arange(6, dtype=np.float32)}, - action={"action": np.zeros(6, dtype=np.float32)}, - ) - raise RuntimeError("boom mid-stream") # …then blow up before the final flush - - out = OutputConfig(format="lerobot", path=tmp_path / "ds", metadata={"fps": 10.0}) - with pytest.raises(RuntimeError, match="boom"): - write(bad_samples(), out) - - # episode 0's 3 frames were flushed; the file must have a valid footer. - data = tmp_path / "ds" / "data" / "chunk-000" / "file-000.parquet" - assert data.exists() - assert pq.read_table(data).num_rows == 3 # raises ArrowInvalid if footer missing - - -def test_lerobot_v3_per_episode_task_labels(tmp_path: Path) -> None: - """Episodes with distinct task_labels must produce distinct tasks + task_index - (multi-task recordings must not collapse to one task).""" - - def samples() -> Iterator[Sample]: - for ep, task in ((0, "pick"), (1, "place")): - for i in range(3): - yield Sample( - ts=float(ep * 3 + i), - episode_id=f"ep_{ep:06d}", - observation={"state": np.arange(6, dtype=np.float32)}, - action={"action": np.zeros(6, dtype=np.float32)}, - task_label=task, - ) - - out = OutputConfig(format="lerobot", path=tmp_path / "ds", metadata={"fps": 10.0}) - root = write(samples(), out) - - tasks = pd.read_parquet(root / "meta" / "tasks.parquet") - assert set(tasks.index) == {"pick", "place"} - - ep = pq.read_table(root / "meta" / "episodes" / "chunk-000" / "file-000.parquet").to_pylist() - assert ep[0]["tasks"] == ["pick"] - assert ep[1]["tasks"] == ["place"] - - data = pq.read_table(root / "data" / "chunk-000" / "file-000.parquet") - ti = data.column("task_index").to_pylist() - assert ti[:3] == [0, 0, 0] # episode 0 → task 0 (pick) - assert ti[3:] == [1, 1, 1] # episode 1 → task 1 (place) - - -def test_lerobot_v3_inspect_state_only(tmp_path: Path) -> None: - out = OutputConfig(format="lerobot", path=tmp_path / "ds", metadata={"fps": 10.0}) - root = write(_state_samples(), out) - info = inspect(root) - assert info["format"] == "lerobot" - assert info["version"] == "v3.0" - assert info["episodes"] == 1 - assert info["frames"] == 4 - assert "observation.state" in info["observation"] - assert "action" in info["action"] - assert info["has_stats"] is True - - -def test_lerobot_v3_with_images_writes_concatenated_mp4(tmp_path: Path) -> None: - out = OutputConfig(format="lerobot", path=tmp_path / "ds", metadata={"fps": 10.0}) - try: - root = write(_image_samples(), out) - except RuntimeError as e: - if "VideoWriter" in str(e): - pytest.skip(f"no mp4v encoder available in this environment: {e}") - raise - - # v3.0 video path: videos//chunk-000/file-000.mp4 (key before chunk, one per camera) - mp4 = root / "videos" / "observation.images.cam" / "chunk-000" / "file-000.mp4" - assert mp4.exists() and mp4.stat().st_size > 0 - - info = json.loads((root / "meta" / "info.json").read_text()) - assert ( - info["video_path"] == "videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4" ) - assert info["features"]["observation.images.cam"]["dtype"] == "video" - # image column is excluded from parquet; state/action remain - assert "observation.state" in info["features"] - assert info["total_frames"] == 4 + (root / "meta" / "stats.json").write_text("{}") + pq.write_table(pa.table({"length": [3, 4]}), episodes / "file-000.parquet") + + result = inspect(root) + + assert result["robot"] == "openyam" + assert result["episodes"] == 2 + assert result["frames"] == 7 + assert result["episode_lengths"] == { + "min": 3, + "max": 4, + "mean": 3.5, + "uniform": False, + } + assert result["observation"]["observation.images.wrist"]["dtype"] == "video" diff --git a/dimos/imitation/dataprep/lerobot.py b/dimos/imitation/dataprep/lerobot.py new file mode 100644 index 0000000000..7bf0d1e2d0 --- /dev/null +++ b/dimos/imitation/dataprep/lerobot.py @@ -0,0 +1,60 @@ +# Copyright 2026 Dimensional Inc. +# +# 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. + +"""Launch native LeRobot conversion in the policy runtime environment.""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import tempfile + +from dimos.core.python_native_environment import ( + project_environment_vars, + python_native_project, + uv_run_command, +) +from dimos.imitation.dataprep.core import DataPrepConfig +from dimos.imitation.policy.lerobot.module import LeRobotPolicyModule +from dimos.utils.cache import cache_usage_guard + + +def run_lerobot_dataprep(config: DataPrepConfig) -> Path: + """Run conversion under the locked LeRobot dependency stack.""" + project = python_native_project(LeRobotPolicyModule) + command: list[str] + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", encoding="utf-8") as config_file: + config_file.write(config.model_dump_json()) + config_file.flush() + command = uv_run_command( + project, + "python", + "-m", + "dimos_lerobot.dataprep", + config_file.name, + ) + env = dict(os.environ) + env.pop("VIRTUAL_ENV", None) + env.update(project_environment_vars(project)) + try: + with cache_usage_guard(): + result = subprocess.run(command, env=env, check=False) + except FileNotFoundError as error: + raise RuntimeError( + "uv is required for LeRobot conversion; install uv and ensure it is on PATH" + ) from error + if result.returncode: + raise RuntimeError(f"LeRobot conversion exited with status {result.returncode}") + return config.output.path diff --git a/dimos/imitation/dataprep/openyam_lerobot.json b/dimos/imitation/dataprep/openyam_lerobot.json new file mode 100644 index 0000000000..672939562d --- /dev/null +++ b/dimos/imitation/dataprep/openyam_lerobot.json @@ -0,0 +1,47 @@ +{ + "source": "data/recordings/session_openyam.db", + "episodes": { + "extractor": "episode_status", + "status_stream": "status" + }, + "observation": { + "wrist": { + "stream": "color_image", + "field": "data" + }, + "joint_state": { + "stream": "coordinator_joint_state", + "field": "position" + } + }, + "action": { + "joint_target": { + "stream": "coordinator_joint_state", + "field": "position" + } + }, + "sync": { + "anchor": "wrist", + "rate_hz": 15, + "tolerance_ms": 80, + "action_shift": 1 + }, + "output": { + "format": "lerobot", + "path": "data/datasets/openyam", + "metadata": { + "repo_id": "local/openyam-wrist", + "robot_type": "openyam", + "default_task_label": "openyam_task", + "joint_names": [ + "arm/joint1", + "arm/joint2", + "arm/joint3", + "arm/joint4", + "arm/joint5", + "arm/joint6", + "arm/gripper" + ] + } + } +} diff --git a/dimos/imitation/dataprep/test_core.py b/dimos/imitation/dataprep/test_core.py index 5e9492b73b..2746807e0e 100644 --- a/dimos/imitation/dataprep/test_core.py +++ b/dimos/imitation/dataprep/test_core.py @@ -389,3 +389,33 @@ def test_run_dataprep_rejects_shared_obs_action_key() -> None: ) with pytest.raises(ValueError, match="share feature name"): run_dataprep(cfg) + + +def test_run_dataprep_reports_empty_recorded_stream_before_writer(mocker, tmp_path: Path) -> None: + store = mocker.MagicMock() + store.list_streams.return_value = ["color_image", "joint_state", "status"] + stream_counts = {"color_image": 0, "joint_state": 20} + store.stream.side_effect = lambda name: mocker.MagicMock( + count=mocker.Mock(return_value=stream_counts[name]) + ) + mocker.patch("dimos.imitation.dataprep.build.SqliteStore", return_value=store) + mocker.patch( + "dimos.imitation.dataprep.build.extract_episodes", + return_value=[Episode(id="ep_0", start_ts=1.0, end_ts=2.0)], + ) + mocker.patch("dimos.imitation.dataprep.build.iter_episode_samples", return_value=iter(())) + writer = mocker.Mock(return_value=tmp_path) + cfg = DataPrepConfig( + source="recording.db", + observation={ + "wrist": StreamField(stream="color_image", field="data"), + "state": StreamField(stream="joint_state", field="position"), + }, + output=OutputConfig(format="hdf5", path=tmp_path / "dataset.hdf5"), + ) + + with pytest.raises(RuntimeError, match="color_image=0, joint_state=20"): + run_dataprep(cfg, writer=writer) + + writer.assert_not_called() + store.stop.assert_called_once_with() diff --git a/dimos/imitation/dataprep/test_lerobot_cli.py b/dimos/imitation/dataprep/test_lerobot_cli.py new file mode 100644 index 0000000000..b070170ab7 --- /dev/null +++ b/dimos/imitation/dataprep/test_lerobot_cli.py @@ -0,0 +1,64 @@ +# Copyright 2026 Dimensional Inc. +# +# 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. + +from pathlib import Path +import subprocess + +import pytest +import pytest_mock + +from dimos.imitation.dataprep.core import DataPrepConfig, OutputConfig +from dimos.imitation.dataprep.lerobot import run_lerobot_dataprep + + +def test_conversion_uses_packaged_locked_environment( + tmp_path: Path, mocker: pytest_mock.MockerFixture +) -> None: + run = mocker.patch( + "dimos.imitation.dataprep.lerobot.subprocess.run", + return_value=subprocess.CompletedProcess([], 0), + ) + config = DataPrepConfig( + source="recording.db", + output=OutputConfig(format="lerobot", path=tmp_path / "dataset"), + ) + + assert run_lerobot_dataprep(config) == tmp_path / "dataset" + + command = run.call_args.args[0] + assert command[:2] == ["uv", "run"] + assert "--project" in command + assert "--locked" in command + assert command[command.index("--python") + 1] == "3.12" + entrypoint = command.index("dimos_lerobot.dataprep") + assert command[entrypoint - 2 : entrypoint + 1] == [ + "python", + "-m", + "dimos_lerobot.dataprep", + ] + assert run.call_args.kwargs["env"]["UV_PROJECT_ENVIRONMENT"] + + +def test_conversion_reports_missing_uv(tmp_path: Path, mocker: pytest_mock.MockerFixture) -> None: + mocker.patch( + "dimos.imitation.dataprep.lerobot.subprocess.run", + side_effect=FileNotFoundError("uv"), + ) + config = DataPrepConfig( + source="recording.db", + output=OutputConfig(format="lerobot", path=tmp_path / "dataset"), + ) + + with pytest.raises(RuntimeError, match="uv is required"): + run_lerobot_dataprep(config) diff --git a/dimos/imitation/policy/lerobot/README.md b/dimos/imitation/policy/lerobot/README.md new file mode 100644 index 0000000000..36e8a19227 --- /dev/null +++ b/dimos/imitation/policy/lerobot/README.md @@ -0,0 +1,57 @@ +# LeRobot Policy Module + +`LeRobotPolicyModule` runs trained LeRobot policies in a managed Python-native +subprocess. Its LeRobot, Transformers, Torch, and NumPy versions live in the +sibling `python/` project and do not change the main DimOS environment. + +The host contract subscribes to: + +- `color_image: Image` +- `coordinator_joint_state: JointState` + +It publishes `joint_command: JointState` in the configured `joint_names` order. +The receiving coordinator and hardware stack must enforce joint limits and +other actuation safety constraints. + +```python +from dimos.imitation.policy.lerobot.module import ( + LeRobotPolicyConfig, + LeRobotPolicyModule, +) + +policy = LeRobotPolicyModule.blueprint( + policies={ + "pick": LeRobotPolicyConfig( + policy_path="outputs/pick/checkpoints/last/pretrained_model", + task="pick up the object", + ) + }, + joint_names=["arm/joint1", "arm/joint2", "arm/gripper"], + fps=15.0, + robot_type="my_robot", +) +``` + +The module exposes three RPCs: `execute_learned_policy`, +`stop_learned_policy`, and `policy_status`. Checkpoints are loaded lazily on the +first execution. The runtime rejects missing or stale observations, missing +joints, non-finite values, incompatible checkpoint features, and actions with +the wrong dimension. + +Run the hardware-independent process smoke example from the repository root: + +```bash +uv run python examples/native-modules/python_lerobot.py +``` + +The example starts the real isolated runtime and calls `policy_status`, but it +does not load the placeholder checkpoint or publish a command. + +Run isolated runtime checks with: + +```bash +cd dimos/imitation/policy/lerobot/python +uv sync --locked --group tests +uv run --locked --group tests pytest +uv run --locked --group tests mypy +``` diff --git a/dimos/imitation/policy/lerobot/module.py b/dimos/imitation/policy/lerobot/module.py new file mode 100644 index 0000000000..7351cc4717 --- /dev/null +++ b/dimos/imitation/policy/lerobot/module.py @@ -0,0 +1,103 @@ +# Copyright 2026 Dimensional Inc. +# +# 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. + +"""Host contract for isolated LeRobot policy inference.""" + +from typing import TypedDict + +from pydantic import Field, field_validator + +from dimos.core.core import rpc +from dimos.core.python_native_module import PythonNativeModule, PythonNativeModuleConfig +from dimos.core.stream import In, Out +from dimos.msgs.sensor_msgs.Image import Image +from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.protocol.service.spec import BaseConfig + + +class PolicyStatus(TypedDict): + running: bool + observations_ready: bool + observation_error: str | None + active_policy: str | None + policy_path: str | None + available_policies: list[str] + task: str + commands_sent: int + last_error: str | None + + +class LeRobotPolicyConfig(BaseConfig): + """Configuration for one named learned policy.""" + + policy_path: str + task: str = "" + device: str | None = None + default_duration: float = Field(default=10.0, gt=0) + + +class LeRobotPolicyModuleConfig(PythonNativeModuleConfig): + """Configuration shared by the host contract and isolated runtime.""" + + policies: dict[str, LeRobotPolicyConfig] = Field(min_length=1) + joint_names: list[str] = Field(min_length=1) + fps: float = Field(default=15.0, gt=0) + robot_type: str = "" + max_observation_age_s: float = Field(default=0.5, gt=0) + + @field_validator("policies") + @classmethod + def policy_names_must_not_be_empty( + cls, policies: dict[str, LeRobotPolicyConfig] + ) -> dict[str, LeRobotPolicyConfig]: + if any(not name.strip() for name in policies): + raise ValueError("policy names must not be empty") + return policies + + @field_validator("joint_names") + @classmethod + def joint_names_must_be_unique(cls, joint_names: list[str]) -> list[str]: + if len(set(joint_names)) != len(joint_names): + raise ValueError("joint_names must not contain duplicates") + return joint_names + + +class LeRobotPolicyModule(PythonNativeModule): + """Convert live image and joint-state observations into joint targets.""" + + implementation = "dimos_lerobot.runtime:LeRobotPolicyRuntime" + config: LeRobotPolicyModuleConfig + + color_image: In[Image] + coordinator_joint_state: In[JointState] + joint_command: Out[JointState] + + @rpc + def execute_learned_policy( + self, + policy_name: str, + duration: float | None = None, + ) -> str: + """Execute a configured learned policy against live camera and robot state.""" + raise NotImplementedError + + @rpc + def stop_learned_policy(self) -> str: + """Stop the running learned policy and hold the last commanded pose.""" + raise NotImplementedError + + @rpc + def policy_status(self) -> PolicyStatus: + """Return live execution status for CLIs and monitoring.""" + raise NotImplementedError diff --git a/dimos/imitation/policy/lerobot/python/.gitignore b/dimos/imitation/policy/lerobot/python/.gitignore new file mode 100644 index 0000000000..1d08ed0591 --- /dev/null +++ b/dimos/imitation/policy/lerobot/python/.gitignore @@ -0,0 +1,4 @@ +.venv/ +.pytest_cache/ +.mypy_cache/ +__pycache__/ diff --git a/dimos/imitation/policy/lerobot/python/.python-version b/dimos/imitation/policy/lerobot/python/.python-version new file mode 100644 index 0000000000..e4fba21835 --- /dev/null +++ b/dimos/imitation/policy/lerobot/python/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/dimos/imitation/policy/lerobot/python/dimos_lerobot/__init__.py b/dimos/imitation/policy/lerobot/python/dimos_lerobot/__init__.py new file mode 100644 index 0000000000..60368e7efc --- /dev/null +++ b/dimos/imitation/policy/lerobot/python/dimos_lerobot/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Dimensional Inc. +# +# 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. + +"""Isolated LeRobot runtime package.""" diff --git a/dimos/imitation/policy/lerobot/python/dimos_lerobot/dataprep.py b/dimos/imitation/policy/lerobot/python/dimos_lerobot/dataprep.py new file mode 100644 index 0000000000..b39373518e --- /dev/null +++ b/dimos/imitation/policy/lerobot/python/dimos_lerobot/dataprep.py @@ -0,0 +1,182 @@ +# Copyright 2026 Dimensional Inc. +# +# 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. + +"""Native LeRobot dataset writer and isolated command entry point.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import suppress +from pathlib import Path +from typing import Annotated, Any, Protocol, cast + +from lerobot.datasets.lerobot_dataset import LeRobotDataset +import numpy as np +from numpy.typing import NDArray +import typer + +from dimos.imitation.dataprep.build import run_dataprep +from dimos.imitation.dataprep.core import DataPrepConfig, OutputConfig, Sample, is_image_array + +_IMAGE_FEATURE = "observation.images.wrist" +_STATE_FEATURE = "observation.state" +_ACTION_FEATURE = "action" + + +class _WritableDataset(Protocol): + root: Path + + def add_frame(self, frame: dict[str, Any]) -> None: ... + + def save_episode(self, *, parallel_encoding: bool = True) -> None: ... + + def clear_episode_buffer(self) -> None: ... + + def finalize(self) -> None: ... + + +def _one_feature( + values: dict[str, NDArray[Any]], *, image: bool, kind: str +) -> tuple[str, NDArray[Any]]: + matches = [(name, value) for name, value in values.items() if is_image_array(value) is image] + if len(matches) != 1: + expected = "image" if image else "low-dimensional" + raise ValueError(f"LeRobot conversion requires exactly one {expected} {kind} feature") + return matches[0] + + +def _joint_names(metadata: dict[str, Any], size: int) -> list[str]: + names = metadata.get("joint_names") + if not isinstance(names, list) or not all(isinstance(name, str) and name for name in names): + raise ValueError("LeRobot output.metadata.joint_names must be a non-empty string list") + if len(names) != size: + raise ValueError(f"joint_names has {len(names)} entries but state has {size}") + return names + + +def _task(sample: Sample, metadata: dict[str, Any]) -> str: + value = sample.task_label or metadata.get("default_task_label") + if not isinstance(value, str) or not value.strip(): + raise ValueError("every LeRobot frame requires an episode or default task label") + return value + + +def write(samples: Iterator[Sample], output: OutputConfig) -> Path: + """Write synchronized DimOS samples through LeRobot's native dataset API.""" + repo_id = output.metadata.get("repo_id") + if not isinstance(repo_id, str) or not repo_id.strip(): + raise ValueError("LeRobot output.metadata.repo_id is required") + fps_value = output.metadata.get("fps") + if ( + not isinstance(fps_value, (int, float)) + or isinstance(fps_value, bool) + or fps_value <= 0 + or not float(fps_value).is_integer() + ): + raise ValueError("LeRobot output.metadata.fps must be a positive integer") + fps = int(fps_value) + + iterator = iter(samples) + try: + first = next(iterator) + except StopIteration as error: + raise ValueError("cannot create a LeRobot dataset without samples") from error + + image_key, image = _one_feature(first.observation, image=True, kind="observation") + state_key, state = _one_feature(first.observation, image=False, kind="observation") + action_key, action = _one_feature(first.action, image=False, kind="action") + state = np.asarray(state).reshape(-1) + action = np.asarray(action).reshape(-1) + if state.shape != action.shape: + raise ValueError(f"state shape {state.shape} does not match action shape {action.shape}") + if image.ndim != 3 or image.shape[2] not in (1, 3, 4): + raise ValueError(f"wrist image must have HWC shape, got {image.shape}") + names = _joint_names(output.metadata, state.size) + shape = tuple(int(value) for value in image.shape) + features = { + _IMAGE_FEATURE: { + "dtype": "video", + "shape": shape, + "names": ["height", "width", "channels"], + }, + _STATE_FEATURE: {"dtype": "float32", "shape": state.shape, "names": names}, + _ACTION_FEATURE: {"dtype": "float32", "shape": action.shape, "names": names}, + } + dataset = cast( + "_WritableDataset", + LeRobotDataset.create( + repo_id=repo_id, + fps=fps, + features=features, + root=output.path, + robot_type=output.metadata.get("robot_type"), + use_videos=True, + ), + ) + current_episode: str | None = None + finished: set[str] = set() + + def add(sample: Sample) -> None: + nonlocal current_episode + if current_episode is not None and sample.episode_id != current_episode: + dataset.save_episode(parallel_encoding=False) + finished.add(current_episode) + if sample.episode_id in finished: + raise ValueError(f"episode {sample.episode_id!r} is not contiguous") + current_episode = sample.episode_id + sample_image = np.asarray(sample.observation[image_key]) + sample_state = np.asarray(sample.observation[state_key], dtype=np.float32).reshape(-1) + sample_action = np.asarray(sample.action[action_key], dtype=np.float32).reshape(-1) + if sample_image.shape != shape: + raise ValueError(f"wrist image shape changed from {shape} to {sample_image.shape}") + if sample_state.shape != state.shape or sample_action.shape != action.shape: + raise ValueError("state or action shape changed during conversion") + dataset.add_frame( + { + _IMAGE_FEATURE: sample_image.astype(np.uint8, copy=False), + _STATE_FEATURE: sample_state, + _ACTION_FEATURE: sample_action, + "task": _task(sample, output.metadata), + } + ) + + try: + add(first) + for sample in iterator: + add(sample) + dataset.save_episode(parallel_encoding=False) + dataset.finalize() + except BaseException: + with suppress(Exception): + dataset.clear_episode_buffer() + with suppress(Exception): + dataset.finalize() + raise + return Path(dataset.root) + + +def main( + config_path: Annotated[ + Path, + typer.Argument(help="JSON DataPrepConfig for the DimOS recording"), + ], +) -> None: + """Convert a DimOS recording to a LeRobot dataset.""" + config = DataPrepConfig.model_validate_json(config_path.read_text()) + path = run_dataprep(config, writer=write) + typer.echo(path) + + +if __name__ == "__main__": + typer.run(main) diff --git a/dimos/imitation/policy/lerobot/python/dimos_lerobot/runtime.py b/dimos/imitation/policy/lerobot/python/dimos_lerobot/runtime.py new file mode 100644 index 0000000000..ec3b18c59b --- /dev/null +++ b/dimos/imitation/policy/lerobot/python/dimos_lerobot/runtime.py @@ -0,0 +1,387 @@ +# Copyright 2026 Dimensional Inc. +# +# 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. + +"""Run trained LeRobot policies in an isolated Python environment.""" + +from __future__ import annotations + +from contextlib import nullcontext +from dataclasses import dataclass +from threading import Event, RLock, Thread, current_thread +import time +from typing import Any, Protocol, cast + +from lerobot.configs.policies import PreTrainedConfig +from lerobot.policies.factory import get_policy_class, make_pre_post_processors +from lerobot.policies.pretrained import PreTrainedPolicy +from lerobot.policies.utils import prepare_observation_for_inference +from lerobot.processor import PolicyProcessorPipeline +from lerobot.utils.import_utils import register_third_party_plugins +import numpy as np +from numpy.typing import NDArray +from reactivex.disposable import Disposable +import torch +from torch import Tensor + +from dimos.constants import DEFAULT_THREAD_JOIN_TIMEOUT +from dimos.core.core import rpc +from dimos.imitation.policy.lerobot.module import ( + LeRobotPolicyConfig, + LeRobotPolicyModule, + PolicyStatus, +) +from dimos.msgs.sensor_msgs.Image import Image, ImageFormat +from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + +_IMAGE_FEATURE = "observation.images.image" +_STATE_FEATURE = "observation.state" +_ACTION_FEATURE = "action" + +RawObservation = dict[str, NDArray[np.uint8] | NDArray[np.float32]] +PreparedObservation = dict[str, Tensor | str] +PolicyBatch = dict[str, Tensor] + + +class _Resettable(Protocol): + def reset(self) -> None: ... + + +@dataclass(frozen=True) +class _LoadedPolicy: + policy: PreTrainedPolicy + device: torch.device + preprocessor: PolicyProcessorPipeline[PreparedObservation, PreparedObservation] + postprocessor: PolicyProcessorPipeline[Tensor, Tensor] + use_amp: bool + + +class LeRobotPolicyRuntime(LeRobotPolicyModule): + """Concrete LeRobot implementation loaded by ``LeRobotPolicyModule``.""" + + _lock: RLock + _loaded_policies: dict[str, _LoadedPolicy] + _latest_image: tuple[NDArray[np.uint8], float] | None + _latest_joint_state: JointState | None + _stop_event: Event + _thread: Thread | None + _commands_sent: int + _last_error: str | None + _active_policy_name: str | None + _active_task: str + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._lock = RLock() + self._loaded_policies = {} + self._latest_image = None + self._latest_joint_state = None + self._stop_event = Event() + self._thread = None + self._commands_sent = 0 + self._last_error = None + self._active_policy_name = None + self._active_task = "" + + @rpc + def start(self) -> None: + super().start() + self.register_disposable(Disposable(self.color_image.subscribe(self._on_color_image))) + self.register_disposable( + Disposable(self.coordinator_joint_state.subscribe(self._on_joint_state)) + ) + + @rpc + def stop(self) -> None: + self._stop_policy() + super().stop() + + @rpc + def execute_learned_policy( + self, + policy_name: str, + duration: float | None = None, + ) -> str: + policy = self.config.policies.get(policy_name) + if policy is None: + available = ", ".join(sorted(self.config.policies)) + return f"Unknown learned policy {policy_name!r}. Available policies: {available}." + + execution_duration = policy.default_duration if duration is None else duration + if execution_duration <= 0: + return "Duration must be greater than zero." + + try: + with self._lock: + if self._thread is not None and self._thread.is_alive(): + return f"Learned policy {self._active_policy_name!r} is already running." + self._snapshot_observation(time.time()) + loaded_policy = self._loaded_policies.get(policy_name) + + if loaded_policy is None: + newly_loaded_policy = self._load_policy(policy) + with self._lock: + loaded_policy = self._loaded_policies.setdefault( + policy_name, newly_loaded_policy + ) + logger.info( + "Loaded LeRobot policy", + policy=policy_name, + path=policy.policy_path, + ) + + with self._lock: + if self._thread is not None and self._thread.is_alive(): + return f"Learned policy {self._active_policy_name!r} is already running." + self._snapshot_observation(time.time()) + cast("_Resettable", loaded_policy.policy).reset() + cast("_Resettable", loaded_policy.preprocessor).reset() + cast("_Resettable", loaded_policy.postprocessor).reset() + self._stop_event.clear() + self._commands_sent = 0 + self._last_error = None + self._active_policy_name = policy_name + self._active_task = policy.task + self._thread = Thread( + target=self._run_policy, + args=(loaded_policy, execution_duration, policy.task), + name=f"lerobot-policy-{policy_name}", + daemon=True, + ) + self._thread.start() + return ( + f"Learned policy {policy_name!r} started for up to {execution_duration:.1f}s. " + "Use stop_learned_policy to stop early." + ) + except Exception as exc: + with self._lock: + self._last_error = str(exc) + return f"Learned policy did not start: {exc}" + + @rpc + def stop_learned_policy(self) -> str: + was_running = self._stop_policy() + return "Learned policy stopped." if was_running else "Learned policy was not running." + + @rpc + def policy_status(self) -> PolicyStatus: + with self._lock: + running = ( + self._thread is not None + and self._thread.is_alive() + and not self._stop_event.is_set() + ) + observation_error: str | None = None + try: + self._snapshot_observation(time.time()) + except RuntimeError as exc: + observation_error = str(exc) + return { + "running": running, + "observations_ready": observation_error is None, + "observation_error": observation_error, + "active_policy": self._active_policy_name, + "policy_path": ( + self.config.policies[self._active_policy_name].policy_path + if self._active_policy_name is not None + else None + ), + "available_policies": sorted(self.config.policies), + "task": self._active_task, + "commands_sent": self._commands_sent, + "last_error": self._last_error, + } + + def _on_color_image(self, image: Image) -> None: + rgb = image.to_rgb() + if rgb.format != ImageFormat.RGB or rgb.data.dtype != np.uint8: + logger.warning("Ignoring non-uint8 RGB policy image", image=str(image)) + return + if rgb.data.ndim != 3 or rgb.data.shape[2] != 3: + logger.warning("Ignoring policy image with unexpected shape", shape=rgb.data.shape) + return + with self._lock: + self._latest_image = (np.ascontiguousarray(rgb.data), rgb.ts) + + def _on_joint_state(self, state: JointState) -> None: + with self._lock: + self._latest_joint_state = JointState(state) + + def _snapshot_observation(self, now: float) -> tuple[NDArray[np.uint8], NDArray[np.float32]]: + if self._latest_image is None: + raise RuntimeError("no camera image has been received") + if self._latest_joint_state is None: + raise RuntimeError("no coordinator joint state has been received") + + image, image_ts = self._latest_image + state = self._latest_joint_state + max_age = self.config.max_observation_age_s + if now - image_ts > max_age: + raise RuntimeError(f"camera image is stale by {now - image_ts:.2f}s") + if now - state.ts > max_age: + raise RuntimeError(f"joint state is stale by {now - state.ts:.2f}s") + + positions = dict(zip(state.name, state.position, strict=False)) + missing = [name for name in self.config.joint_names if name not in positions] + if missing: + raise RuntimeError(f"joint state is missing configured joints: {missing}") + vector = np.asarray( + [positions[name] for name in self.config.joint_names], + dtype=np.float32, + ) + if not np.all(np.isfinite(vector)): + raise RuntimeError("joint state contains non-finite positions") + return image.copy(), vector + + def _load_policy(self, policy: LeRobotPolicyConfig) -> _LoadedPolicy: + register_third_party_plugins() + policy_config = PreTrainedConfig.from_pretrained(policy.policy_path) + if policy.device is not None: + policy_config.device = policy.device + if policy_config.device is None: + raise RuntimeError("LeRobot did not resolve an inference device") + + self._validate_features(policy_config) + device = torch.device(policy_config.device) + if device.type == "cuda" and not torch.cuda.is_available(): + raise RuntimeError( + f"Policy requested device {policy_config.device!r}, but CUDA is not available" + ) + + policy_class = get_policy_class(policy_config.type) + loaded_policy = policy_class.from_pretrained(policy.policy_path, config=policy_config) + preprocessor, postprocessor = make_pre_post_processors( + policy_cfg=policy_config, + pretrained_path=policy.policy_path, + preprocessor_overrides={"device_processor": {"device": str(device)}}, + ) + return _LoadedPolicy( + policy=loaded_policy, + device=device, + preprocessor=cast( + "PolicyProcessorPipeline[PreparedObservation, PreparedObservation]", preprocessor + ), + postprocessor=postprocessor, + use_amp=bool(policy_config.use_amp), + ) + + def _validate_features(self, policy_config: PreTrainedConfig) -> None: + inputs = policy_config.input_features or {} + outputs = policy_config.output_features or {} + missing = {_IMAGE_FEATURE, _STATE_FEATURE} - set(inputs) + if missing: + raise ValueError( + "Policy is incompatible with the DimOS single-camera runtime; " + f"missing input features: {sorted(missing)}" + ) + if _ACTION_FEATURE not in outputs: + raise ValueError(f"Policy has no {_ACTION_FEATURE!r} output feature") + + state_shape = tuple(inputs[_STATE_FEATURE].shape) + action_shape = tuple(outputs[_ACTION_FEATURE].shape) + joint_count = len(self.config.joint_names) + if not state_shape or state_shape[0] != joint_count: + raise ValueError( + f"Policy state dimension {state_shape} does not match {joint_count} configured joints" + ) + if not action_shape or action_shape[0] != joint_count: + raise ValueError( + f"Policy action dimension {action_shape} does not match {joint_count} configured joints" + ) + + def _predict( + self, + loaded_policy: _LoadedPolicy, + image: NDArray[np.uint8], + state: NDArray[np.float32], + *, + task: str, + ) -> NDArray[np.float32]: + observation: RawObservation = { + _IMAGE_FEATURE: image, + _STATE_FEATURE: state, + } + with ( + torch.inference_mode(), + torch.autocast(device_type="cuda") + if loaded_policy.device.type == "cuda" and loaded_policy.use_amp + else nullcontext(), + ): + prepared = cast( + "PreparedObservation", + prepare_observation_for_inference( + observation, + loaded_policy.device, + task=task, + robot_type=self.config.robot_type, + ), + ) + prepared = loaded_policy.preprocessor(prepared) + action = loaded_policy.policy.select_action(cast("PolicyBatch", prepared)) + action = loaded_policy.postprocessor(action) + return np.asarray(action.squeeze(0).to("cpu").numpy(), dtype=np.float32) + + def _run_policy( + self, + loaded_policy: _LoadedPolicy, + duration: float, + task: str, + ) -> None: + period = 1.0 / self.config.fps + deadline = time.monotonic() + duration + try: + while not self._stop_event.is_set() and time.monotonic() < deadline: + tick_started = time.monotonic() + with self._lock: + image, state = self._snapshot_observation(time.time()) + action = np.asarray( + self._predict(loaded_policy, image, state, task=task), + dtype=np.float32, + ).reshape(-1) + if action.shape != (len(self.config.joint_names),): + raise RuntimeError( + f"policy returned {action.shape}, expected " + f"({len(self.config.joint_names)},)" + ) + if not np.all(np.isfinite(action)): + raise RuntimeError("policy returned non-finite joint targets") + if self._stop_event.is_set() or time.monotonic() >= deadline: + break + + self.joint_command.publish( + JointState( + name=list(self.config.joint_names), + position=action.astype(float).tolist(), + ) + ) + with self._lock: + self._commands_sent += 1 + self._stop_event.wait(max(0.0, period - (time.monotonic() - tick_started))) + except Exception as exc: + with self._lock: + self._last_error = str(exc) + logger.exception("LeRobot policy execution stopped", error=str(exc)) + finally: + self._stop_event.set() + + def _stop_policy(self) -> bool: + with self._lock: + thread = self._thread + was_running = thread is not None and thread.is_alive() + self._stop_event.set() + if thread is not None and thread is not current_thread(): + thread.join(timeout=DEFAULT_THREAD_JOIN_TIMEOUT) + return was_running diff --git a/dimos/imitation/policy/lerobot/python/pyproject.toml b/dimos/imitation/policy/lerobot/python/pyproject.toml new file mode 100644 index 0000000000..7e03f6927e --- /dev/null +++ b/dimos/imitation/policy/lerobot/python/pyproject.toml @@ -0,0 +1,53 @@ +[build-system] +requires = ["setuptools>=70"] +build-backend = "setuptools.build_meta" + +[project] +name = "dimos-lerobot-runtime" +version = "0.1.0" +requires-python = ">=3.12,<3.13" +dependencies = [ + "lerobot[dataset]==0.6.0", + "transformers[torch]>=5.4,<5.6", +] + +[dependency-groups] +tests = [ + "mypy==1.19.0", + "pytest==8.3.5", + "pytest-mock>=3.14", +] + +[tool.uv] +default-groups = [] +override-dependencies = [ + # LeRobot's headless OpenCV wheel owns the same cv2/ tree as DimOS's + # opencv-contrib-python dependency. Contrib is the required superset. + "opencv-python-headless; sys_platform == 'never'", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["dimos_lerobot*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] + +[tool.mypy] +files = ["dimos_lerobot/"] +python_version = "3.12" +strict = true + +[[tool.mypy.overrides]] +module = [ + "lerobot.configs.policies", + "lerobot.datasets.lerobot_dataset", + "lerobot.policies.factory", + "lerobot.policies.pretrained", + "lerobot.policies.utils", + "lerobot.processor", + "lerobot.utils.import_utils", +] +follow_untyped_imports = true +ignore_missing_imports = true diff --git a/dimos/imitation/policy/lerobot/python/tests/test_dataprep.py b/dimos/imitation/policy/lerobot/python/tests/test_dataprep.py new file mode 100644 index 0000000000..b0beb1e1d6 --- /dev/null +++ b/dimos/imitation/policy/lerobot/python/tests/test_dataprep.py @@ -0,0 +1,85 @@ +# Copyright 2026 Dimensional Inc. +# +# 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. + +from collections.abc import Iterator +import json +from pathlib import Path + +from dimos_lerobot.dataprep import write +import numpy as np +import pytest + +from dimos.imitation.dataprep.core import OutputConfig, Sample + +JOINTS = [f"arm/joint{index}" for index in range(1, 7)] + ["arm/gripper"] + + +def samples() -> Iterator[Sample]: + for episode, task in (("first", "pick"), ("second", "place")): + for frame in range(3): + value = float(frame + (10 if episode == "second" else 0)) + yield Sample( + ts=value, + episode_id=episode, + observation={ + "wrist": np.full((64, 64, 3), frame, dtype=np.uint8), + "joints": np.full(7, value, dtype=np.float32), + }, + action={"next_joints": np.full(7, value + 1, dtype=np.float32)}, + task_label=task, + ) + + +def output(path: Path) -> OutputConfig: + return OutputConfig( + format="lerobot", + path=path, + metadata={ + "repo_id": "local/openyam-test", + "fps": 15, + "robot_type": "openyam", + "joint_names": JOINTS, + }, + ) + + +def test_native_writer_creates_canonical_openyam_dataset(tmp_path: Path) -> None: + root = write(samples(), output(tmp_path / "dataset")) + + info = json.loads((root / "meta" / "info.json").read_text()) + assert info["total_episodes"] == 2 + assert info["total_frames"] == 6 + assert info["fps"] == 15 + assert info["robot_type"] == "openyam" + assert set(info["features"]) >= { + "observation.images.wrist", + "observation.state", + "action", + } + assert info["features"]["observation.state"]["names"] == JOINTS + + +def test_native_writer_requires_repo_id(tmp_path: Path) -> None: + config = output(tmp_path / "dataset").model_copy(update={"metadata": {"fps": 15}}) + + with pytest.raises(ValueError, match="repo_id is required"): + write(samples(), config) + + +def test_native_writer_rejects_fractional_fps(tmp_path: Path) -> None: + config = output(tmp_path / "dataset") + config.metadata["fps"] = 14.5 + + with pytest.raises(ValueError, match="positive integer"): + write(samples(), config) diff --git a/dimos/imitation/policy/lerobot/python/tests/test_runtime.py b/dimos/imitation/policy/lerobot/python/tests/test_runtime.py new file mode 100644 index 0000000000..fa4857c1bf --- /dev/null +++ b/dimos/imitation/policy/lerobot/python/tests/test_runtime.py @@ -0,0 +1,391 @@ +# Copyright 2026 Dimensional Inc. +# +# 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. + +"""Behavior tests for the isolated LeRobot runtime.""" + +from collections.abc import Iterator +from threading import Event +import time +from typing import Protocol + +from dimos_lerobot import runtime as policy_runtime +from dimos_lerobot.runtime import LeRobotPolicyRuntime +import numpy as np +from numpy.typing import NDArray +import pytest +import pytest_mock +import torch +from torch import Tensor + +from dimos.imitation.policy.lerobot.module import LeRobotPolicyConfig +from dimos.msgs.sensor_msgs.Image import Image, ImageFormat +from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.protocol.rpc.pubsubrpc import LCMRPC +from dimos.utils.testing.waiting import wait_until + +JOINTS = [f"test_arm/joint{i}" for i in range(1, 5)] + + +class FakeFeature: + def __init__(self, shape: tuple[int, ...]) -> None: + self.shape = shape + + +class FakeUpstreamConfig: + def __init__(self, joint_count: int) -> None: + self.type = "fake_policy" + self.device: str | None = "cpu" + self.use_amp = False + self.input_features = { + "observation.images.image": FakeFeature((3, 4, 5)), + "observation.state": FakeFeature((joint_count,)), + } + self.output_features = {"action": FakeFeature((joint_count,))} + + +class FakePipeline: + def __init__(self) -> None: + self.calls: list[object] = [] + self.reset_count = 0 + + def __call__(self, value: object) -> object: + self.calls.append(value) + return value + + def reset(self) -> None: + self.reset_count += 1 + + +class FakePolicy: + def __init__(self, action: NDArray[np.float32]) -> None: + self.action = torch.from_numpy(action).unsqueeze(0) + self.called = Event() + self.reset_count = 0 + self.batch: dict[str, object] | None = None + self.upstream_config = FakeUpstreamConfig(len(JOINTS)) + self.preprocessor = FakePipeline() + self.postprocessor = FakePipeline() + self.config_load_count = 0 + + def reset(self) -> None: + self.reset_count += 1 + + def select_action(self, batch: dict[str, object]) -> Tensor: + self.batch = dict(batch) + self.called.set() + return self.action + + +class CapturingOutput: + def __init__(self) -> None: + self.messages: list[JointState] = [] + self.published = Event() + + def publish(self, message: JointState) -> None: + self.messages.append(message) + self.published.set() + + +class RuntimeFactory(Protocol): + def __call__( + self, + policies: dict[str, FakePolicy], + *, + devices: dict[str, str] | None = None, + ) -> tuple[LeRobotPolicyRuntime, CapturingOutput]: ... + + +@pytest.fixture +def make_runtime(mocker: pytest_mock.MockerFixture) -> Iterator[RuntimeFactory]: + mocker.patch("dimos.core.module.get_loop", return_value=(mocker.MagicMock(), None)) + mocker.patch.object(LCMRPC, "__init__", return_value=None) + mocker.patch.object(LCMRPC, "serve_module_rpc", return_value=None) + mocker.patch.object(LCMRPC, "start", return_value=None) + mocker.patch.object(LCMRPC, "stop", return_value=None) + built: list[LeRobotPolicyRuntime] = [] + + def _make( + policies: dict[str, FakePolicy], + *, + devices: dict[str, str] | None = None, + ) -> tuple[LeRobotPolicyRuntime, CapturingOutput]: + policy_configs = { + name: LeRobotPolicyConfig( + policy_path=f"checkpoint/{name}", + task=f"task for {name}", + device=(devices or {}).get(name), + ) + for name in policies + } + + def load_config(path: str) -> FakeUpstreamConfig: + policy = policies[path.rsplit("/", maxsplit=1)[-1]] + policy.config_load_count += 1 + return policy.upstream_config + + policy_class = mocker.MagicMock() + policy_class.from_pretrained.side_effect = lambda path, *, config: policies[ + path.rsplit("/", maxsplit=1)[-1] + ] + mocker.patch.object( + policy_runtime.PreTrainedConfig, + "from_pretrained", + side_effect=load_config, + ) + mocker.patch.object(policy_runtime, "get_policy_class", return_value=policy_class) + mocker.patch.object( + policy_runtime, + "make_pre_post_processors", + side_effect=lambda *, pretrained_path, **_kwargs: ( + policies[pretrained_path.rsplit("/", maxsplit=1)[-1]].preprocessor, + policies[pretrained_path.rsplit("/", maxsplit=1)[-1]].postprocessor, + ), + ) + + def prepare_observation( + observation: dict[str, np.ndarray], + _device: torch.device, + *, + task: str, + robot_type: str, + ) -> dict[str, object]: + return { + **{ + name: torch.from_numpy(value).unsqueeze(0) + for name, value in observation.items() + }, + "task": task, + "robot_type": robot_type, + } + + mocker.patch.object( + policy_runtime, + "prepare_observation_for_inference", + side_effect=prepare_observation, + ) + mocker.patch.object(policy_runtime, "register_third_party_plugins") + + module = LeRobotPolicyRuntime( + _python_native_runtime=True, + policies=policy_configs, + joint_names=JOINTS, + fps=50.0, + robot_type="test_arm", + ) + output = CapturingOutput() + mocker.patch.object(module, "joint_command", output) + built.append(module) + return module, output + + yield _make + for module in built: + module.stop() + + +def _provide_observation( + module: LeRobotPolicyRuntime, +) -> tuple[NDArray[np.uint8], list[float]]: + bgr = np.zeros((4, 5, 3), dtype=np.uint8) + bgr[..., 0] = 10 + bgr[..., 1] = 20 + bgr[..., 2] = 30 + positions = [float(i) / 10 for i in range(len(JOINTS))] + now = time.time() + module._on_color_image(Image(data=bgr, format=ImageFormat.BGR, ts=now)) + module._on_joint_state(JointState(ts=now, name=JOINTS, position=positions)) + return bgr, positions + + +def test_policy_uses_direct_lerobot_inference_pipeline(make_runtime: RuntimeFactory) -> None: + action = np.arange(len(JOINTS), dtype=np.float32) / 20 + policy = FakePolicy(action) + module, output = make_runtime({"pick_up_cube": policy}) + bgr, positions = _provide_observation(module) + + result = module.execute_learned_policy("pick_up_cube", duration=1.0) + + assert "started" in result.lower() + assert output.published.wait(1.0), "policy did not publish a command" + assert policy.reset_count == 1 + assert policy.preprocessor.reset_count == 1 + assert policy.postprocessor.reset_count == 1 + assert policy.batch is not None + assert policy.batch["task"] == "task for pick_up_cube" + assert policy.batch["robot_type"] == "test_arm" + image = policy.batch["observation.images.image"] + state = policy.batch["observation.state"] + assert isinstance(image, Tensor) + assert isinstance(state, Tensor) + np.testing.assert_array_equal(image.squeeze(0).numpy(), bgr[..., ::-1]) + np.testing.assert_allclose(state.squeeze(0).numpy(), positions) + assert output.messages[0].name == JOINTS + np.testing.assert_allclose(output.messages[0].position, action) + + +def test_policy_refuses_to_load_without_live_observations(make_runtime: RuntimeFactory) -> None: + policy = FakePolicy(np.zeros(len(JOINTS), dtype=np.float32)) + module, output = make_runtime({"default": policy}) + + result = module.execute_learned_policy("default", duration=1.0) + + assert "no camera image" in result + assert policy.config_load_count == 0 + assert output.messages == [] + assert module.policy_status()["running"] is False + + +def test_policy_refuses_stale_observations(make_runtime: RuntimeFactory) -> None: + policy = FakePolicy(np.zeros(len(JOINTS), dtype=np.float32)) + module, output = make_runtime({"default": policy}) + stale = time.time() - module.config.max_observation_age_s - 1.0 + module._on_color_image( + Image(data=np.zeros((4, 5, 3), dtype=np.uint8), format=ImageFormat.RGB, ts=stale) + ) + module._on_joint_state(JointState(ts=stale, name=JOINTS, position=[0.0] * len(JOINTS))) + + result = module.execute_learned_policy("default", duration=1.0) + + assert "camera image is stale" in result + assert policy.config_load_count == 0 + assert output.messages == [] + + +def test_policy_refuses_incomplete_joint_state(make_runtime: RuntimeFactory) -> None: + policy = FakePolicy(np.zeros(len(JOINTS), dtype=np.float32)) + module, output = make_runtime({"default": policy}) + now = time.time() + module._on_color_image( + Image(data=np.zeros((4, 5, 3), dtype=np.uint8), format=ImageFormat.RGB, ts=now) + ) + module._on_joint_state(JointState(ts=now, name=JOINTS[:-1], position=[0.0] * 3)) + + result = module.execute_learned_policy("default", duration=1.0) + + assert JOINTS[-1] in result + assert policy.config_load_count == 0 + assert output.messages == [] + + +@pytest.mark.parametrize( + ("action", "message"), + [ + (np.zeros(len(JOINTS) - 1, dtype=np.float32), f"expected ({len(JOINTS)},)"), + ( + np.asarray([0.0, 0.0, 0.0, np.nan], dtype=np.float32), + "policy returned non-finite joint targets", + ), + ], +) +def test_invalid_policy_action_stops_without_publishing( + make_runtime: RuntimeFactory, + action: NDArray[np.float32], + message: str, +) -> None: + policy = FakePolicy(action) + module, output = make_runtime({"invalid": policy}) + _provide_observation(module) + + module.execute_learned_policy("invalid", duration=1.0) + + assert policy.called.wait(1.0), "policy was not invoked" + wait_until(lambda: module.policy_status()["running"] is False, timeout=1.0) + assert output.messages == [] + assert message in (module.policy_status()["last_error"] or "") + + +def test_named_policies_load_on_demand_and_are_cached(make_runtime: RuntimeFactory) -> None: + cup = FakePolicy(np.zeros(len(JOINTS), dtype=np.float32)) + plate = FakePolicy(np.zeros(len(JOINTS), dtype=np.float32)) + module, _output = make_runtime({"cup": cup, "plate": plate}) + _provide_observation(module) + + assert "started" in module.execute_learned_policy("cup", duration=1.0).lower() + assert cup.called.wait(1.0) + module.stop_learned_policy() + assert "started" in module.execute_learned_policy("plate", duration=1.0).lower() + assert plate.called.wait(1.0) + module.stop_learned_policy() + assert "started" in module.execute_learned_policy("cup", duration=1.0).lower() + module.stop_learned_policy() + + assert cup.config_load_count == 1 + assert plate.config_load_count == 1 + assert cup.reset_count == 2 + assert module.policy_status()["available_policies"] == ["cup", "plate"] + + +def test_policy_rejects_incompatible_checkpoint_features(make_runtime: RuntimeFactory) -> None: + policy = FakePolicy(np.zeros(len(JOINTS), dtype=np.float32)) + del policy.upstream_config.input_features["observation.images.image"] + module, output = make_runtime({"invalid": policy}) + _provide_observation(module) + + result = module.execute_learned_policy("invalid", duration=1.0) + + assert "missing input features" in result + assert output.messages == [] + + +def test_policy_rejects_unavailable_cuda_device( + make_runtime: RuntimeFactory, + mocker: pytest_mock.MockerFixture, +) -> None: + policy = FakePolicy(np.zeros(len(JOINTS), dtype=np.float32)) + module, output = make_runtime({"default": policy}, devices={"default": "cuda"}) + _provide_observation(module) + mocker.patch.object(policy_runtime.torch.cuda, "is_available", return_value=False) + + result = module.execute_learned_policy("default", duration=1.0) + + assert "CUDA is not available" in result + assert output.messages == [] + + +def test_concurrent_policy_start_is_rejected(make_runtime: RuntimeFactory) -> None: + cup = FakePolicy(np.zeros(len(JOINTS), dtype=np.float32)) + plate = FakePolicy(np.zeros(len(JOINTS), dtype=np.float32)) + module, _output = make_runtime({"cup": cup, "plate": plate}) + _provide_observation(module) + assert "started" in module.execute_learned_policy("cup", duration=1.0).lower() + assert cup.called.wait(1.0) + + result = module.execute_learned_policy("plate", duration=1.0) + + assert "already running" in result.lower() + assert plate.config_load_count == 0 + assert module.stop_learned_policy() == "Learned policy stopped." + + +def test_policy_reports_duration_completion(make_runtime: RuntimeFactory) -> None: + policy = FakePolicy(np.zeros(len(JOINTS), dtype=np.float32)) + module, output = make_runtime({"default": policy}) + _provide_observation(module) + + result = module.execute_learned_policy("default", duration=0.05) + + assert "started" in result.lower() + assert output.published.wait(1.0), "policy did not publish before its deadline" + wait_until(lambda: module.policy_status()["running"] is False, timeout=1.0) + + +def test_unknown_policy_is_rejected_without_loading(make_runtime: RuntimeFactory) -> None: + policy = FakePolicy(np.zeros(len(JOINTS), dtype=np.float32)) + module, output = make_runtime({"cup": policy}) + _provide_observation(module) + + result = module.execute_learned_policy("missing") + + assert "unknown learned policy" in result.lower() + assert policy.config_load_count == 0 + assert output.messages == [] diff --git a/dimos/imitation/policy/lerobot/python/uv.lock b/dimos/imitation/policy/lerobot/python/uv.lock new file mode 100644 index 0000000000..c1643465ee --- /dev/null +++ b/dimos/imitation/policy/lerobot/python/uv.lock @@ -0,0 +1,1542 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')", + "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'arm64' and sys_platform == 'linux')", + "(platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (platform_machine != 'arm64' and sys_platform == 'darwin') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", +] + +[manifest] +overrides = [{ name = "opencv-python-headless", marker = "sys_platform == 'never'" }] + +[[package]] +name = "accelerate" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "safetensors" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/75/94cd5d389649578aca399e5aa822637eec18319a1dadc400ffe2f9a7493f/accelerate-1.14.0.tar.gz", hash = "sha256:41b9c4377a54e0b460a959b0defa1b736e4ca0a2373252d9a539964c2afe3c8d", size = 412167, upload-time = "2026-06-11T13:45:52.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/db/253133d7e7cb40d3af384bb2f5c0b4a2b7fdcffbc95c688cc67a20a3c103/accelerate-1.14.0-py3-none-any.whl", hash = "sha256:e94390c2863b873be18f623f9df48a0d8fe5eff13ea7f1a00092b0a7904888c6", size = 389246, upload-time = "2026-06-11T13:45:50.477Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "av" +version = "15.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/c3/83e6e73d1592bc54436eae0bc61704ae0cff0c3cfbde7b58af9ed67ebb49/av-15.1.0.tar.gz", hash = "sha256:39cda2dc810e11c1938f8cb5759c41d6b630550236b3365790e67a313660ec85", size = 3774192, upload-time = "2025-08-30T04:41:56.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/58/de78b276d20db6ffcd4371283df771721a833ba525a3d57e753d00a9fe79/av-15.1.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:40c5df37f4c354ab8190c6fd68dab7881d112f527906f64ca73da4c252a58cee", size = 21760991, upload-time = "2025-08-30T04:40:00.801Z" }, + { url = "https://files.pythonhosted.org/packages/56/cc/45f85775304ae60b66976360d82ba5b152ad3fd91f9267d5020a51e9a828/av-15.1.0-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:af455ce65ada3d361f80c90c810d9bced4db5655ab9aa513024d6c71c5c476d5", size = 26953097, upload-time = "2025-08-30T04:40:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/2d781e5e71d02fc829487e775ccb1185e72f95340d05f2e84eb57a11e093/av-15.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86226d2474c80c3393fa07a9c366106029ae500716098b72b3ec3f67205524c3", size = 38319710, upload-time = "2025-08-30T04:40:07.701Z" }, + { url = "https://files.pythonhosted.org/packages/ac/13/37737ef2193e83862ccacff23580c39de251da456a1bf0459e762cca273c/av-15.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:11326f197e7001c4ca53a83b2dbc67fd39ddff8cdf62ce6be3b22d9f3f9338bd", size = 39915519, upload-time = "2025-08-30T04:40:11.066Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e8032c7b8f2a4129a03f63f896544f8b7cf068e2db2950326fa2400d5c47/av-15.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a631ea879cc553080ee62874f4284765c42ba08ee0279851a98a85e2ceb3cc8d", size = 40286166, upload-time = "2025-08-30T04:40:14.561Z" }, + { url = "https://files.pythonhosted.org/packages/e2/23/612c0fd809444d04b8387a2dfd942ccc77829507bd78a387ff65a9d98c24/av-15.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8f383949b010c3e731c245f80351d19dc0c08f345e194fc46becb1cb279be3ff", size = 41150592, upload-time = "2025-08-30T04:40:17.951Z" }, + { url = "https://files.pythonhosted.org/packages/15/74/6f8e38a3b0aea5f28e72813672ff45b64615f2c69e6a4a558718c95edb9f/av-15.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d5921aa45f4c1f8c1a8d8185eb347e02aa4c3071278a2e2dd56368d54433d643", size = 31336093, upload-time = "2025-08-30T04:40:21.393Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/31/4971872b3ed8715346231fb6eb4da8fcba65a4143c189db151ee28a2812b/charset_normalizer-3.5.0.tar.gz", hash = "sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e", size = 169295, upload-time = "2026-08-12T14:35:31.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/3c/045ea64ea5a550870dd8ab60b2242870328d53f17d2be593b4f9f3121474/charset_normalizer-3.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:98820e1ceb25c6df7a80c4fd8efa59cb121f99bc7c4c1693ad94a2caff5b311d", size = 343861, upload-time = "2026-08-12T14:32:27.254Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/7502be709db899d5b4801509829188b3a5a10969411da9c846115a5f1b70/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:608553f476fca509537e804c4a71f5eb166ce63b75141f89c2c686ce1aa36956", size = 237550, upload-time = "2026-08-12T14:32:28.385Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ce/66392661375148d9455c17bee25509a54e28c39969e34befa48ec8777936/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6753de11eef42f1c321b26d682957d92c7f7bbce6530f34bbe0f9291dd37cc6f", size = 229673, upload-time = "2026-08-12T14:32:29.668Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/f58c32a8d4ecf55b82ee61ee9aa6a664d4afcd36c72feb4c926fd6fe9af8/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f76dc0a47f94cb9b69d86f01e477f4b0371ca70208b9ccea7e063c41eed9046", size = 260768, upload-time = "2026-08-12T14:32:30.891Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ea/36c90e59a96386174377e855479ec154221ef001e96637e0b23be92489c4/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c387c6bf91b4774e359a48a179e2872b8e8bf741e4fde06ba8d1665eb9a4760a", size = 257880, upload-time = "2026-08-12T14:32:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/23/35/5b85772eb82528ef22ba29487ad544a7049dfd27f35b1a5a55dbc0843048/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14f6904a3cf870abf044df3a8c4924ac6c8ef77e9896586fd37e73ae96cff2af", size = 247547, upload-time = "2026-08-12T14:32:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/b6/14/ba11a99c2a22ab04c2d5383a700b378cb463a78ab15f36444cabc10cd671/charset_normalizer-3.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cce46dd29d73e135e8087b96eb62a4aca6d69391b7f97808c6588ebed3178f3", size = 243326, upload-time = "2026-08-12T14:32:34.794Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/cadba3f2400b45aa1d62a4ae0298bf58a3b30b1158baf15c338c7ce5b601/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b476cdb63df22da2b91837593380be3ddbe406f36c506c1c91d80e7196b66288", size = 238820, upload-time = "2026-08-12T14:32:35.984Z" }, + { url = "https://files.pythonhosted.org/packages/47/41/d5188b9342d75b72c2b05d3ee373f01a691397e770f001ee05e3b37925f5/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1f56ce84b317ef2a59d7d3461891c7597c79247d2192bb8114c68a1a1debfcc0", size = 231661, upload-time = "2026-08-12T14:32:37.191Z" }, + { url = "https://files.pythonhosted.org/packages/13/5f/df38fa972c4e945c3d8cee2bc4e610613af522fd359c7dc7a74c419f0278/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9ce0f885239357379d92fd9a5fddbe20f0e30e0527c29ba69f8e99eeb1304a76", size = 261459, upload-time = "2026-08-12T14:32:38.369Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d7/043ff7720067a3beee05523465ac9c1c846c68b7884930dd483f72ee5ab6/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:96ae7ab5d8155fde927aa0864fbc8ba3cc4fde6d41ab0c7cea9d6012b4978603", size = 242300, upload-time = "2026-08-12T14:32:39.505Z" }, + { url = "https://files.pythonhosted.org/packages/19/f6/33980b7b802a048e546a6d9ad2ea783a6cf6b10a86aaccd10db462d8b913/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bf91921009025e96ce57a03ced6d14604fc3baf0530351638e9504a55da6fa3b", size = 259101, upload-time = "2026-08-12T14:32:41.02Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b7/0d19bde844bff9165377c1da9ef3c4792a4c24bd49b5b7094d9e6f6ab58b/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b2e44e6d42d1a4ff78ccc219a93c5449105d10b16198d1aea581080df8073f9", size = 249246, upload-time = "2026-08-12T14:32:42.47Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cc/2c34fdfaacdf0e96e880ef562cbf80a9b5f8ea97e0dd9e57ba348a9c65cf/charset_normalizer-3.5.0-cp312-cp312-win32.whl", hash = "sha256:deb99535e9bf0bea8e274c6413eb939a21be35a3f492678dba4d5b1f4d70f142", size = 178025, upload-time = "2026-08-12T14:32:43.909Z" }, + { url = "https://files.pythonhosted.org/packages/76/d0/c34dbd1df23bcbdc1b5d2f48256340d72fa747f1eb03924a9d2fa35ed85b/charset_normalizer-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54dd1a66fa4bce0ccaf0db9dde336e49b3eec646dc4c1c0991279369d373a14", size = 200143, upload-time = "2026-08-12T14:32:45.254Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/4e6bf1465d60c3d8f488d5bde140d0cb91cd23ccab0dc2895cc6c6982047/charset_normalizer-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:b8ea208b304587d47931b36481342d20336e0d338ab052f8b4305926482598d6", size = 180046, upload-time = "2026-08-12T14:32:46.545Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f3/7b523d807cb5e73562ef8acf21d39cdb9d704955327362c781bc3478a73d/charset_normalizer-3.5.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5", size = 330840, upload-time = "2026-08-12T14:34:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/f0/de/fc68978fe78ca97063c96d764e41ff92ca639948f319271e0ff450e577a2/charset_normalizer-3.5.0-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3", size = 251862, upload-time = "2026-08-12T14:34:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cb/82b41a0ab7fb1a88065f1d78ad32696ad88ea3fe8e25b8189d08833938de/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3", size = 239484, upload-time = "2026-08-12T14:34:47.869Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0c/19608b631f4538f908098d4a2d56a8f79a665e27cc58e9d90479761a9227/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438", size = 230602, upload-time = "2026-08-12T14:34:49.265Z" }, + { url = "https://files.pythonhosted.org/packages/29/db/f648eb30e14eba301aed61e11672156f137905c1bdbb530151abe8065943/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a", size = 259208, upload-time = "2026-08-12T14:34:50.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e0/ed2c8bdbac484d69614d6993143aeb6cb0f4dd1561c883402517b623c8ef/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539", size = 253659, upload-time = "2026-08-12T14:34:52.11Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/b4907cb9ec5b521d9d024ced13611240b86ef065c2eb15b3ad2334dc9940/charset_normalizer-3.5.0-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706", size = 248821, upload-time = "2026-08-12T14:34:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/12/b2/e2d1abcfbc05822f0030869efb4e9f8a3658e13b4821796d4b62da917327/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26", size = 240271, upload-time = "2026-08-12T14:34:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f9/4ba127ad610542fa3eabfa41c45bf12d357860a815b3566374ec0188e213/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3", size = 232155, upload-time = "2026-08-12T14:34:56.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/68/40613182366d00bd6dbd5f6c84a926cbd120960e038a8269e9ae7d782762/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e", size = 259674, upload-time = "2026-08-12T14:34:57.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/53/4574a14fa4c9de4a6c9f31725354bfa40b67f653e6d594ce1654f9a41b32/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49", size = 246122, upload-time = "2026-08-12T14:34:59.337Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/bd8030d13d92c058ca7b2b9615bbb3169569e144db64d65c149cd45abf5e/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45", size = 255221, upload-time = "2026-08-12T14:35:00.71Z" }, + { url = "https://files.pythonhosted.org/packages/77/9d/10ecd3bcbe2666b3d4d4026c97b48f73990682815db516052a1e8f4a31c5/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8", size = 253450, upload-time = "2026-08-12T14:35:02.217Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/d044c4872c0938a84f87b5027a698c0e61bacfc5c3551a4e749ca9b7bc5c/charset_normalizer-3.5.0-cp37-abi3-win32.whl", hash = "sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516", size = 173594, upload-time = "2026-08-12T14:35:03.845Z" }, + { url = "https://files.pythonhosted.org/packages/10/6b/6046773901f1944b9a89436351529811ee958afc7b774563be9d74a6f0c3/charset_normalizer-3.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74", size = 198959, upload-time = "2026-08-12T14:35:05.187Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/b57708ac92aefc8e8389d51d5178129b81f03196da61ee2c23e687b8178a/charset_normalizer-3.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca", size = 267055, upload-time = "2026-08-12T14:35:06.533Z" }, + { url = "https://files.pythonhosted.org/packages/22/c7/754d09943a616937df61e4ba367c409ded2a987e872972098d51a6fcf73b/charset_normalizer-3.5.0-py3-none-any.whl", hash = "sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea", size = 67943, upload-time = "2026-08-12T14:35:30.363Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + +[[package]] +name = "cmake" +version = "4.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/17/f8f42ae205604319cc36f46d9929bd9bfbd83d3d02d6314c44fa97c42006/cmake-4.1.3.tar.gz", hash = "sha256:89f48ddc2570eb62447e33311cffc6dfeb09631bd0a19423d8a59cec8af030f1", size = 34998, upload-time = "2025-11-19T22:41:27.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/79/1bf4009d7ef16d62e0b92ddb78efeda830ca5903149abf9dc01d270c3d4e/cmake-4.1.3-py3-none-macosx_10_10_universal2.whl", hash = "sha256:3b6b25ce8fecc768881b36a1dfbca0013adac10a299c73e24cf4cbb99e4c37d6", size = 49246088, upload-time = "2025-11-19T22:40:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9d/14e076406388efa2bbea2366ec0bbe85e2536787ebbb374dda792f068222/cmake-4.1.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893eb9c20d8a8bac3d951bbef9a4ce9d5495cd35a08b4e08d76215f5ead5897", size = 30381441, upload-time = "2025-11-19T22:40:31.55Z" }, + { url = "https://files.pythonhosted.org/packages/f7/9e/0f7216dfef03f1cbac0cdf4685da6994559f5ede3452e563335a35d6a6cb/cmake-4.1.3-py3-none-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:487faf892ff5e05084c6a7f229dd9e568d0542b88487386acb42f0cb2f6634b6", size = 30781002, upload-time = "2025-11-19T22:40:35.325Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2e/69d9b1eee7b7c68e9ce53f8449e372151b4967c223ecd43c7083a4dece8d/cmake-4.1.3-py3-none-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3dbddc52f839df0ebc1c6b6915bd78d63d0805137c6f419fbddd587404276c28", size = 32613762, upload-time = "2025-11-19T22:40:39.488Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9b/deac4d6f8cf4adcaa61d7f16d1ec42d41d471bf330ffcdac4d29c83e46a3/cmake-4.1.3-py3-none-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b42e99eb6e976f455f29283dd7583270d611b55c7687b5fe8d022d9ae7c95de5", size = 28577197, upload-time = "2025-11-19T22:40:42.517Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6c/323c40671c6f1b3e02bb4a7404fbe2bf653190a56e63cf4b6a4f06e876bc/cmake-4.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:81f11b72bc59cbe547d9f283487ef0519bf68176edffcdfa1a4dc5a52f292369", size = 29690899, upload-time = "2025-11-19T22:40:45.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/a3/ab7866f55ee11a07aa446ee31b91b8f337f1b702b9546fc7b18e23d0566f/cmake-4.1.3-py3-none-manylinux_2_31_armv7l.whl", hash = "sha256:fd633c4395b1522caedf0b64034d1a48ea0e483f19e9c2985d14ee7152b21593", size = 26522320, upload-time = "2025-11-19T22:40:48.463Z" }, + { url = "https://files.pythonhosted.org/packages/db/21/a99ed3f1192c85d6d565e61c0cd0161f8046afcf0b0951e6492be632f2f2/cmake-4.1.3-py3-none-manylinux_2_35_riscv64.whl", hash = "sha256:ea40a64b8027f2b7fb1684312a2f170e4d0904b7a4f123cd96e7290103bb1ed4", size = 28869263, upload-time = "2025-11-19T22:40:51.618Z" }, + { url = "https://files.pythonhosted.org/packages/13/66/3c32bb2d5e72f00a0861066b29cc6981cbffcf9786f7339317f151a4d4be/cmake-4.1.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e3782d5f82e8960290e50747b1fb5ff8396363a656ad5716a3aedc77334ca94f", size = 41751469, upload-time = "2025-11-19T22:40:54.75Z" }, + { url = "https://files.pythonhosted.org/packages/05/60/922c05d62ba5b422afd211966877673ddceb634e95552893bf9a11cc4e58/cmake-4.1.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:44b011b8374aac8f3d7a7fb319b3c25d54c2fd9342d94a855ae3a64240efe828", size = 35040544, upload-time = "2025-11-19T22:40:57.669Z" }, + { url = "https://files.pythonhosted.org/packages/71/ae/957336b0489f7d3050cd19010585d4ab5ebcdef485292b9baee68ebbeccf/cmake-4.1.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f29e924fd6d1a4f2f731eb743cc687b82063f73f15f0b4fb8e2b8a8211faba8", size = 45811680, upload-time = "2025-11-19T22:41:01.124Z" }, + { url = "https://files.pythonhosted.org/packages/85/0d/41e2ac694b156b249bfaccec071897c46b21deeb4db1ec51d949e7843f4b/cmake-4.1.3-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:466cdce904392f18b201471a3a6429cc12b4d98a166faa3ee0ad4461f3043083", size = 45859079, upload-time = "2025-11-19T22:41:04.694Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/42ca38f001b1f1327c19734e4c0080557a7991db832aacfe4b193ba7743a/cmake-4.1.3-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:d37db26f98ac26f0858cf6a30157a4be83b29cb195afeb640b355b097f1d94d7", size = 39946757, upload-time = "2025-11-19T22:41:08.082Z" }, + { url = "https://files.pythonhosted.org/packages/73/ab/a3965bfce6376894c76e17af095b0e360a9e1a1719e3df1e244ea6d6d893/cmake-4.1.3-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:18e1e2b7b226763017521ba8721c74d1a2a3cd7d1ec8e889b0b869d4e939370b", size = 44016695, upload-time = "2025-11-19T22:41:11.84Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/fa0e8d3c66459a616f0baf9d22933e14137c259f4b62f0dad9c3723cf42d/cmake-4.1.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6966746b25d1e9c8d32c731452e220e84331b5133544f710b21bd228a93812ca", size = 43357408, upload-time = "2025-11-19T22:41:15.302Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8f/5c43c6465af62bb16159de113438365c789c5a69261dad36746aa1ec74b8/cmake-4.1.3-py3-none-win32.whl", hash = "sha256:b1c890af27bb548d0a2c0e1affc81ad180fc17d8dfa9545e0658153446fe7db4", size = 34268275, upload-time = "2025-11-19T22:41:18.733Z" }, + { url = "https://files.pythonhosted.org/packages/c1/51/2bc56a4d8d9c2680913f1a7e0b7a33e48100f336df91176b74dda6dff8b3/cmake-4.1.3-py3-none-win_amd64.whl", hash = "sha256:fd5a2ea9a38c6109036d8c912a7db4df2de241cfbc00b7424ae246494387da80", size = 37545974, upload-time = "2025-11-19T22:41:21.85Z" }, + { url = "https://files.pythonhosted.org/packages/36/a5/ec213d5c228ab7a205abeb51cc23aa1be9b586041c40cdccc157c325822a/cmake-4.1.3-py3-none-win_arm64.whl", hash = "sha256:79bd8f92a3385cc6641949b0274cd10ee9a4f45a2c13840121b68b2e90b5af3a", size = 36337597, upload-time = "2025-11-19T22:41:24.968Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cuda-bindings" +version = "13.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965, upload-time = "2026-05-29T23:11:52.227Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b4/d088047afe39827556df21118cac9ffd20cc3f968c99a7681494d1eb333c/cuda_pathfinder-1.6.0-py3-none-any.whl", hash = "sha256:1503af579d8379c24bdd65528379bc57039b0455be9f5f9686cf8e473a1fce51", size = 54591, upload-time = "2026-07-21T15:03:56.224Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, +] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux'" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "sys_platform == 'linux'" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux'" }, +] +curand = [ + { name = "nvidia-curand", marker = "sys_platform == 'linux'" }, +] +cusolver = [ + { name = "nvidia-cusolver", marker = "sys_platform == 'linux'" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "sys_platform == 'linux'" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux'" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux'" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "sys_platform == 'linux'" }, +] + +[[package]] +name = "datasets" +version = "4.8.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dill" }, + { name = "filelock" }, + { name = "fsspec", extra = ["http"] }, + { name = "httpx" }, + { name = "huggingface-hub" }, + { name = "multiprocess" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pyarrow" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/34/14cd8e76f907f7d4dca2334cfeec9f81d30fd15c25a015f99aaea694eaed/datasets-4.8.5.tar.gz", hash = "sha256:0f0c1c3d56ffff2c93b2f4c63c95bac94f3d7e8621aea2a2a576275233bba772", size = 605649, upload-time = "2026-04-27T15:43:57.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/99/00f3196036501b53032c4b1ab8337a0b978dee832ed276dae3815df4e8b5/datasets-4.8.5-py3-none-any.whl", hash = "sha256:5079900781719c0e063a8efdd2cd95a31ad0c63209178669cd23cf1b926149ff", size = 528973, upload-time = "2026-04-27T15:43:53.702Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[package]] +name = "dimos-lerobot-runtime" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "lerobot", extra = ["dataset"] }, + { name = "transformers", extra = ["torch"] }, +] + +[package.dev-dependencies] +tests = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "lerobot", extras = ["dataset"], specifier = "==0.6.0" }, + { name = "transformers", extras = ["torch"], specifier = ">=5.4,<5.6" }, +] + +[package.metadata.requires-dev] +tests = [ + { name = "mypy", specifier = "==1.19.0" }, + { name = "pytest", specifier = "==8.3.5" }, + { name = "pytest-mock", specifier = ">=3.14" }, +] + +[[package]] +name = "draccus" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "pyyaml" }, + { name = "pyyaml-include" }, + { name = "toml" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/e2/f5012fda17ee5d1eaf3481b6ca3e11dffa5348e5e08ab745538fdc8041bb/draccus-0.10.0.tar.gz", hash = "sha256:8dd08304219becdcd66cd16058ba98e9c3e6b7bfe48ccb9579dae39f8d37ae19", size = 62243, upload-time = "2025-02-05T07:27:48.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/9a/a83083b230d352ee5d205757b74006dbe084448ca45e3bc5ca99215b1e55/draccus-0.10.0-py3-none-any.whl", hash = "sha256:90243418ae0e9271c390a59cafb6acfd37001193696ed36fcc8525f791a83282", size = 71783, upload-time = "2025-02-05T07:27:46.1Z" }, +] + +[[package]] +name = "einops" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/77/850bef8d72ffb9219f0b1aac23fbc1bf7d038ee6ea666f331fa273031aa2/einops-0.8.2.tar.gz", hash = "sha256:609da665570e5e265e27283aab09e7f279ade90c4f01bcfca111f3d3e13f2827", size = 56261, upload-time = "2026-01-26T04:13:17.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/09/f8d8f8f31e4483c10a906437b4ce31bdf3d6d417b73fe33f1a8b59e34228/einops-0.8.2-py3-none-any.whl", hash = "sha256:54058201ac7087911181bfec4af6091bb59380360f069276601256a76af08193", size = 65638, upload-time = "2026-01-26T04:13:18.546Z" }, +] + +[[package]] +name = "farama-notifications" +version = "0.0.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/91/14397890dde30adc4bee6462158933806207bc5dd10d7b4d09d5c33845cf/farama_notifications-0.0.6.tar.gz", hash = "sha256:b19acac4bb41d76e59e03394b5dd165f4761c86fa327f56307a35cbee3b60158", size = 2517, upload-time = "2026-04-24T08:43:57.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/f0/21f81892e4ed10f4ec3ef2e7cf8635fb76e7c0907c55d0da66be50094760/farama_notifications-0.0.6-py3-none-any.whl", hash = "sha256:f84839188efa1ce5bb361c2a84881b2dc2c0d0d7fb661ff00421820170930935", size = 2897, upload-time = "2026-04-24T08:43:56.785Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/64/a02e6765de08964ed371eca577870593245afc9dfac16d037de7c10d18e6/filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f", size = 218135, upload-time = "2026-08-13T16:00:05.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09", size = 98901, upload-time = "2026-08-13T16:00:03.974Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, +] + +[package.optional-dependencies] +http = [ + { name = "aiohttp" }, +] + +[[package]] +name = "gymnasium" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, + { name = "farama-notifications" }, + { name = "numpy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/ff/14b6880d703dfaca204490979d3254ccd280c99550798993319902873658/gymnasium-1.3.0.tar.gz", hash = "sha256:6939e86e835d6b71b6ba6bfd360487420876deafc79bfb7bacba83a7c446bcf3", size = 830646, upload-time = "2026-04-22T13:47:14.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/73/fda6a25f3beeb5e49d74330b44092b9e5a547395ccd478d1103ddcbff1fc/gymnasium-1.3.0-py3-none-any.whl", hash = "sha256:6b8c159a8540dcbcb221722d7efda24d78ebbcbc3bd2ea1c2611aa2a34471fc2", size = 953904, upload-time = "2026-04-22T13:47:12.13Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload-time = "2026-08-03T22:33:05.287Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload-time = "2026-08-03T22:33:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload-time = "2026-08-03T22:33:08.616Z" }, + { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload-time = "2026-08-03T22:33:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/9b/ddf3d02a8681f1b9ce52fda03d755dad6b74c4f8172304c4c8d2975450f9/huggingface_hub-1.27.0.tar.gz", hash = "sha256:c1fed40ea82a6b41b477f5243546549b792ae0a93abcea608cff66089bf8f8df", size = 942668, upload-time = "2026-08-07T12:48:05.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d8/95b735e183957c1f26d94c52977f09d466d55119cbbc1558ea4975e4c216/huggingface_hub-1.27.0-py3-none-any.whl", hash = "sha256:7df6827c2f956c60fbaa64646e979e566db76f619dd0a9729dfb8c5a3eb4f68d", size = 784926, upload-time = "2026-08-07T12:48:02.905Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonlines" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/87/bcda8e46c88d0e34cad2f09ee2d0c7f5957bccdb9791b0b934ec84d84be4/jsonlines-4.0.0.tar.gz", hash = "sha256:0c6d2c09117550c089995247f605ae4cf77dd1533041d366351f6f298822ea74", size = 11359, upload-time = "2023-09-01T12:34:44.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/62/d9ba6323b9202dd2fe166beab8a86d29465c41a0288cbe229fac60c1ab8d/jsonlines-4.0.0-py3-none-any.whl", hash = "sha256:185b334ff2ca5a91362993f42e83588a360cf95ce4b71a73548502bda52a7c55", size = 8701, upload-time = "2023-09-01T12:34:42.563Z" }, +] + +[[package]] +name = "lerobot" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmake" }, + { name = "draccus" }, + { name = "einops" }, + { name = "gymnasium" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "opencv-python-headless", marker = "sys_platform == 'never'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "requests" }, + { name = "safetensors" }, + { name = "setuptools" }, + { name = "termcolor" }, + { name = "torch" }, + { name = "torchvision" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/18/b2999bbb52399d404ddcf05645536d3902bd65575ba0ad6bb7bef990a723/lerobot-0.6.0.tar.gz", hash = "sha256:6cad660816fdb72570ea7345ecb23bf0f74d36324e2d7c816a260d31a0c29186", size = 1367952, upload-time = "2026-07-06T10:42:05.281Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/20/9a96311c19e9d256e65584ca83c49c5782d0f204836e84ceeb420d4d493e/lerobot-0.6.0-py3-none-any.whl", hash = "sha256:b38a564fbc441d98380576863bf68635dde5fc2c42ddc2a39d0486640dc9e9a8", size = 1743768, upload-time = "2026-07-06T10:42:03.165Z" }, +] + +[package.optional-dependencies] +dataset = [ + { name = "av" }, + { name = "datasets" }, + { name = "jsonlines" }, + { name = "pandas" }, + { name = "pyarrow" }, + { name = "torchcodec", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and sys_platform == 'linux') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'arm64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'win32'" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "multiprocess" +version = "0.70.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dill" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/f2/e783ac7f2aeeed14e9e12801f22529cc7e6b7ab80928d6dcce4e9f00922d/multiprocess-0.70.19.tar.gz", hash = "sha256:952021e0e6c55a4a9fe4cd787895b86e239a40e76802a789d6305398d3975897", size = 2079989, upload-time = "2026-01-19T06:47:39.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/45/8004d1e6b9185c1a444d6b55ac5682acf9d98035e54386d967366035a03a/multiprocess-0.70.19-py310-none-any.whl", hash = "sha256:97404393419dcb2a8385910864eedf47a3cadf82c66345b44f036420eb0b5d87", size = 134948, upload-time = "2026-01-19T06:47:32.325Z" }, + { url = "https://files.pythonhosted.org/packages/86/c2/dec9722dc3474c164a0b6bcd9a7ed7da542c98af8cabce05374abab35edd/multiprocess-0.70.19-py311-none-any.whl", hash = "sha256:928851ae7973aea4ce0eaf330bbdafb2e01398a91518d5c8818802845564f45c", size = 144457, upload-time = "2026-01-19T06:47:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl", hash = "sha256:3a56c0e85dd5025161bac5ce138dcac1e49174c7d8e74596537e729fd5c53c28", size = 150281, upload-time = "2026-01-19T06:47:35.037Z" }, + { url = "https://files.pythonhosted.org/packages/7e/82/69e539c4c2027f1e1697e09aaa2449243085a0edf81ae2c6341e84d769b6/multiprocess-0.70.19-py39-none-any.whl", hash = "sha256:0d4b4397ed669d371c81dcd1ef33fd384a44d6c3de1bd0ca7ac06d837720d3c5", size = 133477, upload-time = "2026-01-19T06:47:38.619Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/b5/b58cdc25fadd424552804bf410855d52324183112aa004f0732c5f6324cf/mypy-1.19.0.tar.gz", hash = "sha256:f6b874ca77f733222641e5c46e4711648c4037ea13646fd0cdc814c2eaec2528", size = 3579025, upload-time = "2025-11-28T15:49:01.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/7e/1afa8fb188b876abeaa14460dc4983f909aaacaa4bf5718c00b2c7e0b3d5/mypy-1.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0fb3115cb8fa7c5f887c8a8d81ccdcb94cff334684980d847e5a62e926910e1d", size = 13207728, upload-time = "2025-11-28T15:46:26.463Z" }, + { url = "https://files.pythonhosted.org/packages/b2/13/f103d04962bcbefb1644f5ccb235998b32c337d6c13145ea390b9da47f3e/mypy-1.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3e19e3b897562276bb331074d64c076dbdd3e79213f36eed4e592272dabd760", size = 12202945, upload-time = "2025-11-28T15:48:49.143Z" }, + { url = "https://files.pythonhosted.org/packages/e4/93/a86a5608f74a22284a8ccea8592f6e270b61f95b8588951110ad797c2ddd/mypy-1.19.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9d491295825182fba01b6ffe2c6fe4e5a49dbf4e2bb4d1217b6ced3b4797bc6", size = 12718673, upload-time = "2025-11-28T15:47:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/3d/58/cf08fff9ced0423b858f2a7495001fda28dc058136818ee9dffc31534ea9/mypy-1.19.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6016c52ab209919b46169651b362068f632efcd5eb8ef9d1735f6f86da7853b2", size = 13608336, upload-time = "2025-11-28T15:48:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/64/ed/9c509105c5a6d4b73bb08733102a3ea62c25bc02c51bca85e3134bf912d3/mypy-1.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f188dcf16483b3e59f9278c4ed939ec0254aa8a60e8fc100648d9ab5ee95a431", size = 13833174, upload-time = "2025-11-28T15:45:48.091Z" }, + { url = "https://files.pythonhosted.org/packages/cd/71/01939b66e35c6f8cb3e6fdf0b657f0fd24de2f8ba5e523625c8e72328208/mypy-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:0e3c3d1e1d62e678c339e7ade72746a9e0325de42cd2cccc51616c7b2ed1a018", size = 10112208, upload-time = "2025-11-28T15:46:41.702Z" }, + { url = "https://files.pythonhosted.org/packages/09/0e/fe228ed5aeab470c6f4eb82481837fadb642a5aa95cc8215fd2214822c10/mypy-1.19.0-py3-none-any.whl", hash = "sha256:0c01c99d626380752e527d5ce8e69ffbba2046eb8a060db0329690849cf9b6f9", size = 2469714, upload-time = "2025-11-28T15:45:33.22Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.0.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/a5/fce49e2ae977e0ccc084e5adafceb4f0ac0c8333cb6863501618a7277f67/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c86fc7f7ae36d7528288c5d88098edcb7b02c633d262e7ddbb86b0ad91be5df2", size = 542851226, upload-time = "2025-10-09T08:59:04.818Z" }, + { url = "https://files.pythonhosted.org/packages/e7/44/423ac00af4dd95a5aeb27207e2c0d9b7118702149bf4704c3ddb55bb7429/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:ee8722c1f0145ab246bccb9e452153b5e0515fd094c3678df50b2a0888b8b171", size = 423133236, upload-time = "2025-10-09T08:59:32.536Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.19.0.56" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" }, + { url = "https://files.pythonhosted.org/packages/a3/22/0b4b932655d17a6da1b92fa92ab12844b053bb2ac2475e179ba6f043da1e/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:d20e1734305e9d68889a96e3f35094d733ff1f83932ebe462753973e53a572bf", size = 366066321, upload-time = "2026-02-03T20:44:52.837Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/10/8dcd1175260706a2fc92a16a52e306b71d4c1ea0b0cc4a9484183399818a/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:400c6ed1cf6780fc6efedd64ec9f1345871767e6a1a0a552a1ea0578117ea77c", size = 220791277, upload-time = "2025-08-13T19:22:40.982Z" }, + { url = "https://files.pythonhosted.org/packages/fd/53/43b0d71f4e702fa9733f8b4571fdca50a8813f1e450b656c239beff12315/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:25e30a8a7323935d4ad0340b95a0b69926eee755767e8e0b1cf8dd85b197d3fd", size = 169884119, upload-time = "2025-08-13T19:23:41.967Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.28.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/55/1920646a2e43ffd4fc958536b276197ed740e9e0c54105b4bb3521591fc7/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:01c873ba1626b54caa12272ed228dc5b2781545e0ae8ba3f432a8ef1c6d78643", size = 196561677, upload-time = "2025-11-18T05:49:03.45Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b4/878fefaad5b2bcc6fcf8d474a25e3e3774bc5133e4b58adff4d0bca238bc/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:e4553a30f34195f3fa1da02a6da3d6337d28f2003943aa0a3d247bbc25fefc42", size = 196493177, upload-time = "2025-11-18T05:49:17.677Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "5.0.0.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c", size = 81817738, upload-time = "2026-07-02T07:01:06.039Z" } + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pyarrow" +version = "25.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/e2/9ab15b88cbfac28e16419ce5439ec29234c5172cb8259301b4ba639bdec0/pyarrow-25.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9", size = 35861559, upload-time = "2026-08-10T12:38:02.567Z" }, + { url = "https://files.pythonhosted.org/packages/58/79/a0036dbe1eabe1f73127427342f1d99982584c4a2cde2651d6c93499c6f6/pyarrow-25.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9", size = 37628383, upload-time = "2026-08-10T12:38:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/13/49/d93a57d375f4bf0cf82913dd6bb54acafde83dd993be2282c81ac5616cad/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3", size = 46820190, upload-time = "2026-08-10T12:38:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/60/c9/711ca85d79f1ec98f29a5eae2b051e25b4ecec5de3e3c0e2d5c5dcb15664/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5389cdf79447ed1515c9e31620e6e1e2302249564d603f2ad727d4f6d313e4c3", size = 50102437, upload-time = "2026-08-10T12:38:22.487Z" }, + { url = "https://files.pythonhosted.org/packages/80/53/8fb8359ff17cfb6263a1cf3ebf7caec9fe197de118719e84fcb1d0618026/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d51592cb7561e87877c506113e7adbf1342ab579e6c21f0ef44b8ba41cb74c80", size = 49942424, upload-time = "2026-08-10T12:38:28.755Z" }, + { url = "https://files.pythonhosted.org/packages/e8/83/4e5ae02a9341571b18a6fca380ac7a58ce6ddae7ab3c060208c0a1e79f02/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6109c94d8b9f3b17a041daca16cacb2f651ad8f1ef70a4232c2c0f37a23da2a8", size = 53144206, upload-time = "2026-08-10T12:38:34.862Z" }, + { url = "https://files.pythonhosted.org/packages/65/ee/197cbf47e49f83e6ebeb946a5259a48a638dea27ac774db42fe78022179d/pyarrow-25.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8858d7bfc22e3f51529aeaa4077225029724623e4595dc9eff8c793935c34140", size = 27953934, upload-time = "2026-08-10T12:38:39.808Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytz" +version = "2026.3.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745, upload-time = "2026-07-25T15:12:07.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "pyyaml-include" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/be/2d07ad85e3d593d69640876a8686eae2c533db8cb7bf298d25c421b4d2d5/pyyaml-include-1.4.1.tar.gz", hash = "sha256:1a96e33a99a3e56235f5221273832464025f02ff3d8539309a3bf00dec624471", size = 20592, upload-time = "2024-03-25T14:56:43.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/ca/6a2cc3a73170d10b5af1f1613baa2ed1f8f46f62dd0bfab2bffd2c2fe260/pyyaml_include-1.4.1-py3-none-any.whl", hash = "sha256:323c7f3a19c82fbc4d73abbaab7ef4f793e146a13383866831631b26ccc7fb00", size = 19079, upload-time = "2024-03-25T14:56:41.274Z" }, +] + +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" }, + { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "safetensors" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, +] + +[[package]] +name = "setuptools" +version = "80.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "torch" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/8b/69e3008d78e5cee2b30183340cc425081b78afc5eff3d080daab0adda9aa/torch-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b5866312ee6e52ea625cd211dcb97d6a2cdc1131a5f15cc0d87eec948f6dd34", size = 80606338, upload-time = "2026-03-23T18:11:34.781Z" }, + { url = "https://files.pythonhosted.org/packages/13/16/42e5915ebe4868caa6bac83a8ed59db57f12e9a61b7d749d584776ed53d5/torch-2.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f99924682ef0aa6a4ab3b1b76f40dc6e273fca09f367d15a524266db100a723f", size = 419731115, upload-time = "2026-03-23T18:11:06.944Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c9/82638ef24d7877510f83baf821f5619a61b45568ce21c0a87a91576510aa/torch-2.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0f68f4ac6d95d12e896c3b7a912b5871619542ec54d3649cf48cc1edd4dd2756", size = 530712279, upload-time = "2026-03-23T18:10:31.481Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ff/6756f1c7ee302f6d202120e0f4f05b432b839908f9071157302cedfc5232/torch-2.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:fbf39280699d1b869f55eac536deceaa1b60bd6788ba74f399cc67e60a5fab10", size = 114556047, upload-time = "2026-03-23T18:10:55.931Z" }, +] + +[[package]] +name = "torchcodec" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/85/38f4843ff2a6bf7dfb71a153acd99024dadb96749965a67524c2f1cc1894/torchcodec-0.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:57056e91d1d883d0fb77ca7759e304be9c0bdb4ea0e37bde5c2e361347063b8c", size = 4368988, upload-time = "2026-04-14T18:24:51.46Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/3b41034b0f1289423745f918ace2a1e1e86b9c578c2e2461b6afcbb5354a/torchcodec-0.11.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f1aee486a84247fcaa67870ac5005aa8d382a9839e91e476fa71b5b3d9fda9b7", size = 2397532, upload-time = "2026-04-14T18:24:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a9/a2b6ee3e84c55bdd0c45fd991dde71c95a99115ec9e26938b212b4545dcf/torchcodec-0.11.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6c26e90e7aa982302644d0af8cb706318682bb390f48a80ecbfeab03499acd04", size = 2329883, upload-time = "2026-04-14T18:24:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/82/48/683114a4ed6b59f76b6919532a5db0f4068787be26bab92cc18a1dfa6794/torchcodec-0.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:3fd2d10e0e0a5f455c1c87dc1380b3bd43b77dd5eeeaf479470643b1c04a2dd2", size = 1921066, upload-time = "2026-04-14T18:24:57.102Z" }, +] + +[[package]] +name = "torchvision" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "torch" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/e7/56b47cc3b132aea90ccce22bcb8975dec688b002150012acc842846039d0/torchvision-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c409e1c3fdebec7a3834465086dbda8bf7680eff79abf7fd2f10c6b59520a7a4", size = 1863502, upload-time = "2026-03-23T18:12:57.326Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ec/5c31c92c08b65662fe9604a4067ae8232582805949f11ddc042cebe818ed/torchvision-0.26.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:406557718e62fdf10f5706e88d8a5ec000f872da913bf629aab9297622585547", size = 7767944, upload-time = "2026-03-23T18:12:42.805Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d8/cb6ccda1a1f35a6597645818641701207b3e8e13553e75fce5d86bac74b2/torchvision-0.26.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d61a5abb6b42a0c0c311996c2ac4b83a94418a97182c83b055a2a4ae985e05aa", size = 7522205, upload-time = "2026-03-23T18:12:54.654Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a9/c272623a0f735c35f0f6cd6dc74784d4f970e800cf063bb76687895a2ab9/torchvision-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:7993c01648e7c61d191b018e84d38fe0825c8fcb2720cd0f37caf7ba14404aa1", size = 4255155, upload-time = "2026-03-23T18:12:32.652Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + +[[package]] +name = "transformers" +version = "5.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/1e/1e244ab2ab50a863e6b52cc55761910567fa532b69a6740f6e99c5fdbd98/transformers-5.5.4.tar.gz", hash = "sha256:2e67cadba81fc7608cc07c4dd54f524820bc3d95b1cabd0ef3db7733c4f8b82e", size = 8227649, upload-time = "2026-04-13T16:55:55.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/fb/162a66789c65e5afa3b051309240c26bf37fbc8fea285b4546ae747995a2/transformers-5.5.4-py3-none-any.whl", hash = "sha256:0bd6281b82966fe5a7a16f553ea517a9db1dee6284d7cb224dfd88fc0dd1c167", size = 10236696, upload-time = "2026-04-13T16:55:51.497Z" }, +] + +[package.optional-dependencies] +torch = [ + { name = "accelerate" }, + { name = "torch" }, +] + +[[package]] +name = "triton" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/5d/08201db32823bdf77a0e2b9039540080b2e5c23a20706ddba942924ebcd6/triton-3.6.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:374f52c11a711fd062b4bfbb201fd9ac0a5febd28a96fb41b4a0f51dde3157f4", size = 176128243, upload-time = "2026-01-20T16:16:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" }, +] + +[[package]] +name = "typer" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "xxhash" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/a5/1386f35da1475fcaeef42581deae73417c6d2a6a0b2d2e8914de18844dcd/xxhash-4.0.1.tar.gz", hash = "sha256:d55bf4ef10eb09b8b6866790e083d26d087d84caa3cc0946ba87c3ca7ecaf7b7", size = 101513, upload-time = "2026-08-17T08:24:08.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/6c/dc7cffeadd06336cd934947187cd38abb263103bbc552ca0f55fe4ff595a/xxhash-4.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1ee523f51718e41753f04f7102bb4dc55a18d2ea5cbaceef8ec7ca08571bd428", size = 38444, upload-time = "2026-08-17T08:21:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/75/c9/cf736f6db8c3273af18925061572db0d4357818a9ce425f4b5fb0021918e/xxhash-4.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:515a822c73abbf6a0b7c70976d9662be342835c9d78b8dc7c023411f39c35dbc", size = 36195, upload-time = "2026-08-17T08:35:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/da/a2/ca1929354b6851529d0148f7f335b5e2b0281f83bab3e19f0896dc579796/xxhash-4.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f5d031f35962e5483a613214e61f09fe24ab523062c3646d592dc16c4a217451", size = 253113, upload-time = "2026-08-17T08:20:52.152Z" }, + { url = "https://files.pythonhosted.org/packages/de/bb/542005206af59518bc8d78a210f1e0172217bc53beb32f64a5b632e72b6b/xxhash-4.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0264844a09b538c894e5eff25313d941deb4dedec2131b98418a71a3c9944e", size = 276525, upload-time = "2026-08-17T08:21:01.886Z" }, + { url = "https://files.pythonhosted.org/packages/1b/df/607cff25dcb0f1d35c3b04493f6ad8471edb03fd4eacbdcc5ceddef1f3e9/xxhash-4.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1642907941ee4b75aacc3db688af52ea02ca2305ab22af7ee686ed726b332684", size = 297703, upload-time = "2026-08-17T08:21:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/15/ba/9d2275eea0b9d9c6b02921be23f7588356c60df95c763b25f0e045894d43/xxhash-4.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4af350bc3f329970c0e3a59af84a8a30998bf8a9167eb50cd48e59baaa1d7bec", size = 280252, upload-time = "2026-08-17T08:20:47.299Z" }, + { url = "https://files.pythonhosted.org/packages/1d/aa/2299d9f6369e550aef2abb64945e39daa34412725aa46a20d99b74d76f67/xxhash-4.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ba782ca3bf1e81492611152b9a0d5264971339e95e34d69de0ac2c926be496d", size = 511041, upload-time = "2026-08-17T08:20:36.771Z" }, + { url = "https://files.pythonhosted.org/packages/83/97/31bd8b8279e6935a0719f6910ced15e9d5a2cd554b253f6027ce1b5a1c2c/xxhash-4.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:237b8f63a2a0fcfb1ffc06e21dad23add44e6d354b2b014364a1d41e419a4dee", size = 261812, upload-time = "2026-08-17T08:22:00.469Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/d180a2da23c105d8e0b02d54f9f5841013fc81c233010ec781e31f1aee4c/xxhash-4.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:81507a68ba84c55241fb61cce1469f473a5da4205fc8ef6f698e5948eea8dd88", size = 339878, upload-time = "2026-08-17T08:35:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3d/f584cd3172fe934f0f5a0a3917d0d7ce781f74d794fd43bb72be71c3ef6f/xxhash-4.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1ea31d61bcd2cd2f3ec4ca80a64187bbd7948f490b63cf0dcbc6e717b4c1e9", size = 272871, upload-time = "2026-08-17T08:20:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/2c7956b2b551682e00b9aebce9ceb0a991a131d65f9850c09f5f9760be2e/xxhash-4.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:06713a5aaf1d0905c5579416c020c02e42b3ceb931e86c7d3b7fb85403dee3f3", size = 301440, upload-time = "2026-08-17T08:21:35.911Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/0739f6482184a8026f4b022718f5f815d352059312e80696825433f0a8e7/xxhash-4.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8cda075b10bb3917b002c74a04f9e02b7d13b5bf732571404d51c52b11c7329", size = 260157, upload-time = "2026-08-17T08:22:01.416Z" }, + { url = "https://files.pythonhosted.org/packages/a1/25/b31a7bcf1d7d116842812e54f9b944843b4236ea4fa85634e8259f342212/xxhash-4.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c10b9206753b64aa791b35b201485477525b26fdec5bf86e8364c388a03e2592", size = 278233, upload-time = "2026-08-17T08:21:15.674Z" }, + { url = "https://files.pythonhosted.org/packages/db/e8/5293bae090fc6119dbc5fcf5c4cc0e1536394b52d73b7904d033836c73db/xxhash-4.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f3e1a44af01b6692de0ec6caba5f0bf93ceb36896e02b7fc00952c6ea7ef39e1", size = 330270, upload-time = "2026-08-17T08:20:51.128Z" }, + { url = "https://files.pythonhosted.org/packages/72/9e/e2ab12d40921f3f34c9317637d65e011aeababf8288356ea8d527de2c1d0/xxhash-4.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6fc415b5568bd9accc7187f1729a99707330c0a67a8b9f93c1149ed573ed75d", size = 478555, upload-time = "2026-08-17T08:22:04.183Z" }, + { url = "https://files.pythonhosted.org/packages/6d/32/c6148d39a49efa95f39b4cf0d41ef35a487f3b30f6fb1fc8fe8d8eab577e/xxhash-4.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96d8de55029d42251945531f6aa7590c32b48163c66a43bf29d8657d7446a377", size = 258174, upload-time = "2026-08-17T08:35:21.18Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fb/0b04b68d6c5bc71c7a2c344f1287327b67e607f28fbcfd937697caca64b6/xxhash-4.0.1-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:0163b5d259de23ae9e07b7eabf435ce4704f6f205589a2b154e6af4be985ce1b", size = 20767, upload-time = "2026-08-17T08:21:00.806Z" }, + { url = "https://files.pythonhosted.org/packages/a6/be/476092aba34d1fcd313e1613a3bb3bc692f253d167b54bc90049043b5034/xxhash-4.0.1-cp312-cp312-win32.whl", hash = "sha256:1216f7ba5683f17a89eb7dcb4bc50a0b743dfe1902278d7b3d0786f538118433", size = 34669, upload-time = "2026-08-17T08:21:49.486Z" }, + { url = "https://files.pythonhosted.org/packages/aa/02/f9413d94fae43cec6d1a74c4f12156c6f4a7f5fd50e1d34defebdee3dec9/xxhash-4.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c2d525a3afabcd8e3549d85fc7e111fde6bc302d06a1893fe73adb79823415e", size = 37073, upload-time = "2026-08-17T08:22:04.886Z" }, + { url = "https://files.pythonhosted.org/packages/c1/83/6fe93c1b95acf962bc61a246df09dc2dcce895ccfc1080c9f48d0b652b92/xxhash-4.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:86b2b12bec60c678ed8f5cca0258ad93a8928ebddb6ca7732f0875afe1451d1a", size = 33299, upload-time = "2026-08-17T08:35:12.708Z" }, + { url = "https://files.pythonhosted.org/packages/86/79/9127ff42a887a348dc4ce3211cf1a962836887adee6f57078132bfba78b4/xxhash-4.0.1-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:ff48915bf1871a1f19f74c11834c6329443d306cedc0c05fe7fe617810422a80", size = 31836, upload-time = "2026-08-17T08:36:28.261Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/f238693bfdd642adb59c99683964d46d9947fe721ff44d3bd850ae675407/xxhash-4.0.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a76345f5aceb4ec404918edf9c7f2b5507db864dc0d7455982009ac0890b57b", size = 34453, upload-time = "2026-08-17T08:23:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/40/4b/796ace33cdfb75c91ba6d11615c3bd436355b9f3103e05865bbee9abce57/xxhash-4.0.1-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31d86f9e81f3e84e00131ac7c54caf5119ae4ddd82c09c31cff597c813ce1ee2", size = 38488, upload-time = "2026-08-17T08:23:59.901Z" }, + { url = "https://files.pythonhosted.org/packages/ad/23/2d549e5d5d7759eaf9ac2d2d2ab81ff60f1bb2b52cdaae8e5ec5c6524354/xxhash-4.0.1-graalpy312-graalpy250_312_native-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deca2a30d983d240b8375ec2ee0a4288e72042827fc61df2f7671f8467e4cb2f", size = 38206, upload-time = "2026-08-17T08:36:32.193Z" }, + { url = "https://files.pythonhosted.org/packages/79/98/1ee576b27f78e6107ee4ea8ac03e8a52888dff256e57d560f8282c195563/xxhash-4.0.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:7c343ee174d417a44d0c3355602c0cbbfa52a04d1bbbf1723378c7d2c8f60626", size = 37127, upload-time = "2026-08-17T08:23:42.705Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] diff --git a/dimos/imitation/policy/lerobot/test_module.py b/dimos/imitation/policy/lerobot/test_module.py new file mode 100644 index 0000000000..fcdf33e698 --- /dev/null +++ b/dimos/imitation/policy/lerobot/test_module.py @@ -0,0 +1,68 @@ +# Copyright 2026 Dimensional Inc. +# +# 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. + +from pathlib import Path + +from pydantic import ValidationError +import pytest + +from dimos.core.python_native_module import contract_rpc_names +from dimos.imitation.policy.lerobot.module import ( + LeRobotPolicyModule, + LeRobotPolicyModuleConfig, +) + + +def test_contract_imports_without_runtime_dependencies() -> None: + assert LeRobotPolicyModule.implementation == "dimos_lerobot.runtime:LeRobotPolicyRuntime" + assert contract_rpc_names(LeRobotPolicyModule) == { + "execute_learned_policy", + "policy_status", + "stop_learned_policy", + } + + +def test_contract_resolves_sibling_runtime_project() -> None: + module = LeRobotPolicyModule( + policies={"smoke": {"policy_path": "unused"}}, + joint_names=["joint"], + ) + try: + assert module.runtime_project == Path(__file__).parent / "python" + finally: + module.stop() + + +@pytest.mark.parametrize( + ("config", "message"), + [ + ( + { + "policies": {"default": {"policy_path": "checkpoint"}}, + "joint_names": ["joint1", "joint1"], + }, + "joint_names must not contain duplicates", + ), + ( + { + "policies": {" ": {"policy_path": "checkpoint"}}, + "joint_names": ["joint1"], + }, + "policy names must not be empty", + ), + ], +) +def test_config_rejects_ambiguous_names(config: dict[str, object], message: str) -> None: + with pytest.raises(ValidationError, match=message): + LeRobotPolicyModuleConfig(**config) diff --git a/dimos/manipulation/planning/kinematics/pink_solver.py b/dimos/manipulation/planning/kinematics/pink_solver.py index 624499b5ee..91b664fcca 100644 --- a/dimos/manipulation/planning/kinematics/pink_solver.py +++ b/dimos/manipulation/planning/kinematics/pink_solver.py @@ -220,10 +220,11 @@ def _build_robot_context( model = pinocchio.buildModelFromXML(description.xml) model = _reduce_to_controlled_joints(model, config, controlled_joints) + mapping = _build_joint_mapping(model, config, controlled_joints) + _apply_configured_velocity_limits(model, config, mapping) data = model.createData() _assert_base_link_is_model_root(model, config.base_link) frame_id = _get_frame_id(model, frame_name) - mapping = _build_joint_mapping(model, config, controlled_joints) return _PinkRobotContext( model=model, data=data, @@ -334,6 +335,32 @@ def _build_joint_mapping( ) +def _apply_configured_velocity_limits( + model: pinocchio.Model, + config: RobotModelConfig, + mapping: _JointMapping, +) -> None: + """Override URDF velocity limits when the robot model config is explicit.""" + limits = config.velocity_limits + if limits is None: + return + if len(limits) != len(config.joint_names): + raise ValueError( + f"RobotModelConfig velocity_limits has {len(limits)} values for " + f"{len(config.joint_names)} joints" + ) + limits_by_name = dict(zip(config.joint_names, limits, strict=True)) + for joint_name, velocity_index in zip( + mapping.model_joint_names, + mapping.idx_v, + strict=True, + ): + limit = float(limits_by_name[joint_name]) + if not np.isfinite(limit) or limit <= 0.0: + raise ValueError(f"Velocity limit for joint '{joint_name}' must be positive and finite") + model.velocityLimit[velocity_index] = limit + + def _get_joint_id(model: pinocchio.Model, joint_name: str) -> int: if hasattr(model, "existJointName") and not model.existJointName(joint_name): raise ValueError(_missing_joint_message(model, joint_name)) diff --git a/dimos/manipulation/planning/kinematics/test_pink_ik.py b/dimos/manipulation/planning/kinematics/test_pink_ik.py index f10fdb2e29..be992742c1 100644 --- a/dimos/manipulation/planning/kinematics/test_pink_ik.py +++ b/dimos/manipulation/planning/kinematics/test_pink_ik.py @@ -592,6 +592,27 @@ def test_joint_order_mapping_uses_names_not_positions() -> None: assert _seed_positions_for_mapping(seed, mapping).tolist() == [10.0, 20.0, 30.0] +def test_robot_context_applies_configured_velocity_limits_by_joint_name( + mocker: MockerFixture, tmp_path: Path +) -> None: + modules = _install_fake_modules(mocker) + model = _FakeModel() + modules.pinocchio.buildModelFromXML = mocker.Mock(return_value=model) + config = _robot_config() + model_path = tmp_path / "fake.urdf" + model_path.write_text("") + config.model = RobotModel.from_file(model_path) + config.velocity_limits = [0.5, 1.5, 2.5] + + context = _StreamingTestPinkIK(PinkIKConfig())._build_robot_context( + config, + "tool", + config.joint_names, + ) + + assert context.model.velocityLimit == pytest.approx([1.5, 0.5, 2.5]) + + def test_streaming_envelope_intersects_configured_and_urdf_velocity( mocker: MockerFixture, ) -> None: diff --git a/dimos/memory/backend.py b/dimos/memory/backend.py index 81509ede79..a2947d87d4 100644 --- a/dimos/memory/backend.py +++ b/dimos/memory/backend.py @@ -16,7 +16,7 @@ from __future__ import annotations -from dataclasses import replace +from dataclasses import dataclass, replace from typing import TYPE_CHECKING, Any, Generic, TypeVar from dimos.core.resource import CompositeResource @@ -25,7 +25,7 @@ from dimos.memory.type.observation import _UNLOADED if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterator, Sequence from reactivex.abc import DisposableBase @@ -40,6 +40,12 @@ T = TypeVar("T") +@dataclass(frozen=True) +class PreparedAppend(Generic[T]): + observation: Observation[T] + encoded: bytes | None + + class Backend(CompositeResource, Generic[T]): """Orchestrates metadata, blob, vector, and live stores for one stream. (encode → insert → store blob → index vector → notify) lives here, @@ -93,6 +99,28 @@ def loader() -> Any: return loader def append(self, obs: Observation[T]) -> Observation[T]: + return self.append_prepared((self.prepare_append(obs),))[0] + + def append_prepared( + self, prepared_appends: Sequence[PreparedAppend[T]] + ) -> list[Observation[T]]: + """Persist prepared observations in one transaction, then publish them.""" + results: list[Observation[T]] = [] + try: + for prepared in prepared_appends: + results.append(self._persist_prepared(prepared)) + if hasattr(self.metadata_store, "commit"): + self.metadata_store.commit() + except BaseException: + if hasattr(self.metadata_store, "rollback"): + self.metadata_store.rollback() + raise + for result in results: + self.notifier.notify(result) + return results + + def prepare_append(self, obs: Observation[T]) -> PreparedAppend[T]: + """Validate and encode an observation without touching storage.""" # Materialize lazy payloads (e.g. from with_pose()/tag()/derived # streams) in place — validation and encoding below read obs._data, # and we must encode it to store anyway. @@ -113,34 +141,26 @@ def append(self, obs: Observation[T]) -> Observation[T]: if self.blob_store is not None and not is_scalar: encoded = self.codec.encode(payload) - try: - # Insert metadata, get assigned id - row_id = self.metadata_store.insert(obs) - obs.id = row_id - - # Store blob (non-scalar data only) - if encoded is not None: - assert self.blob_store is not None - self.blob_store.put(self.name, row_id, encoded) - # Replace inline data with lazy loader - obs._data = _UNLOADED - obs._loader = self._make_loader(row_id) - - # Store embedding vector - if self.vector_store is not None: - emb = getattr(obs, "embedding", None) - if emb is not None: - self.vector_store.put(self.name, row_id, emb) - - # Commit if the metadata store supports it (e.g. SqliteObservationStore) - if hasattr(self.metadata_store, "commit"): - self.metadata_store.commit() - except BaseException: - if hasattr(self.metadata_store, "rollback"): - self.metadata_store.rollback() - raise + return PreparedAppend(observation=obs, encoded=encoded) + + def _persist_prepared(self, prepared: PreparedAppend[T]) -> Observation[T]: + """Insert one prepared observation into the current transaction.""" + obs = prepared.observation + encoded = prepared.encoded + row_id = self.metadata_store.insert(obs) + obs.id = row_id + + if encoded is not None: + assert self.blob_store is not None + self.blob_store.put(self.name, row_id, encoded) + obs._data = _UNLOADED + obs._loader = self._make_loader(row_id) + + if self.vector_store is not None: + emb = getattr(obs, "embedding", None) + if emb is not None: + self.vector_store.put(self.name, row_id, emb) - self.notifier.notify(obs) return obs def iterate(self, query: StreamQuery) -> Iterator[Observation[T]]: diff --git a/dimos/memory/module.py b/dimos/memory/module.py index 79b1a37aa7..0191bde9b4 100644 --- a/dimos/memory/module.py +++ b/dimos/memory/module.py @@ -14,17 +14,17 @@ from __future__ import annotations +import asyncio from collections.abc import Awaitable, Callable import enum import inspect import os from pathlib import Path -import sqlite3 import time from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast from pydantic import Field, field_validator -from reactivex import operators as ops +from reactivex.abc import DisposableBase from reactivex.disposable import Disposable from dimos.agents.annotation import skill @@ -32,7 +32,9 @@ from dimos.core.core import rpc from dimos.core.module import Module, ModuleConfig from dimos.core.stream import In +from dimos.memory.backend import Backend from dimos.memory.embed import EmbedImages +from dimos.memory.recording import PreparedWrite, Processor, RecordingFailedError, RecordingPipeline from dimos.memory.store.null import NullStore from dimos.memory.store.sqlite import SqliteStore from dimos.memory.stream import Stream @@ -46,8 +48,6 @@ from dimos.utils.logging_config import setup_logger if TYPE_CHECKING: - from reactivex.abc import DisposableBase - from dimos.core.stream import Out from dimos.msgs.geometry_msgs.Pose import Pose @@ -326,6 +326,8 @@ async def _lidar_pose(self, msg): tf: In[TFMessage] _pose_setters: dict[str, Any] = {} + _recording_subscriptions: list[DisposableBase] + _recording_pipeline: RecordingPipeline | None = None @rpc def start(self) -> None: @@ -338,6 +340,8 @@ def start(self) -> None: return self._pose_setters = self._collect_pose_setters() + self._recording_subscriptions = [] + self._recording_pipeline = None # TODO: store reset API/logic is not implemented yet. This module # shouldn't need to know about files (SqliteStore specific), and @@ -368,38 +372,62 @@ def start(self) -> None: logger.warning("Recorder has no In ports — nothing to record, subclass the Recorder") return + processors: dict[str, Processor] = {} for name, port in self._data_ports().items(): stream_name = self.config.stream_remapping.get(name, name) codec = self.config.stream_codecs.get(stream_name) overrides = {"codec": codec} if codec is not None else {} stream: Stream[Any] = self.store.stream(stream_name, port.type, **overrides) - self._port_to_stream(name, port, stream) + processors[name] = self._port_processor(name, stream) logger.info("Recording %s -> %s (%s)", name, stream_name, port.type.__name__) if self.config.record_tf: - self._record_tf() + tf_processor = self._tf_processor() + if tf_processor is not None: + processors["tf"] = tf_processor + + if not processors: + return + pipeline = RecordingPipeline(processors) + pipeline.start() + self._recording_pipeline = pipeline + for name, port in self._data_ports().items(): + self._subscribe_port(name, port) + if "tf" in processors: + subscription = Disposable(self.tf.subscribe(lambda msg: pipeline.submit("tf", msg))) + self._recording_subscriptions.append(subscription) + self.register_disposable(subscription) def _data_ports(self) -> dict[str, In[Any]]: """The In ports to record generically — everything but the tf port.""" return {name: port for name, port in self.inputs.items() if port is not self.tf} - def _port_to_stream(self, name: str, input_topic: In[Any], stream: Stream[Any]) -> None: - """Append each message from *input_topic* to *stream*, attaching world pose via tf. + def _port_processor(self, name: str, stream: Stream[Any]) -> Processor: + """Build the preparation stage for a stream, including world-pose lookup. Stamped messages use their own ``.frame_id`` and ``.ts``; unstamped messages (or ones whose frame isn't in the tf graph, e.g. a payload already in world coords) fall back to ``config.default_frame_id`` — so every observation gets a robot-pose anchor when tf is publishing. - Each port is recorded by an async callback dispatched on the module's - event loop via :meth:`process_observable`, which serialises invocations - and registers the subscription for cleanup on stop(). + Preparation runs on the pipeline's single worker, outside the transport + callback and the serialized database writer. """ - async def on_msg(stamped: tuple[float, Any]) -> None: + backend = cast("Backend[Any]", stream._source) # type: ignore[attr-defined] + + def process(stamped: tuple[float, Any]) -> tuple[PreparedWrite]: recv_ts, msg = stamped ts = self._resolve_ts(name, msg) - pose = await self._resolve_pose(name, msg, ts) + if name in self._pose_setters: + loop = self._loop + if loop is None or not loop.is_running(): + raise RecordingFailedError("Recorder event loop is not running") + pose = asyncio.run_coroutine_threadsafe( + self._resolve_pose(name, msg, ts), loop + ).result() + else: + pose = self._resolve_tf_pose(msg, ts) if not pose and name not in self.config.poseless_streams: logger.warning( "[%s] No pose for time %s (msg ts: %s), storing without pose", @@ -407,11 +435,36 @@ async def on_msg(stamped: tuple[float, Any]) -> None: ts, getattr(msg, "ts", None), ) - stream.append(msg, ts=ts, pose=pose, tags={"reception_ts": recv_ts}) + if hasattr(msg, "ts"): + msg.ts = ts + observation = Observation( + id=-1, + ts=ts, + pose=pose, + tags={"reception_ts": recv_ts}, + _data=msg, + ) + return (PreparedWrite(backend, backend.prepare_append(observation)),) + + return process + + def _subscribe_port(self, name: str, input_topic: In[Any]) -> None: + """Enqueue decoded messages directly from the transport callback.""" + pipeline = self._recording_pipeline + assert pipeline is not None - # Stamp arrival time before the coalescing dispatch queue. - stamped = input_topic.pure_observable().pipe(ops.map(lambda msg: (time.time(), msg))) - self.process_observable(stamped, on_msg) + def on_message(msg: Any) -> None: + pipeline.submit(name, (time.time(), msg)) + + subscription = input_topic.pure_observable().subscribe(on_message) + self._recording_subscriptions.append(subscription) + self.register_disposable(subscription) + transport = getattr(input_topic, "_transport", None) + subscribe_errors = getattr(transport, "subscribe_errors", None) + if subscribe_errors is not None: + error_subscription = Disposable(subscribe_errors(pipeline.fail)) + self._recording_subscriptions.append(error_subscription) + self.register_disposable(error_subscription) def _prepare_streams(self) -> None: """On APPEND, drop the streams this recorder is about to (re)write — the @@ -430,12 +483,21 @@ def _resolve_ts(self, name: str, msg: Any) -> float: return getattr(msg, "ts", None) or time.time() async def _resolve_pose(self, name: str, msg: Any, ts: float) -> Pose | None: - """Pose to anchor *msg* with. Dispatches to the stream's (async) - ``@pose_setter_for`` if one is defined, else falls back to a - ``world <- frame_id`` tf lookup.""" + """Pose to anchor *msg* with. + + Poseless streams skip pose setters and tf resolution. Other streams + dispatch to their async ``@pose_setter_for`` when defined, then fall + back to a ``world <- frame_id`` tf lookup. + """ + if name in self.config.poseless_streams: + return None setter = self._pose_setters.get(name) if setter is not None: return cast("Pose | None", await setter(msg)) + return self._resolve_tf_pose(msg, ts) + + def _resolve_tf_pose(self, msg: Any, ts: float) -> Pose | None: + """Resolve the ordinary TF pose directly from recorder queue threads.""" if self._tf is None: return None frame_id = getattr(msg, "frame_id", None) or self.config.default_frame_id @@ -453,19 +515,43 @@ def _collect_pose_setters(self) -> dict[str, PoseSetter]: setters[stream] = getattr(self, attr_name) return setters - def _record_tf(self) -> None: - """Record the live tf stream under "tf" (no-op without a wired tf port).""" + def _tf_processor(self) -> Processor | None: + """Build the preparation stage for tf, or return None when it is unwired.""" if getattr(self.tf, "_transport", None) is None: logger.warning("Recorder: tf port has no transport — not recording tf") - return + return None tf_stream = self.store.stream("tf", TFMessage) + backend = cast("Backend[Any]", tf_stream._source) # type: ignore[attr-defined] + + def process_tf(msg: TFMessage) -> list[PreparedWrite]: + writes: list[PreparedWrite] = [] + for transform in msg.transforms: + observation = Observation( + id=-1, + ts=transform.ts, + pose=None, + _data=TFMessage(transform), + ) + writes.append(PreparedWrite(backend, backend.prepare_append(observation))) + return writes - def on_tf(msg: TFMessage) -> None: - try: - for transform in msg.transforms: - tf_stream.append(TFMessage(transform), ts=transform.ts, pose=None) - except sqlite3.ProgrammingError: - # A late LCM callback raced teardown and hit the closed store. - pass + return process_tf - self.register_disposable(Disposable(self.tf.subscribe(on_tf))) + @rpc + def stop(self) -> None: + """Stop inputs, drain all accepted observations, then close storage.""" + pipeline = self._recording_pipeline + if pipeline is None: + super().stop() + return + for subscription in self._recording_subscriptions: + subscription.dispose() + try: + pipeline.close() + except BaseException as error: + failure: BaseException | None = error + else: + failure = None + super().stop() + if failure is not None: + raise RecordingFailedError("Recorder failed while draining") from failure diff --git a/dimos/memory/recording.py b/dimos/memory/recording.py new file mode 100644 index 0000000000..12a3dcbe29 --- /dev/null +++ b/dimos/memory/recording.py @@ -0,0 +1,193 @@ +# Copyright 2026 Dimensional Inc. +# +# 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. + +"""Lossless preparation and persistence pipeline for Recorder.""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Callable, Iterable +from dataclasses import dataclass +import threading +import time +from typing import Any + +from dimos.memory.backend import Backend, PreparedAppend +from dimos.memory.buffer import ClosedError, DropNew + + +class RecordingFailedError(RuntimeError): + """Raised when the pipeline cannot persist every accepted message.""" + + +@dataclass(frozen=True) +class PreparedWrite: + backend: Backend[Any] + append: PreparedAppend[Any] + + +Processor = Callable[[Any], Iterable[PreparedWrite]] + + +class RecordingPipeline: + """Prepare one global FIFO and serialize batched database writes.""" + + def __init__( + self, + processors: dict[str, Processor], + *, + ingress_size: int = 4096, + writer_size: int = 4096, + batch_rows: int = 64, + batch_delay_s: float = 0.010, + ) -> None: + self._processors = processors + self._ingress = DropNew[tuple[str, Any]](ingress_size) + self._writer = DropNew[PreparedWrite](writer_size) + self._batch_rows = batch_rows + self._batch_delay_s = batch_delay_s + self._failure: BaseException | None = None + self._lock = threading.Lock() + self._started = threading.Event() + self._closed = threading.Event() + self._preparation_thread: threading.Thread | None = None + self._writer_thread: threading.Thread | None = None + + def start(self) -> None: + with self._lock: + if self._closed.is_set(): + raise RecordingFailedError("Recording pipeline is closed") + if self._started.is_set(): + return + self._writer_thread = threading.Thread( + target=self._write_loop, + name="recorder-writer", + daemon=True, + ) + self._preparation_thread = threading.Thread( + target=self._prepare_loop, + name="recorder-prepare", + daemon=True, + ) + self._writer_thread.start() + self._preparation_thread.start() + self._started.set() + + def submit(self, stream: str, value: Any) -> None: + self._raise_if_unavailable() + try: + self._processors[stream] + except KeyError as unknown_stream: + raise KeyError(f"Unknown recording stream {stream!r}") from unknown_stream + if not self._ingress.put((stream, value)): + capacity_error = RecordingFailedError("Recording ingress reached capacity") + self.fail(capacity_error) + raise capacity_error + + def fail(self, error: BaseException) -> None: + with self._lock: + if self._failure is None: + self._failure = error + + def close(self, timeout_s: float = 10.0) -> None: + if not self._started.is_set(): + self._closed.set() + self._raise_if_failed() + return + first_close = not self._closed.is_set() + self._closed.set() + deadline = time.monotonic() + timeout_s + if first_close: + self._ingress.close() + shutdown_failure: BaseException | None = None + if self._preparation_thread is not None: + try: + self._join(self._preparation_thread, deadline) + except BaseException as error: + shutdown_failure = shutdown_failure or error + self._writer.close() + if self._writer_thread is not None: + try: + self._join(self._writer_thread, deadline) + except BaseException as error: + shutdown_failure = shutdown_failure or error + if shutdown_failure is not None: + raise RecordingFailedError("Recording pipeline did not stop") from shutdown_failure + self._raise_if_failed() + + def _prepare_loop(self) -> None: + try: + for stream, value in self._ingress: + if self._has_failed(): + continue + for write in self._processors[stream](value): + if not self._writer.put(write): + raise RecordingFailedError("Recording writer buffer reached capacity") + except BaseException as error: + self.fail(error) + + def _write_loop(self) -> None: + while True: + try: + first = self._writer.take() + except ClosedError: + return + batch = [first] + deadline = time.monotonic() + self._batch_delay_s + while len(batch) < self._batch_rows: + try: + batch.append(self._writer.take(timeout=max(0.0, deadline - time.monotonic()))) + except TimeoutError: + break + except ClosedError: + break + try: + self._persist(batch) + except BaseException as error: + self.fail(error) + return + + @staticmethod + def _persist(batch: list[PreparedWrite]) -> None: + grouped: dict[int, list[PreparedAppend[Any]]] = defaultdict(list) + backends: dict[int, Backend[Any]] = {} + for write in batch: + key = id(write.backend) + backends[key] = write.backend + grouped[key].append(write.append) + for key, appends in grouped.items(): + backends[key].append_prepared(appends) + + def _raise_if_unavailable(self) -> None: + if not self._started.is_set(): + raise RecordingFailedError("Recording pipeline is not started") + if self._closed.is_set(): + raise RecordingFailedError("Recording pipeline is closed") + self._raise_if_failed() + + def _has_failed(self) -> bool: + with self._lock: + return self._failure is not None + + def _raise_if_failed(self) -> None: + with self._lock: + failure = self._failure + if failure is not None: + raise RecordingFailedError("Recording pipeline failed") from failure + + @staticmethod + def _join(thread: threading.Thread, deadline: float) -> None: + thread.join(timeout=max(0.0, deadline - time.monotonic())) + if thread.is_alive(): + raise RecordingFailedError(f"{thread.name} did not stop") diff --git a/dimos/memory/test_recording.py b/dimos/memory/test_recording.py new file mode 100644 index 0000000000..782d34f07a --- /dev/null +++ b/dimos/memory/test_recording.py @@ -0,0 +1,134 @@ +# Copyright 2026 Dimensional Inc. +# +# 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. + +from __future__ import annotations + +import threading +from typing import Any, cast + +import pytest + +from dimos.memory.backend import Backend, PreparedAppend +from dimos.memory.codecs.pickle import PickleCodec +from dimos.memory.notifier.subject import SubjectNotifier +from dimos.memory.observationstore.memory import ListObservationStore +from dimos.memory.recording import PreparedWrite, RecordingFailedError, RecordingPipeline +from dimos.memory.type.observation import Observation + + +class _Backend: + def __init__(self) -> None: + self.batches: list[list[float]] = [] + + def append_prepared(self, appends: list[PreparedAppend[Any]]) -> list[Observation[Any]]: + observations = [append.observation for append in appends] + self.batches.append([observation.ts for observation in observations]) + return observations + + +def _write(backend: Backend[Any], value: float) -> tuple[PreparedWrite]: + observation = Observation(ts=value, _data=value) + return (PreparedWrite(backend, PreparedAppend(observation, None)),) + + +def test_pipeline_preserves_global_fifo_and_batches_writes() -> None: + backend = cast("Backend[Any]", _Backend()) + pipeline = RecordingPipeline( + { + "camera": lambda value: _write(backend, value), + "imu": lambda value: _write(backend, value), + }, + batch_rows=3, + batch_delay_s=1.0, + ) + pipeline.start() + try: + pipeline.submit("camera", 1.0) + pipeline.submit("imu", 2.0) + pipeline.submit("camera", 3.0) + finally: + pipeline.close(timeout_s=1.0) + + assert cast("_Backend", backend).batches == [[1.0, 2.0, 3.0]] + + +def test_pipeline_fails_instead_of_dropping_when_ingress_is_full() -> None: + backend = cast("Backend[Any]", _Backend()) + started = threading.Event() + release = threading.Event() + + def process(value: float) -> tuple[PreparedWrite]: + started.set() + assert release.wait(timeout=1.0) + return _write(backend, value) + + pipeline = RecordingPipeline({"depth": process}, ingress_size=1) + pipeline.start() + pipeline.submit("depth", 1.0) + assert started.wait(timeout=1.0) + pipeline.submit("depth", 2.0) + + with pytest.raises(RecordingFailedError, match="reached capacity"): + pipeline.submit("depth", 3.0) + + release.set() + with pytest.raises(RecordingFailedError, match="failed"): + pipeline.close(timeout_s=1.0) + + +def test_pipeline_reports_preparation_failure() -> None: + def process(_: float) -> tuple[PreparedWrite]: + raise OSError("encoder failed") + + pipeline = RecordingPipeline({"depth": process}) + pipeline.start() + pipeline.submit("depth", 1.0) + + with pytest.raises(RecordingFailedError, match="failed") as exc: + pipeline.close(timeout_s=1.0) + assert isinstance(exc.value.__cause__, OSError) + + +def test_backend_commits_batch_before_notifying(monkeypatch: pytest.MonkeyPatch) -> None: + events: list[str] = [] + store = ListObservationStore[float](name="camera") + notifier = SubjectNotifier[float]() + monkeypatch.setattr(store, "commit", lambda: events.append("commit"), raising=False) + monkeypatch.setattr(notifier, "notify", lambda obs: events.append(f"notify-{obs.ts}")) + backend = Backend[float](metadata_store=store, codec=PickleCodec(), notifier=notifier) + appends = [backend.prepare_append(Observation(ts=ts, _data=ts)) for ts in (1.0, 2.0)] + + backend.append_prepared(appends) + + assert events == ["commit", "notify-1.0", "notify-2.0"] + + +def test_backend_rolls_back_without_notifying(monkeypatch: pytest.MonkeyPatch) -> None: + events: list[str] = [] + store = ListObservationStore[float](name="camera") + notifier = SubjectNotifier[float]() + + def fail_insert(_: Observation[float]) -> int: + raise OSError("locked") + + monkeypatch.setattr(store, "insert", fail_insert) + monkeypatch.setattr(store, "rollback", lambda: events.append("rollback"), raising=False) + monkeypatch.setattr(notifier, "notify", lambda obs: events.append("notify")) + backend = Backend[float](metadata_store=store, codec=PickleCodec(), notifier=notifier) + append = backend.prepare_append(Observation(ts=1.0, _data=1.0)) + + with pytest.raises(OSError, match="locked"): + backend.append_prepared((append,)) + + assert events == ["rollback"] diff --git a/dimos/protocol/pubsub/impl/shmpubsub.py b/dimos/protocol/pubsub/impl/shmpubsub.py index e0863d5d4e..31da1d8cd8 100644 --- a/dimos/protocol/pubsub/impl/shmpubsub.py +++ b/dimos/protocol/pubsub/impl/shmpubsub.py @@ -31,7 +31,7 @@ from dimos.protocol.pubsub.encoders import LCMEncoderMixin, PickleEncoderMixin from dimos.protocol.pubsub.impl.lcmpubsub import Topic -from dimos.protocol.pubsub.shm.ipc_factory import CpuShmChannel, FrameChannel +from dimos.protocol.pubsub.shm.ipc_factory import CpuShmChannel, CpuShmQueue, FrameChannel from dimos.protocol.pubsub.spec import PubSub from dimos.utils.logging_config import setup_logger @@ -79,6 +79,7 @@ class _TopicState: "channel", "cp", "dtype", + "error_subs", "last_local_payload", "last_seq", "publish_buffer", @@ -96,6 +97,7 @@ def __init__(self, channel, capacity: int, cp_mod) -> None: # type: ignore[no-u self.shape = (self.capacity + 20,) # +20 for header: length(4) + uuid(16) self.dtype = np.uint8 self.subs: list[Callable[[bytes, str], None]] = [] + self.error_subs: list[Callable[[BaseException], None]] = [] self.stop = threading.Event() self.thread: threading.Thread | None = None self.last_seq = 0 # start at 0 to avoid b"" on first poll @@ -219,6 +221,21 @@ def _unsub() -> None: return _unsub + def subscribe_errors( + self, topic: str, callback: Callable[[BaseException], None] + ) -> Callable[[], None]: + """Subscribe to reliable-transport delivery failures for *topic*.""" + st = self._ensure_topic(topic) + st.error_subs.append(callback) + + def _unsub() -> None: + try: + st.error_subs.remove(callback) + except ValueError: + pass + + return _unsub + # Capacity mgmt def reconfigure(self, topic: str, *, capacity: int) -> dict: # type: ignore[type-arg] @@ -273,10 +290,19 @@ def _names_for_topic(topic: str, capacity: int) -> tuple[str, str]: def _fanout_loop(self, topic: str, st: _TopicState) -> None: while not st.stop.is_set(): - seq, _ts_ns, view = st.channel.read(last_seq=st.last_seq, require_new=True) + previous_seq = st.last_seq + seq, _ts_ns, view = st.channel.read(last_seq=previous_seq, require_new=True) if view is None: time.sleep(0.001) continue + dropped = max(0, seq - max(1, previous_seq + 1)) + if dropped: + error = RuntimeError( + f"Shared-memory reader for {topic!r} lost {dropped} message(s) " + "because its ring overflowed" + ) + for callback in list(st.error_subs): + callback(error) st.last_seq = seq host = np.array(view, copy=True) @@ -326,7 +352,13 @@ class PickleSharedMemory( ): """SharedMemory pubsub that transports arbitrary Python objects via pickle.""" - ... + def __init__(self, *, queue_size: int = 256, **kwargs: Any) -> None: + if queue_size <= 0: + raise ValueError("queue_size must be positive") + self.queue_size = queue_size + self._channel_class = CpuShmQueue + self._channel_kwargs = {"slots": queue_size} + super().__init__(**kwargs) class LCMSharedMemoryPubSubBase(PubSub[Topic, Any]): diff --git a/dimos/protocol/pubsub/shm/test_ipc_factory.py b/dimos/protocol/pubsub/shm/test_ipc_factory.py index 8afc9f84d6..904645accd 100644 --- a/dimos/protocol/pubsub/shm/test_ipc_factory.py +++ b/dimos/protocol/pubsub/shm/test_ipc_factory.py @@ -112,6 +112,20 @@ def test_reader_outpaced_drops_oldest() -> None: ch.close() +def test_reader_sequence_exposes_overflow_on_first_recovery_read() -> None: + ch = CpuShmQueue((CAP,), np.uint8, slots=4) + try: + for i in range(8): + _publish(ch, f"m{i}".encode()) + + seq, _, payload = ch.read(last_seq=0) + + assert seq == 5 + assert payload is not None + finally: + ch.close() + + def test_concurrent_publishers_no_loss() -> None: """Threads sharing ONE instance publish concurrently with no loss or dupes. diff --git a/dimos/protocol/pubsub/test_registry.py b/dimos/protocol/pubsub/test_registry.py index 9d7796c2ce..806ba7855b 100644 --- a/dimos/protocol/pubsub/test_registry.py +++ b/dimos/protocol/pubsub/test_registry.py @@ -14,6 +14,10 @@ from __future__ import annotations +import pickle +import threading +import uuid + import pytest from dimos.core.transport import ( @@ -31,6 +35,7 @@ subscribe_pubsub_uri, supported_protos, ) +from dimos.protocol.pubsub.shm.ipc_factory import CpuShmQueue def test_supported_protos_includes_known_set() -> None: @@ -102,6 +107,63 @@ def test_make_pubsub_transport_pshm_uses_pSHMTransport() -> None: assert isinstance(t, pSHMTransport) +def test_reliable_shm_transport_uses_configured_ring() -> None: + transport = pSHMTransport("reliable-test", queue_size=32, default_capacity=1024) + + assert transport.shm._channel_class is CpuShmQueue + assert transport.shm._channel_kwargs == {"slots": 32} + + +def test_pshm_transport_rejects_non_positive_queue_size() -> None: + with pytest.raises(ValueError, match="queue_size must be positive"): + pSHMTransport("invalid", queue_size=0) + + +def test_pshm_transport_preserves_queue_size_when_pickled() -> None: + restored = pickle.loads( + pickle.dumps(pSHMTransport("pickled", queue_size=17, default_capacity=1024)) + ) + + assert restored.shm.queue_size == 17 + assert restored.shm.config.default_capacity == 1024 + + +def test_pshm_transport_reports_sequence_gap_when_ring_overflows() -> None: + topic = f"overflow-{uuid.uuid4().hex}" + publisher = pSHMTransport[int](topic, queue_size=2, default_capacity=1024) + subscriber = pSHMTransport[int](topic, queue_size=2, default_capacity=1024) + callback_started = threading.Event() + release_callback = threading.Event() + overflow_reported = threading.Event() + errors: list[str] = [] + + def receive(value: int) -> None: + if value == 0: + callback_started.set() + assert release_callback.wait(timeout=1.0) + + def receive_error(error: BaseException) -> None: + errors.append(str(error)) + overflow_reported.set() + + unsubscribe = subscriber.subscribe(receive) + unsubscribe_errors = subscriber.subscribe_errors(receive_error) + try: + publisher.broadcast(None, 0) + assert callback_started.wait(timeout=1.0) + for value in range(1, 5): + publisher.broadcast(None, value) + release_callback.set() + assert overflow_reported.wait(timeout=1.0) + assert any("lost 2 message(s)" in error for error in errors) + finally: + release_callback.set() + unsubscribe() + unsubscribe_errors() + publisher.stop() + subscriber.stop() + + def test_make_pubsub_transport_shm_uses_SHMTransport() -> None: t = make_pubsub_transport("shm:bytes_topic") assert isinstance(t, SHMTransport) diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 687d574016..ffceec556f 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -74,6 +74,7 @@ "keyboard-teleop-piper": "dimos.robot.manipulators.piper.blueprints.teleop:keyboard_teleop_piper", "keyboard-teleop-xarm6": "dimos.robot.manipulators.xarm.blueprints.teleop:keyboard_teleop_xarm6", "keyboard-teleop-xarm7": "dimos.robot.manipulators.xarm.blueprints.teleop:keyboard_teleop_xarm7", + "learning-collect-quest-openyam": "dimos.imitation.collection.blueprint:learning_collect_quest_openyam", "learning-collect-quest-piper": "dimos.imitation.collection.blueprint:learning_collect_quest_piper", "learning-collect-quest-xarm7": "dimos.imitation.collection.blueprint:learning_collect_quest_xarm7", "mid360": "dimos.hardware.sensors.lidar.livox.livox_blueprints:mid360", @@ -253,6 +254,7 @@ "object-tracker3-d": "dimos.perception.experimental.object_tracker_3d.ObjectTracker3D", "object-tracking": "dimos.perception.experimental.object_tracker.ObjectTracking", "open-arm-teleop-coordinator": "dimos.robot.manipulators.openarm.blueprints.teleop.OpenArmTeleopCoordinator", + "open-yam-teleop-coordinator": "dimos.robot.manipulators.openyam.blueprints.teleop.OpenYamTeleopCoordinator", "osm-skill": "dimos.agents.skills.osm.OsmSkill", "path-following-coordinator": "dimos.control.path_following_coordinator.PathFollowingCoordinator", "patrolling-module": "dimos.navigation.patrolling.module.PatrollingModule", diff --git a/dimos/robot/manipulators/openyam/blueprints/teleop.py b/dimos/robot/manipulators/openyam/blueprints/teleop.py index 708b52c2c3..8e731ab8b4 100644 --- a/dimos/robot/manipulators/openyam/blueprints/teleop.py +++ b/dimos/robot/manipulators/openyam/blueprints/teleop.py @@ -16,6 +16,7 @@ from __future__ import annotations +from dimos.control.components import HardwareComponent from dimos.control.coordinator import TaskConfig from dimos.control.tasks.trajectory_task.trajectory_task import JOINT_TRAJECTORY_TASK_NAME from dimos.control.teleop_coordinator import TeleopControlCoordinator @@ -36,7 +37,9 @@ OPENYAM_GRIPPER_JOINT, make_openyam_model_config, openyam_hardware, + openyam_mock_hardware, ) +from dimos.robot.manipulators.openyam.teleop_ik import OpenYamPinkPoseTargetSolver from dimos.teleop.keyboard.keyboard_teleop_module import KeyboardTeleopModule from dimos.teleop.quest.quest_extensions import ArmTeleopModule @@ -92,6 +95,21 @@ def _gripper_task() -> TaskConfig: OPENYAM_QUEST_TASK_NAME = "teleop_openyam" + +def _openyam_quest_hardware(can_port: str | None) -> HardwareComponent: + if can_port is None: + return openyam_mock_hardware() + return openyam_hardware(can_port=can_port) + + +class OpenYamTeleopCoordinator(TeleopControlCoordinator): + """Select fake or explicit-CAN OpenYAM hardware during coordinator setup.""" + + def _setup_from_config(self) -> None: + self.config.hardware = [_openyam_quest_hardware(self.config.g.can_port)] + super()._setup_from_config() + + _openyam_quest_pink = PinkKinematicsConfig( dt=0.01, position_cost=8.0, @@ -99,9 +117,9 @@ def _gripper_task() -> TaskConfig: posture_cost=0.01, joint_limit_posture_margin=0.3, lm_damping=0.01, - gain=0.25, + gain=1.0, ) -_openyam_quest_hw = openyam_hardware() +_openyam_quest_hw = openyam_mock_hardware() _openyam_quest_model = make_openyam_model_config(name="arm") _openyam_quest_task = teleop_ik_task( _openyam_quest_hw, @@ -109,6 +127,7 @@ def _gripper_task() -> TaskConfig: name=OPENYAM_QUEST_TASK_NAME, joint_names=OPENYAM_ARM_JOINTS, priority=10, + solver_type=OpenYamPinkPoseTargetSolver, bindings=[ { "hand": "right", @@ -123,16 +142,15 @@ def _gripper_task() -> TaskConfig: "timeout": 0.5, "max_command_tracking_error_deg": 10.0, "max_joint_velocity_rad_s": 2.0, - "joint_command_filter_cutoff_hz": 5.0, + "joint_command_filter_cutoff_hz": 30.0, }, ) # Single-arm Quest teleop: right controller -> OpenYAM arm teleop_quest_openyam = autoconnect( - ArmTeleopModule.blueprint(task_names={"right": OPENYAM_QUEST_TASK_NAME}), - TeleopControlCoordinator.blueprint( + ArmTeleopModule.blueprint(), + OpenYamTeleopCoordinator.blueprint( instance_name="ControlCoordinator", - hardware=[_openyam_quest_hw], tasks=[ _openyam_quest_task, _trajectory_task(priority=20), diff --git a/dimos/robot/manipulators/openyam/config.py b/dimos/robot/manipulators/openyam/config.py index b092b0588c..46f9305e5a 100644 --- a/dimos/robot/manipulators/openyam/config.py +++ b/dimos/robot/manipulators/openyam/config.py @@ -43,18 +43,37 @@ OPENYAM_PACKAGE_PATHS: dict[str, Path] = {"yam_description": OPENYAM_PACKAGE} -def openyam_hardware() -> HardwareComponent: +def openyam_hardware(*, can_port: str | None = None) -> HardwareComponent: """Select the physical or in-memory whole-body adapter for OpenYAM.""" - adapter_type = "mock_whole_body" if global_config.simulation else "openyam_damiao" + explicit_can_port = can_port is not None + selected_can_port = can_port if explicit_can_port else global_config.can_port + adapter_type = ( + "mock_whole_body" + if global_config.simulation and not explicit_can_port + else "openyam_damiao" + ) adapter_kwargs: dict[str, object] = {} - if not global_config.simulation: - bus_devices = ( - {"openyam": global_config.can_port} if global_config.can_port is not None else {} - ) + if adapter_type == "openyam_damiao": + bus_devices = {"openyam": selected_can_port} if selected_can_port is not None else {} adapter_kwargs["runtime_config"] = DamiaoRuntimeConfig( bus_devices=bus_devices, gravity_comp=True, ) + return _openyam_hardware_component(adapter_type, adapter_kwargs) + + +def openyam_mock_hardware() -> HardwareComponent: + """Build an OpenYAM component that is unconditionally safe and in-memory.""" + return _openyam_hardware_component( + "mock_whole_body", + {"initial_positions": [0.0] * len(OPENYAM_JOINTS)}, + ) + + +def _openyam_hardware_component( + adapter_type: str, + adapter_kwargs: dict[str, object], +) -> HardwareComponent: return HardwareComponent( hardware_id=OPENYAM_HARDWARE_ID, hardware_type=HardwareType.WHOLE_BODY, @@ -103,4 +122,6 @@ def make_openyam_model_config( urdf_joint_prefix="", ), home_joints=home_joints or [0.0] * OPENYAM_DOF, + velocity_limits=[2.0] * OPENYAM_DOF, + max_velocity=2.0, ) diff --git a/dimos/robot/manipulators/openyam/teleop_ik.py b/dimos/robot/manipulators/openyam/teleop_ik.py new file mode 100644 index 0000000000..c58707f686 --- /dev/null +++ b/dimos/robot/manipulators/openyam/teleop_ik.py @@ -0,0 +1,42 @@ +# Copyright 2026 Dimensional Inc. +# +# 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. + +"""OpenYAM-specific Pink objective tuning for Quest teleoperation.""" + +import numpy as np +import pink + +from dimos.control.tasks.pose_target_ik import PinkPoseTargetSolver + +# OpenYAM has six joints for a six-DoF frame objective, so modest posture +# ratios have almost no visible effect. These factors yield effective costs of +# 3.0 for the large proximal joints and 0.01 for the wrist at the blueprint's +# 0.01 base posture cost. +_POSTURE_WEIGHTS = np.array([300.0, 300.0, 300.0, 1.0, 1.0, 1.0], dtype=np.float64) + + +class OpenYamPinkPoseTargetSolver(PinkPoseTargetSolver): + """Prefer wrist motion while stabilizing the larger proximal joints.""" + + def _create_tasks( + self, + configuration: pink.Configuration, + target_frames: tuple[str, ...], + ) -> dict[str, pink.Task]: + tasks = super()._create_tasks(configuration, target_frames) + posture_task = tasks.get("posture/current") + if posture_task is None: + raise ValueError("OpenYamPinkPoseTargetSolver requires a positive posture cost") + posture_task.cost = self.config.posture_cost * _POSTURE_WEIGHTS + return tasks diff --git a/dimos/robot/manipulators/openyam/test_openyam.py b/dimos/robot/manipulators/openyam/test_openyam.py index be56155549..d033db023c 100644 --- a/dimos/robot/manipulators/openyam/test_openyam.py +++ b/dimos/robot/manipulators/openyam/test_openyam.py @@ -18,6 +18,7 @@ from dimos.control.components import HardwareType from dimos.control.coordinator import ControlCoordinator +from dimos.core.coordination.blueprint_config.parser import BlueprintConfigParser from dimos.core.coordination.blueprints import Blueprint from dimos.core.global_config import global_config from dimos.robot.manipulators.openyam.blueprints.basic import ( @@ -25,8 +26,11 @@ openyam_planner_coordinator, ) from dimos.robot.manipulators.openyam.blueprints.teleop import ( + OpenYamTeleopCoordinator, + _openyam_quest_hardware, keyboard_teleop_openyam, keyboard_teleop_openyam_planner, + teleop_quest_openyam, ) from dimos.robot.manipulators.openyam.config import ( OPENYAM_ARM_JOINTS, @@ -37,6 +41,8 @@ make_openyam_model_config, openyam_hardware, ) +from dimos.robot.manipulators.openyam.teleop_ik import OpenYamPinkPoseTargetSolver +from dimos.teleop.quest.quest_extensions import ArmTeleopModule def _module_kwargs(blueprint: Blueprint, module_type: type) -> dict[str, Any]: @@ -59,6 +65,38 @@ def test_make_openyam_model_config_maps_only_arm_joints() -> None: assert OPENYAM_GRIPPER_JOINT not in config.joint_name_mapping assert config.base_link == "base" assert config.end_effector_link == "gripper_tip" + assert config.velocity_limits == [2.0] * OPENYAM_DOF + assert config.max_velocity == 2.0 + + +def test_quest_teleop_matches_dual_openyam_response_tuning() -> None: + tasks = _coordinator_kwargs(teleop_quest_openyam)["tasks"] + teleop = next(task for task in tasks if task.type == "teleop_ik") + + assert teleop.params["pink"].gain == 1.0 + assert teleop.params["solver_type"] is OpenYamPinkPoseTargetSolver + assert teleop.params["max_joint_velocity_rad_s"] == 2.0 + assert teleop.params["joint_command_filter_cutoff_hz"] == 30.0 + + +def test_quest_teleop_defaults_to_fake_hardware() -> None: + coordinator = _module_kwargs(teleop_quest_openyam, OpenYamTeleopCoordinator) + hardware = _openyam_quest_hardware(None) + + assert "hardware" not in coordinator + assert hardware.adapter_type == "mock_whole_body" + + +def test_quest_teleop_selects_physical_hardware_with_explicit_can_port() -> None: + parsed = BlueprintConfigParser(teleop_quest_openyam).parse( + ["--can-port", "can8"], + environ={}, + ) + hardware = _openyam_quest_hardware("can8") + + assert parsed.global_config_values()["can_port"] == "can8" + assert hardware.adapter_type == "openyam_damiao" + assert hardware.adapter_kwargs["runtime_config"].bus_devices == {"openyam": "can8"} def test_openyam_hardware_physical_mode_returns_one_whole_body( @@ -98,6 +136,13 @@ def test_openyam_hardware_simulation_mode_returns_generic_whole_body_mock( assert hardware.adapter_type == "mock_whole_body" +def test_quest_teleop_module_accepts_blueprint_config() -> None: + kwargs = _module_kwargs(teleop_quest_openyam, ArmTeleopModule) + + module = ArmTeleopModule(**kwargs) + module.stop() + + @pytest.mark.parametrize( "blueprint", [ diff --git a/dimos/robot/manipulators/openyam/test_teleop_ik.py b/dimos/robot/manipulators/openyam/test_teleop_ik.py new file mode 100644 index 0000000000..a4c68b0c62 --- /dev/null +++ b/dimos/robot/manipulators/openyam/test_teleop_ik.py @@ -0,0 +1,90 @@ +# Copyright 2026 Dimensional Inc. +# +# 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. + +"""Objective tests for single-arm OpenYAM Quest teleoperation.""" + +import pytest + +from dimos.control.tasks.pose_target_ik import PinkPoseTargetSolver, PoseTargetIKTaskConfig +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.robot.manipulators.openyam.blueprints.teleop import _openyam_quest_task +from dimos.robot.manipulators.openyam.config import OPENYAM_ARM_JOINTS +from dimos.robot.manipulators.openyam.teleop_ik import OpenYamPinkPoseTargetSolver + +_SAFE_POSTURE = [0.0, 1.047, 1.047, 0.0, 0.0, 0.0] +_TARGET_FRAME = _openyam_quest_task.params["robot_model"].end_effector_link + + +def _solver( + solver_type: type[PinkPoseTargetSolver] = OpenYamPinkPoseTargetSolver, +) -> PinkPoseTargetSolver: + task = _openyam_quest_task + config = PoseTargetIKTaskConfig( + joint_names=tuple(task.joint_names), + robot_model=task.params["robot_model"], + target_frames=(_TARGET_FRAME,), + pink=task.params["pink"], + max_joint_velocity_rad_s=task.params["max_joint_velocity_rad_s"], + joint_command_filter_cutoff_hz=task.params["joint_command_filter_cutoff_hz"], + ) + return solver_type(config) + + +@pytest.mark.self_hosted +def test_solver_weights_large_joints_above_wrist_joints() -> None: + solver = _solver() + state = JointState(name=OPENYAM_ARM_JOINTS, position=_SAFE_POSTURE) + targets = solver.frame_poses(state, (_TARGET_FRAME,)) + + assert solver.step(targets, state, 0.01) is not None + + context = next(iter(solver._control_contexts.values())) + assert context.tasks is not None + posture = context.tasks["posture/current"] + assert posture.cost == pytest.approx([3.0, 3.0, 3.0, 0.01, 0.01, 0.01]) + + +def _orientation_target_motion( + solver_type: type[PinkPoseTargetSolver], +) -> tuple[float, float]: + solver = _solver(solver_type) + initial = JointState(name=OPENYAM_ARM_JOINTS, position=_SAFE_POSTURE) + pose = solver.frame_poses(initial, (_TARGET_FRAME,))[_TARGET_FRAME] + target = PoseStamped( + frame_id=pose.frame_id, + position=pose.position, + orientation=Quaternion.from_euler(Vector3(0.0, 0.2, 0.0)) * pose.orientation, + ) + state = initial + for _ in range(10): + command = solver.step({_TARGET_FRAME: target}, state, 0.01) + assert command is not None + state = command + motion = [ + abs(commanded - start) + for commanded, start in zip(state.position, initial.position, strict=True) + ] + return sum(motion[:3]), sum(motion[3:]) + + +@pytest.mark.self_hosted +def test_weighted_solver_shifts_orientation_motion_toward_wrist() -> None: + generic_proximal, generic_wrist = _orientation_target_motion(PinkPoseTargetSolver) + weighted_proximal, weighted_wrist = _orientation_target_motion(OpenYamPinkPoseTargetSolver) + + assert weighted_proximal < generic_proximal * 0.75 + assert weighted_wrist > generic_wrist * 0.9 diff --git a/dimos/robot/test_all_blueprints_generation.py b/dimos/robot/test_all_blueprints_generation.py index 9c4a443fb8..4e3c57ae99 100644 --- a/dimos/robot/test_all_blueprints_generation.py +++ b/dimos/robot/test_all_blueprints_generation.py @@ -116,9 +116,7 @@ def _build_module_class_set(root: Path) -> set[str]: known: set[str] = {"Module", "ModuleBase"} all_classes: list[tuple[str, list[str]]] = [] - for path in sorted(root.rglob("*.py")): - if "__pycache__" in str(path): - continue + for path in sorted(_get_all_python_files(root)): try: tree = ast.parse(path.read_text("utf-8"), str(path)) except Exception: @@ -143,7 +141,8 @@ def _is_production_module_file(file_path: Path, root: Path) -> bool: Excludes test helpers, deprecated code, and framework base classes in core/. """ - rel = str(file_path.relative_to(root)) + relative_path = file_path.relative_to(root) + rel = str(relative_path) stem = file_path.stem return not ( stem.startswith("test_") @@ -154,10 +153,29 @@ def _is_production_module_file(file_path: Path, root: Path) -> bool: or stem.startswith("mock_") or "deprecated" in rel or "/testing/" in rel + # Python-native implementations are private subprocess details. Only + # their host contracts belong in the runnable module registry. + or "python" in relative_path.parts or rel.startswith("core/") ) +def test_python_native_runtime_is_not_a_production_module(tmp_path: Path) -> None: + runtime = tmp_path / "feature" / "python" / "package" / "runtime.py" + + assert _is_production_module_file(runtime, tmp_path) is False + + +def test_python_native_runtime_tree_is_not_scanned(tmp_path: Path) -> None: + contract = tmp_path / "feature" / "module.py" + runtime = tmp_path / "feature" / "python" / "package" / "runtime.py" + runtime.parent.mkdir(parents=True) + contract.write_text("") + runtime.write_text("") + + assert list(_get_all_python_files(tmp_path)) == [contract] + + def _scan_for_blueprints(root: Path) -> tuple[dict[str, str], dict[str, str]]: all_blueprints: dict[str, str] = {} all_modules: dict[str, str] = {} @@ -238,11 +256,20 @@ def _check_for_uncommitted_changes(file_path: Path) -> bool: def _get_all_python_files(root: Path) -> Generator[Path, None, None]: - for path in root.rglob("*.py"): - rel_path = str(path.relative_to(root.parent)) - if "__pycache__" in str(path) or rel_path in IGNORED_FILES: - continue - yield path + """Yield host source files without entering Python-native runtime projects.""" + for directory, directory_names, file_names in os.walk(root): + # A sibling directory named ``python`` is the isolation boundary for a + # PythonNativeModule. Its implementation and .venv are not host code. + directory_names[:] = sorted( + name for name in directory_names if name not in {"__pycache__", "python"} + ) + for file_name in sorted(file_names): + if not file_name.endswith(".py"): + continue + path = Path(directory) / file_name + rel_path = str(path.relative_to(root.parent)) + if rel_path not in IGNORED_FILES: + yield path def _path_to_module_name(path: Path, root: Path) -> str: diff --git a/dimos/robot/unitree/g1/blueprints/perceptive/unitree_g1_shm.py b/dimos/robot/unitree/g1/blueprints/perceptive/unitree_g1_shm.py index 37727eaf9b..d09a03053e 100644 --- a/dimos/robot/unitree/g1/blueprints/perceptive/unitree_g1_shm.py +++ b/dimos/robot/unitree/g1/blueprints/perceptive/unitree_g1_shm.py @@ -27,7 +27,7 @@ unitree_g1.transports( { ("color_image", Image): pSHMTransport( - "/color_image", default_capacity=DEFAULT_CAPACITY_COLOR_IMAGE + "/color_image", queue_size=1, default_capacity=DEFAULT_CAPACITY_COLOR_IMAGE ), } ), diff --git a/dimos/teleop/quest/blueprints.py b/dimos/teleop/quest/blueprints.py index 1fc2ecf837..f1e1229fd0 100644 --- a/dimos/teleop/quest/blueprints.py +++ b/dimos/teleop/quest/blueprints.py @@ -125,7 +125,7 @@ { ("cmd_vel", Twist): LCMTransport("/cmd_vel", Twist), ("color_image", Image): pSHMTransport( - "color_image", default_capacity=DEFAULT_CAPACITY_COLOR_IMAGE + "color_image", queue_size=1, default_capacity=DEFAULT_CAPACITY_COLOR_IMAGE ), } ) diff --git a/pyproject.toml b/pyproject.toml index c2f586161f..0ed730b98b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,10 @@ exclude = [ "**/*.svg", "**/*.tcss", "**/*.pyi", + # Locked Python-native runtime definition. Keep these exact rather than + # shipping arbitrary manifests from the source tree. + "imitation/policy/lerobot/python/pyproject.toml", + "imitation/policy/lerobot/python/uv.lock", ] [tool.setuptools.exclude-package-data] @@ -621,7 +625,7 @@ strict = true warn_unused_ignores = false untyped_calls_exclude = ["zenoh"] explicit_package_bases = true -exclude = "^dimos/models/Detic(/|$)|.*/test_.|.*/tool_.|.*/conftest.py*" +exclude = "^dimos/models/Detic(/|$)|^dimos/imitation/policy/lerobot/python/|.*/test_.|.*/tool_.|.*/conftest.py*" [[tool.mypy.overrides]] module = [ @@ -736,7 +740,7 @@ exclude_also = [ max_size_kb = 75 ignore = [ "uv.lock", - "examples/external_python_module/python/uv.lock", + "*/uv.lock", "*/package-lock.json", "*/Cargo.lock", "dimos/web/dimos_interface/themes.json", diff --git a/setup.py b/setup.py index 9cd153ebc5..b6797b8357 100644 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ def python_is_macos_universal_binary(executable: str | None = None) -> bool: return False -TEST_MODULE_PATTERNS = ("test_*.py", "conftest.py") +TEST_MODULE_PATTERNS = ("test_*.py", "*_tests.py", "conftest.py") # The Deno relay (repo-root web/) ships inside the wheel so a pip-installed # dimos can run it without a checkout. Copied into build_lib below; editable @@ -89,9 +89,17 @@ def find_package_modules(self, package, package_dir): def run(self): super().run() + self._remove_test_modules() if not getattr(self, "editable_mode", False): self._copy_relay_dist() + def _remove_test_modules(self) -> None: + """Remove tests left by either this build or a stale incremental build.""" + build_root = Path(self.build_lib) + for pattern in TEST_MODULE_PATTERNS: + for test_module in build_root.rglob(pattern): + test_module.unlink() + def _copy_relay_dist(self): src = Path(__file__).parent / "web" if not (src / "relay" / "main.ts").is_file():