From a95979cbed997321f276532666957d92acd7319d Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Wed, 22 Jul 2026 13:36:58 +0200 Subject: [PATCH 01/16] componenet config design --- .../captured-component-config-report.md | 361 +++++++ .../robot-construction-remembered-init.md | 945 ++++++++++++++++++ 2 files changed, 1306 insertions(+) create mode 100644 docs/development/captured-component-config-report.md create mode 100644 docs/development/robot-construction-remembered-init.md diff --git a/docs/development/captured-component-config-report.md b/docs/development/captured-component-config-report.md new file mode 100644 index 0000000..2007765 --- /dev/null +++ b/docs/development/captured-component-config-report.md @@ -0,0 +1,361 @@ +# Captured component configuration — design report + +**Audience:** Runtime developers +**Status:** Accepted — implement per rollout +**Canonical spec:** [robot-construction-remembered-init.md](robot-construction-remembered-init.md) +**Context:** Follow-up to [Studio SharedRobot #818](https://github.com/open-edge-platform/physical-ai-studio/pull/818) + +This report is the human-readable map of the design. When behavior is ambiguous, the canonical design note wins. + +![Architecture: behavior, construction, and transport layers](captured-component-config-architecture.png) + +--- + +## One-sentence summary + +Opt-in `@export_config` keeps constructor args as JSON-safe `class_path` + `init_args`, so Studio and SharedRobot/SharedCamera can spawn fresh components **without** special-case serializers or transport imports in plugins. + +--- + +## Why it exists + +```mermaid +flowchart LR + subgraph today [Today] + JA[jsonargparse YAML] -->|instantiate| Live1[Live component] + Live2[Live driver] -.->|cannot recover args| Gap[Gap] + Gap --> Studio[Studio / owner spawn] + end + + subgraph after [After this design] + Live3[Live @export_config component] + Live3 -->|to_config| CC[ComponentConfig] + CC -->|instantiate| Live4[Fresh disconnected component] + CC -->|ComponentConfig stdin| Owner[Owner / publisher child] + end +``` + +| Pain | Fix | +| --------------------------------------------------------------- | ------------------------------------------------ | +| jsonargparse builds trees but cannot reverse a live object | `@export_config` + `to_config` | +| SharedRobot needs class + JSON kwargs across a process boundary | Same `ComponentConfig` in the owner envelope | +| Studio serializers per robot/camera type | Plugins opt in; Studio only checks exportability | +| Putting `to_dict` on `Robot` / `Camera` | Keep protocols behavior-only | + +--- + +## Three-layer mental model + +```mermaid +flowchart TB + subgraph behavior [Runtime behavior — unchanged] + R[Robot] + C[Camera] + A[ActionSource] + end + + subgraph construction [Construction — new] + DI["@export_config"] + TC[to_config / instantiate] + CFG["ComponentConfig
class_path + init_args"] + DI --> TC --> CFG + end + + subgraph transport [Transport envelopes — consume config] + RO[RobotOwnerConfig] + CP[Camera publisher config] + SR[SharedRobot] + SC[SharedCamera] + RO --> SR + CP --> SC + end + + behavior -.->|opt-in only| construction + CFG -->|robot: / camera:| transport +``` + +**Remember:** + +- **Construction** = how to build a fresh instance +- **Transport** = name, rate, `service_name`, timeouts, sharing +- **Exportable ≠ shared** — Studio owns `robot.share`; `@export_config` only enables config export + +--- + +## Public API (`physicalai.config`) + +```python +ComponentConfig = {"class_path": str, "init_args": dict[str, JsonValue]} + +@export_config # opt-in on concrete classes +to_config(value) # live → ComponentConfig +instantiate(config) # trusted ComponentConfig → fresh object +is_config_exportable # decorator marker OR __component_config__ +``` + +```mermaid +sequenceDiagram + participant App + participant Export as @export_config + participant API as physicalai.config + participant Child as Owner/publisher + + App->>Export: SO101(port, calibration) + Export-->>App: live robot + stored init_args + App->>API: to_config(robot) + API-->>App: ComponentConfig + App->>Child: ComponentConfig stdin JSON (same cwd) + Child->>API: instantiate(robot) + API-->>Child: fresh SO101 (not connected) +``` + +Library code always uses module-level `to_config(value)` (type-checker friendly). An injected instance method is optional sugar only. + +--- + +## End-to-end data flow + +```mermaid +flowchart LR + subgraph parent [Parent process] + B[Builder / CLI / Studio] + T[to_config] + Env[Transport envelope
robot/camera ComponentConfig] + B --> T --> Env + end + + subgraph child [Child process same cwd] + Val[Validate new shape] + Inst[instantiate] + Proto[Check Robot/Camera] + Conn[connect later] + Val --> Inst --> Proto --> Conn + end + + Env -->|trusted stdin JSON| Val +``` + +**Trust rule:** `instantiate()` is for trusted local / parent→child config only. Never run it on Zenoh metadata, camera metadata, or peer payloads. + +--- + +## What gets exported (v1) + +```mermaid +flowchart TB + RR[RobotRuntime] + RR --> Robot + RR --> AS[ActionSource] + RR --> Cams[cameras map] + RR --> Cbs[callbacks] + + Robot --> SO101 + Robot --> WidowXAI + Robot --> Bimanual["BimanualWidowXAI
left + right nested"] + + AS --> PS[PolicySource] + AS --> TS[TeleopSource] + + PS --> IM[InferenceModel
path-rooted] + PS --> Ex[Sync / Async / RTC Execution] + PS --> Q[Chunked / RTC ActionQueue] + Q --> Sm[Lerp / Replace Smoother] + + Cams --> UVC[UVC / RealSense / …] + Cbs --> CB[Console / LPF / Jsonl / Rerun / Async] +``` + +| Area | v1 rule | +| ------------------------ | ----------------------------------------------------------------------------------------- | +| Defaults | Capture only supplied args — omit ⇒ current ctor defaults on replay | +| `PolicySource` | Default queue uses **LerpSmoother**; bare `ChunkedActionQueue()` uses **ReplaceSmoother** | +| `InferenceModel` | Path-rooted (`export_dir`, scalars); live runner/processor overrides fail `to_config` | +| `TeleopSource.to_action` | Replayable only when omitted | +| Bimanual | One SharedRobot owner for the composite | +| Callbacks | Exact first-party set; observers with post-hoc `session_id` out of scope | + +--- + +## Transport migration (private wire) + +Do **not** bump `ROBOT_TRANSPORT_PROTOCOL_VERSION` or camera frame `PROTOCOL_VERSION` for this. Those are network/frame protocols. Startup stdin is a same-package `Popen` handshake — **hard cutover** to `ComponentConfig` with no `config_format` and no dual-read (see [wire decision](shared-construction-wire-decision.md)). + +```text +# Robot owner — after +{name, robot: {class_path, init_args}, allow_remote, rate_hz, idle_timeout, …} + +# Camera publisher — after +{camera: {class_path, init_args}, service_name, idle_timeout, …} +``` + +Writers and readers speak only the new shape. Legacy flat stdin (`robot_class` / `camera_type`) is rejected before import. + +```mermaid +flowchart TB + subgraph robot_stdin [Robot owner stdin] + R2[name, rate_hz, …] + R3["robot: {class_path, init_args}"] + end + + subgraph cam_stdin [Camera publisher stdin] + C2[service_name + transport fields] + C3["camera: {class_path, init_args}"] + end +``` + +### SharedCamera naming + +| Mode | `service_name` | +| ----------------- | -------------------------------------------------------------------------- | +| Built-in spawn | Derived in transport: `class_path` → legacy `CameraType` token + device id | +| Third-party spawn | **Required** explicit name (no hashing) | +| Attach-only | Required; no construction config | + +`service_name` lives **beside** `camera: ComponentConfig`, never inside `init_args`. + +### Public API (adapters) + +| Surface | Accept (XOR) | Preferred | +| ---------------------- | ------------------------------------------------------- | ----------------------------- | +| `SharedRobot` | `robot=` **xor** `robot_class`+`robot_kwargs` (adapter) | `from_config` / `from_robot` | +| `SharedCamera` | `camera=` **xor** `camera_type`+kwargs (adapter) | `from_config` / `from_camera` | +| CLI `physicalai robot` | `--robot` **xor** legacy flags (adapter) | `--robot` | +| Metadata | Keep key `robot_class` | Value = public `class_path` | + +Legacy flat forms always pack `ComponentConfig` and write only the new stdin. Removing adapters is a later cleanup PR. + +`from_robot` / `from_camera`: require exportable, **reject if connected**, never disconnect implicitly. + +--- + +## Paths: relative-first, cwd at open + +```mermaid +flowchart LR + subgraph export [to_config] + P1["Path → str(path) as given"] + P2["str unchanged
e.g. ./calibration.json"] + end + + subgraph resolve [instantiate / ctor open] + P3[Resolve against process cwd] + P4[Child inherits parent cwd at Popen] + P3 --> P4 + end +``` + +- Prefer relative paths for project- or folder-local configs (portable export directories). +- Do not absolutize in `to_config` or IPC writers. +- Unsupported: `chdir` between export and spawn when configs still contain relatives. +- Future bundle/export-folder root may pin resolution without absolutizing — not v1. + +--- + +## Studio consumption + +```mermaid +flowchart TD + Builder[Plugin builder] --> Driver[Ordinary Robot/Camera] + Driver --> Share{robot.share?} + Share -->|no| Direct[In-process driver] + Share -->|yes| Exp{is_config_exportable?} + Exp -->|no| Fail[Fail loud] + Exp -->|yes| Conn{connected?} + Conn -->|yes| Fail2[Fail — caller must disconnect] + Conn -->|no| SR[SharedRobot.from_config] +``` + +Plugins never import Zenoh, iceoryx2, `SharedRobot`, or `SharedCamera`. + +--- + +## Security (developer checklist) + +| Do | Don't | +| ----------------------------------------------------- | ----------------------------------------------------- | +| Treat `class_path` as executable trusted config | Instantiate configs from `/metadata` or network peers | +| Validate malformed input before import | Assume validation = sandbox | +| Carry config parent→child stdin only | Accept component configs from subscribers | +| Keep nesting depth bounded (`_MAX_CONFIG_DEPTH = 10`) | Unbounded recursive instantiate | + +See also [security.md](security.md) rules 4, 5, 11, 12. + +--- + +## Out of scope for v1 + +- Inference `ComponentSpec` / `instantiate_component` unification (different defaults, extras, registry) +- Self-contained portable bundles / `to_config(..., profile="self_contained")` / explicit bundle root +- `__component_path_keys__` / public path absolutizers +- Callable references for `TeleopSource.to_action` +- Per-arm SharedRobot for bimanual nests +- Persisted workflow document versioning + +--- + +## Implementation rollout + +```mermaid +gantt + title Suggested implementation order + dateFormat X + axisFormat %s + + section Core + physicalai.config engine :a1, 0, 1 + export_config + to_config :a2, 1, 2 + SO101 / WidowX / Bimanual :a3, 2, 3 + + section Transport + SharedRobot + owner hard cutover :b1, 3, 4 + SharedCamera + publisher cutover :b2, 4, 5 + + section Runtime tree + PolicySource graph + InferenceModel :c1, 5, 6 + RobotRuntime + callbacks :c2, 6, 7 + + section Downstream + Studio share policy :d1, 7, 8 + Docs / preview versioning note :d2, 8, 9 +``` + +| Step | Deliverable | +| ---: | -------------------------------------------------------------------------------------------- | +| 1 | `ComponentConfig`, instantiate, depth/cycles, shared importer (no inference behavior change) | +| 2 | `@export_config`, `to_config`, `is_config_exportable` | +| 3 | SO101, WidowXAI, BimanualWidowXAI round-trips | +| 4 | SharedRobot + owner stdin hard cutover + public/CLI adapters (cwd inherit for relatives) | +| 5 | SharedCamera + publisher stdin hard cutover + `service_name` rules | +| 6 | PolicySource graph, TeleopSource, path-rooted InferenceModel, v1 callbacks | +| 7 | RobotRuntime -> `instantiate` + jsonargparse | +| 8 | Studio drops interim serializers | +| 9 | User-facing docs | +| 10 | Separate: inference factory follow-up (optional) | + +Implement **one step at a time**. Keep the design note open as the contract. + +--- + +## Plugin checklist + +1. Implement `Robot` / `Camera` / `ActionSource` / callback protocol +2. `@export_config` (or `__component_config__`) +3. JSON-normalizable args; constructors accept normalized forms +4. Relative path args OK; keep cwd stable through Shared\* spawn (or use absolute/`Path` as given) +5. Stable public import path + `__config_class_path__` when re-exported +6. Round-trip test: `to_config` → `json` → `instantiate` → `to_config` equal + +--- + +## Quick reference + +| Symbol | Role | +| --------------------------- | ------------------------------------------------------------------- | +| `@export_config` | Opt into `ComponentConfig` export (stores supplied `__init__` args) | +| `ComponentConfig` | Wire shape shared with jsonargparse | +| `to_config` / `instantiate` | Export / trusted rebuild | +| `is_config_exportable` | Can we get a recipe? | +| Owner/publisher stdin | Hard cutover to `robot:` / `camera: ComponentConfig` | +| `service_name` | Camera transport identity (not construction) | + +**Full rules and required tests:** [robot-construction-remembered-init.md](robot-construction-remembered-init.md) diff --git a/docs/development/robot-construction-remembered-init.md b/docs/development/robot-construction-remembered-init.md new file mode 100644 index 0000000..f308c47 --- /dev/null +++ b/docs/development/robot-construction-remembered-init.md @@ -0,0 +1,945 @@ +# Captured component configuration (design note) + +Status: **accepted — implement per rollout**. Follow-up to Studio SharedRobot +integration +([physical-ai-studio#818](https://github.com/open-edge-platform/physical-ai-studio/pull/818)); +out of scope for that PR, now ready for Runtime implementation. + +## Decision + +Add one opt-in, transport-agnostic decorator for components whose constructor +configuration must be exportable so a fresh instance can be created later. + +```text +Robot / Camera / ActionSource = runtime behavior (unchanged) +@export_config = opt into ComponentConfig export from ctor args +ComponentConfig = plain class_path + init_args data +``` + +Use the same contract for robots, cameras, action sources, runtimes, +callbacks, and nested components. Keep process names, rates, timeouts, and +other transport settings in their existing transport envelopes. + +The contract closes both directions: + +```text +live component -> to_config(value) -> JSON/YAML -> instantiate(config) -> new component +``` + +It uses the same `class_path` + `init_args` vocabulary already consumed by +jsonargparse runtime configs. + +## Problem + +jsonargparse can instantiate a component tree from typed constructor +signatures, but it cannot recover constructor inputs from an arbitrary live +object. `SharedRobot` has the same gap: owner spawn needs an importable class +and JSON-safe constructor arguments, not a live driver with open hardware +resources. + +Studio must not special-case SO101, WidowXAI, camera, runtime, or action-source +serializers. Plugins must not import `SharedRobot` or `SharedCamera`. Adding +serialization methods to `Robot`, `Camera`, or `ActionSource` would mix +construction with runtime behavior. + +## Goals + +1. Export an opted-in live component as JSON-safe `class_path` + + `init_args` data. +2. Instantiate that data as a new component without invoking lifecycle methods + beyond its constructor. +3. Recursively support nested components and collections of components. +4. Emit nested `class_path` + `init_args` fragments that jsonargparse accepts + without translation. A root config may still sit under a CLI wrapper key + such as `runtime:`. +5. Keep hardware and action-source protocols unchanged. +6. Let third-party plugins opt in without depending on transports. +7. Fail at serialization or validation time when replay is not possible. + +## Non-goals + +- Snapshot live state, open handles, queues, threads, or connections. +- Reflect over arbitrary objects and guess how to reconstruct them. +- Serialize lambdas, closures, or arbitrary callables. +- Make untrusted `class_path` imports safe. +- Replace transport configuration such as robot names or camera service names. +- Guarantee that mutation after construction appears in the config. + +## Public API + +Place this API in a neutral module such as `physicalai.config`, +not under robot, capture, runtime, or inference. + +```python +from typing import TypedDict + +JsonScalar = None | bool | int | float | str +JsonValue = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] + + +ComponentConfig = TypedDict("ComponentConfig", {"class_path": str, "init_args": dict[str, JsonValue]}) + + +def export_config(cls: type) -> type: ... + + +def to_config(value: object) -> ComponentConfig: ... + + +def instantiate(config: ComponentConfig) -> object: ... + + +def is_config_exportable(value: object) -> bool: ... +``` + +Normal use stays small: + +```python +@export_config +class SO101: + def __init__(self, port: str, calibration: dict | str): ... + + +robot = SO101("/dev/ttyACM0", "./calibration.json") +config = to_config(robot) +restored = instantiate(config) +``` + +The decorator can also inject `robot.to_config()` as a runtime convenience. +Library code and documentation always use module-level `to_config(robot)` +because static type checkers cannot express that a class decorator adds an +instance method without changing the decorated class's declared type. This +avoids introducing a public protocol only to make the convenience method +type-check. Structural `Protocol` checks ignore the extra method, so the +injection does not break robot/camera/action-source conformance. + +`ComponentConfig` is a typed description of the existing jsonargparse wire +shape, not a stateful model with its own methods. Callers can pass ordinary +dictionaries loaded from JSON or YAML to `instantiate()`; it validates them +before importing code. `instantiate()` is the one shared, explicit trusted-code +boundary: it imports `class_path`, recursively instantiates nested configs, +then calls the class with decoded keyword arguments. + +The implementation rejects local classes (``), non-class import +targets, non-string mapping keys, malformed nested configs, and values outside +the supported JSON model. + +Do not add a public `Constructible` or `Replayable` protocol. Runtime and +Studio code that needs to test exportability uses `is_config_exportable(value)`. +A value is exportable if and only if it carries the private `@export_config` +decorator marker **or** provides a callable `__component_config__` hook. +`to_config(value)` uses the same predicate. This keeps the plugin-facing +contract to one decorator plus one private escape hatch, and gives +`share_if_requested` a single key for both paths. + +### Naming + +Use `export_config` because it names the public capability: the class can export +a `ComponentConfig` via `to_config`. That aligns with `is_config_exportable` +and does not claim to serialize live object state. The decorator works by +remembering supplied `__init__` arguments; that mechanism is an implementation +detail documented here, not part of the public name. + +Rejected names: + +| Name | Problem | +| --------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `capture_init` | Collides with the `physicalai.capture` camera package | +| `record_init` | Will collide with planned runtime recording callbacks | +| `serializable` | Implies complete object-state serialization | +| `configurable` | Usually means an object accepts configuration, not that it exports it | +| `replayable` | Does not say what is replayed and can suggest runtime/action replay | +| `reconstructible` | Accurate but cumbersome and still hides the config-export contract | +| `remember_init` / `snapshot_init` | Mechanism-only; weaker fit next to `to_config` | +| `config_exportable` | Accurate but clumsy as a class decorator | +| `export_init` | Precise about ctor args, but understates hook-only export and rhymes less with `is_config_exportable` | +| `has_captured_init` | Misleading for hook-only classes; use `is_config_exportable` | + +Use `to_config(value)` for export because the result is directly consumable +jsonargparse component configuration. Use `instantiate()` for the trusted +inverse because it creates a fresh instance; avoid `deserialize()`, which +suggests restoring prior object state. + +### Relationship to inference `ComponentSpec` + +Inference factory unification is explicitly out of scope for v1. +`ComponentSpec` is a manifest compatibility model with registry aliases, flat +parameters, artifact handling, and `extra="allow"` semantics. Tightening its +class-path mode or routing registry mode through `instantiate()` changes the +manifest schema and inference critical path; the robot/camera construction use +case does not require either change. + +For v1: + +- `ComponentConfig` owns strict `class_path` + `init_args` validation and + bounded recursive instantiation in `physicalai.config`. +- `ComponentSpec`, `ComponentSpec.from_class()`, `_MAX_COMPONENT_DEPTH`, and + `instantiate_component()` retain their current behavior. +- Rollout step 1 consolidates the duplicated inference and robot dotted-path + import helpers into `physicalai.config` in a behavior-preserving refactor. + Transports call `instantiate()` and add protocol checks; they do not add + another importer. + +A follow-up design can audit exported manifest fixtures, then decide whether +`ComponentSpec` class-path mode should delegate to `ComponentConfig`. That +work must address two known differences: `ComponentSpec.from_class()` applies +defaults while `@export_config` omits unsupplied defaults, and class-path extra +fields are currently accepted and ignored. Registry mode remains an inference +adapter even if class-path construction is later shared. + +Use `_MAX_CONFIG_DEPTH = 10` for the new config engine. Count traversal through +lists and mappings as well as nested component configs, and track container +identities to reject cycles before JSON serialization. Do not apply this +stricter counting rule to existing inference manifests in v1. + +## Exporting constructor configuration + +`@export_config` is the opt-in contract. Constructor signature changes flow +into exported configuration automatically: + +```python +@export_config +class PolicySource(ActionSource): + def __init__(self, model, execution=None, action_queue=None, *, task=None): + self._execution = execution or SyncExecution() + self._action_queue = action_queue or ChunkedActionQueue(...) +``` + +The wrapper uses `inspect.signature()` and `Signature.bind()` before invoking +the constructor. It converts positional arguments to their parameter names, +removes `self`, and flattens a bound `**kwargs` mapping into `init_args`. +Positional-only parameters and `*args` are not replayable through +`class_path` + keyword `init_args`; reject decorated constructors that declare +them. + +Do not call `BoundArguments.apply_defaults()`. Capture only arguments the +caller supplied. Omitted arguments remain omitted so reconstruction invokes +the current constructor defaults. An explicitly supplied `None` remains in +the spec. This produces compact configs and avoids freezing default component +objects selected internally by the current release. + +The wrapper must preserve `__wrapped__` and the original signature through +`functools.wraps()`. jsonargparse and documentation tools must continue to see +the real constructor signature. + +Call the constructor before committing the captured arguments. A failed +constructor must not leave a usable construction record. Do not normalize or +reject captured values during `__init__`; unsupported values fail later when +`to_config(value)` is requested, so opting in does not change ordinary +construction behavior. + +Snapshot built-in mutable containers recursively when capturing them. Keep +nested decorated components and explicit domain values by reference so their +own conversion remains authoritative. This prevents later mutation of a +caller-owned list or dictionary from silently changing the recipe. + +### Inheritance rule + +Decorate every concrete class that should export constructor configuration. Do +not decorate a shared base solely to make all subclasses exportable: a base +signature cannot capture arguments owned by a subclass. + +The decorator tracks construction depth on the instance. If decorated +constructors call other decorated constructors through `super()`, only the +outermost successful call commits its bound arguments. This prevents a camera +base constructor from overwriting the complete concrete-camera recipe with +only `color_mode`. + +A concrete subclass that overrides `__init__` must apply `@export_config` +again. Tests and contributor documentation must state this explicitly. + +There is no merge or last-write behavior. The outermost decorated constructor +owns the complete `init_args`. `to_config(value)` verifies that the +most-derived class's effective `__init__` carries the decorator marker **or** +that the instance provides `__component_config__`; an overriding, undecorated +`__init__` without the hook fails loudly instead of emitting a partial recipe. +A subclass that inherits a decorated constructor unchanged remains valid +because its effective constructor has the marker and the inherited signature +is complete. + +Select `class_path` from the most-derived `type(self)`, never from the class +that defined an inner decorated constructor. Classes can declare a stable +`__config_class_path__` public re-export; otherwise use +`type(self).__module__ + "." + type(self).__qualname__`. Before emitting the +spec, resolve the selected path and verify that it identifies exactly +`type(self)`. + +First-party public robot and camera classes must declare their stable public +re-export path (for example, `physicalai.robot.SO101`) instead of emitting an +internal defining-module path. Plugins must do the same when they promise a +stable public import surface. + +Factory or legacy classes whose configuration does not correspond directly to +one constructor call can implement a private `__component_config__()` hook. +`to_config(value)` and `is_config_exportable(value)` recognize that hook. This +is an internal escape hatch, not a second public protocol; such classes are +the exception, not the default. + +`to_config(value)` describes the object **as constructed**, not its current +mutable state. + +## Serialization and decoding rules + +Normalization is recursive and produces JSON-safe values. + +| Constructor value | Stored value | Value passed on replay | +| -------------------------------------------- | ----------------------------- | ---------------------------- | +| `str`, `int`, finite `float`, `bool`, `None` | as-is | as-is | +| non-finite `float` (`NaN`, `±Inf`) | error | not applicable | +| `Path` | string as given (`str(path)`) | string | +| `Enum` | JSON-safe `.value` | enum value representation | +| decorated or hook-exportable component | `{class_path, init_args}` | instantiated component | +| mapping with string keys | normalized mapping | decoded mapping | +| list or tuple | normalized list | list | +| domain value with an explicit codec | codec output | constructor-compatible value | +| any other object | error | not applicable | + +`Path` values serialize as `str(path)` **without** `resolve()`. Relative paths +stay relative; absolute paths stay absolute. Prefer relative paths for +project- or folder-local configs so an exported directory of config + +artifacts remains portable. Resolution happens at `instantiate` / +constructor open against the process cwd (see Transport integration). + +Reject non-finite floats during normalization. Python's default `json` encoder +permits `NaN` / `Infinity`, which are not portable across strict JSON +consumers. + +For v1, domain codecs remain minimal. `SO101Calibration` can normalize with +`to_dict()` because the SO101 constructor already accepts a dictionary. Do not +add a global arbitrary-object codec registry until a second concrete domain +type requires one. + +Constructors participating in this contract must accept the normalized JSON +representation of their arguments. For example, constructors typed with an +enum must also accept its string value or normalize it internally. This keeps +direct `instantiate()` and jsonargparse reconstruction +equivalent. + +This requirement is enforced at the opt-in boundary. Every first-party class +gains a test that serializes through `json.dumps()` / `json.loads()`, invokes +its constructor through `instantiate()`, and verifies normalized +re-serialization. A class does not ship with `@export_config` until that test +passes. Provide small constructor-side coercion helpers for repeated types such +as `StrEnum`; do not build a general annotation-driven coercion engine. Plugins +have the same mandatory JSON-boundary round-trip test. + +Any dictionary containing `class_path` is reserved as a nested component +config. Its only allowed keys are `class_path` and `init_args`; omitted +`init_args` means an empty mapping. Extra keys or invalid values are malformed +configs, not ordinary dictionaries. A caller that needs `class_path` as a normal +data key must use an explicit domain codec or wrapper. Error messages and the +plugin contract must name this escape hatch. This rule removes ambiguity and +fails closed during generic recursive deserialization. Do not add a sentinel +marker unless a second concrete domain collision demonstrates the need. + +Errors identify the full argument path: + +```text +physicalai.runtime.TeleopSource.init_args.to_action: +cannot encode function ''; omit it or use a supported component value +``` + +## Round-trip contract + +For every opted-in component `value`, tests establish: + +```python +config = to_config(value) +wire = json.loads(json.dumps(config)) +restored = instantiate(wire) +``` + +The guarantee is construction equivalence, not object equality: + +- `type(restored)` matches the represented class. +- `instantiate()` invokes constructors but does not call lifecycle methods such + as `connect()`, `start()`, or `run()`. +- Constructors may still read files, allocate memory, initialize SDKs, or load + models according to their existing contract. +- Calling `to_config(restored)` produces the same normalized config. +- Connecting it addresses the same configured hardware or service. +- Runtime-selected defaults may change between package versions when the + original constructor argument was omitted or `None`. + +Round-trip tests use the public import path emitted in `class_path`, not only +the defining module path. Public paths keep serialized configs stable across +internal module moves. + +### External references and portability + +A component config is a replay recipe, not necessarily a self-contained +artifact. Classify emitted values in two practical profiles: + +- **Local replay** permits paths and other external references. Relative + paths are preferred for project- or folder-local configs. Owner/publisher + subprocess IPC inherits the parent cwd at `Popen` (see Transport + integration); that is the v1 resolution root. +- **Self-contained replay** requires domain values to be inline and rejects + unresolved external dependencies. For example, SO101 calibration must be an + inline dictionary rather than a path. + +The full runtime example below is a local-replay config because both calibration +and `export_dir` reference the filesystem. Relative paths stay meaningful when +the working directory (or a future exported folder layout) is the project root. +A future config-bundling API can copy referenced artifacts and keep or rewrite +relative paths; that is separate from construction replay. + +`to_config(value)` emits local replay by default. A later +`to_config(value, profile="self_contained")` API should be added only when a +concrete portable-export workflow owns artifact bundling and validation. + +`ComponentConfig` has no version field in v1. It is a nested jsonargparse shape, +not an owned persistent document format. IPC envelopes version their own wire +formats as described below. Before advertising generated runtime YAML as a +stable persisted artifact, define a versioned root document that contains the +runtime component config; do not add a version field to every nested component. + +## Full runtime example + +A `RobotRuntime` config recursively contains all participating components: + +```yaml +class_path: physicalai.runtime.RobotRuntime +init_args: + robot: + class_path: physicalai.robot.SO101 + init_args: + port: /dev/ttyACM0 + calibration: ./calibration.json + action_source: + class_path: physicalai.runtime.PolicySource + init_args: + model: + class_path: physicalai.inference.InferenceModel + init_args: + export_dir: ./exports/act_policy + fps: 30 + cameras: + wrist: + class_path: physicalai.capture.UVCCamera + init_args: + device: /dev/video0 + width: 640 + height: 480 + fps: 30 +``` + +This nested fragment is accepted by jsonargparse without translation. For the +existing CLI parser it is nested under `runtime:`; trusted local code may also +pass the bare fragment to `instantiate()`. + +## Component-specific decisions + +### Robots + +SO101 and WidowXAI opt in directly. SO101 calibration is stored as a path when +constructed from a path and as a normalized dictionary when constructed from +an object. + +Private arguments are not excluded by naming convention. If replay needs an +argument, capture it. `SO101.uncalibrated()` currently calls the constructor +with `_allow_uncalibrated=True`, so the decorated outer constructor must retain +that supplied private argument. Uncalibrated round-trip tests are required +before claiming SO101 support; a future public constructor parameter can +replace the private replay detail. + +Bimanual robots no longer need a special serialization design once each arm +uses `@export_config`: `left` and `right` become nested configs under +`BimanualWidowXAI`. v1 `SharedRobot` spawn treats the composite as **one +owner**. Per-arm `SharedRobot` wrapping of nested arms is out of scope. + +### Cameras + +Camera implementations opt in using public class paths. This supports +third-party cameras without extending the `create_camera()` registry. + +Today's `CameraSpec(camera_type, camera_kwargs)` cannot preserve a third-party +class path because `build()` routes through the built-in `create_camera()` +registry. Supporting third-party replay therefore requires a semantic +transport migration, not a wrapper translation. + +Change the private publisher envelope to contain `camera: ComponentConfig` +plus transport fields including `service_name`; `build()` delegates to +`instantiate()` and verifies the result satisfies `Camera`. This is a private +startup-wire **hard cutover** in the same PR as the SharedCamera spawn path: +rewrite the envelope, update fixtures/tests, and drop the legacy +`camera_type` + `camera_kwargs` reader. No `config_format` field and no +dual-read. Normalize built-in names to public class paths in the new shape. +Third-party camera sharing is not supported until this step lands. + +#### SharedCamera service naming + +Transport owns naming; `ComponentConfig` never embeds `service_name`. + +Today spawn derives +`physicalai/camera/{camera_type}/{device_id}/frame` from the registry enum. +After the envelope migration there is no `camera_type` on the construction +config. Rules: + +- **Built-in spawn:** a private map from public `class_path` to the legacy + `CameraType` token (`uvc`, `ip`, `realsense`, `basler`, `genicam`). That map + lives in **capture transport**, not in `physicalai.config`. Derive + `service_name` with the existing scheme using that token plus device id from + `init_args` (`serial_number` else `device`, including `/dev/` symlink + resolve). This preserves existing attacher discovery. +- **Third-party spawn:** require an explicit `service_name` on `SharedCamera` / + the publisher envelope; fail before publisher start if missing. Do not hash + `class_path` or `init_args` into a name (unstable across arg ordering and + omitted defaults). +- **Attach-only:** unchanged — `service_name` required; no construction config + needed. +- The publisher payload carries `service_name` **alongside** + `camera: ComponentConfig`, never inside `init_args`. + +### Action sources + +v1 first-party graph that must round-trip under `PolicySource` (anything else +fails at `to_config`): + +| Class | Notes | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `PolicySource` | Captures `model`, optional `execution`, `action_queue`, `task` | +| `InferenceModel` | Path-rooted (see below) | +| `SyncExecution` | | +| `AsyncExecution` | | +| `RTCExecution` | JSON-safe / scalar constructor args only; `latency_tracker` and live `postprocessors` must be omitted or `to_config` fails | +| `ChunkedActionQueue` | Includes nested `smoother` when supplied | +| `RTCActionQueue` | | +| `LerpSmoother` | Required when an explicit `ChunkedActionQueue(smoother=...)` is captured | +| `ReplaceSmoother` | Same | + +Omitted `execution` / `action_queue` stay omitted so reconstruction uses +current constructor defaults. `PolicySource` itself defaults to +`SyncExecution()` and `ChunkedActionQueue(smoother=LerpSmoother(...))`. +Bare `ChunkedActionQueue()` (smoother omitted) restores `ReplaceSmoother()` — +that is the queue's own default, not the PolicySource default. Explicit nested +values must themselves be exportable. + +`TeleopSource.leader` can be a nested robot config. Its optional `to_action` +callable is replayable only when omitted in v1. Module-level callable support +can be added later with a distinct callable-reference type; do not treat +arbitrary dotted paths as both classes and functions in `ComponentConfig`. + +### Inference model + +`InferenceModel.__init__` eagerly reads the manifest, creates an adapter, and +loads model artifacts. In v1 it is a path-rooted component: capture +`export_dir`, `policy_name`, `backend`, `device`, and JSON-safe scalar adapter +kwargs. Omitted runner, processor, and callback overrides remain omitted and +are reconstructed from the exported package. + +Non-scalar or live override arguments among captured kwargs (including +non-scalar `adapter_kwargs`, and live `runner` / `preprocessors` / +`postprocessors` / `callbacks`) make `to_config` fail. Do not silently drop +them. Live overrides are in scope only when every nested value independently +exports component config or `InferenceModel` supplies the private manual +config hook. Instantiating an inference config can allocate substantial +resources; it is not analogous to constructing a disconnected robot driver. + +### Runtime + +`RobotRuntime` captures its robot, action source, FPS, camera mapping, and +callbacks. A runtime config is therefore the root of a complete jsonargparse +workflow config. It never captures connection state, session IDs, callback +bus state, observations, or action queues created during a run. + +v1 first-party callbacks that must round-trip when supplied (anything else +fails at `to_config`): + +| Class | Notes | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `ConsoleCallback` | scalar args | +| `LowPassFilterCallback` | scalar args | +| `JsonlCallback` | path arg; `Path` → `str(path)` as given; ctor opens the file immediately (eager I/O like `InferenceModel`); tests use temp paths | +| `RerunCallback` | JSON-safe / scalar args only (`save_path`, `connect_addr`, mode, …) | +| `AsyncCallback` | nested `inner` must itself be exportable; reject inners with action hooks per the existing `AsyncCallback` guard | + +Omitted or empty `callbacks` stay omitted or empty. Observer helpers under +`runtime/observer/` that take `session_id` after construction are out of scope +for `RobotRuntime` capture in v1. + +## Transport integration + +Keep construction and transport ownership separate: + +```python +SharedRobot.from_robot(robot, name="follower") +SharedRobot.from_config(to_config(robot), name="follower") +``` + +`SharedRobot.from_robot()` is sugar only. It requires `is_config_exportable(robot)`, +rejects a connected driver via `robot.is_connected()`, then calls +`from_config(to_config(robot), ...)`. It must not scrape constructor kwargs +ad hoc. It never disconnects a caller-owned live driver implicitly. Studio +builders must return disconnected drivers for wrapping, or Studio must +explicitly release a driver it owns before calling `from_robot()`. Prefer +`from_config()` when no live instance is otherwise needed. + +### Private startup envelopes (hard cutover) + +Changing `RobotOwnerConfig` from `robot_class` + `robot_kwargs` to +`robot: ComponentConfig` also changes private startup JSON. Parent and child +are always the same installed package at `Popen`, and stdin is ephemeral — not +a persisted document or peer protocol. Hard-cutover both envelopes in the same +PR as the Shared\* spawn path: rewrite writers, readers, and fixtures; do **not** +add a `config_format` field, dual-read, or shape-detection fallback. + +```text +# Robot owner stdin — before +{name, robot_class, robot_kwargs, allow_remote, rate_hz, idle_timeout, …} + +# Robot owner stdin — after +{name, robot: {class_path, init_args}, allow_remote, rate_hz, idle_timeout, …} + +# Camera publisher stdin — before +{camera_type, camera_kwargs, service_name, idle_timeout, …} + +# Camera publisher stdin — after +{camera: {class_path, init_args}, service_name, idle_timeout, …} +``` + +Writers and readers speak only the new shape. Reject payloads that still carry +legacy flat keys (`robot_class` / `robot_kwargs`, or `camera_type` / +`camera_kwargs`) before import or hardware access — no silent translation. + +Do not reuse `capture.transport.PROTOCOL_VERSION` or +`ROBOT_TRANSPORT_PROTOCOL_VERSION` for these changes. Those version frame and +robot network payloads respectively, not the one-shot stdin construction +envelopes. Existing detached owners/publishers are discovered through their +current transport protocols and do not receive a new startup config. + +Decision record: [shared construction wire decision](shared-construction-wire-decision.md). + +### Paths and cwd for local replay / IPC + +Relative paths are preferred for project- and folder-local configs (for +example an exported directory that contains `runtime.yaml`, calibration, and +model artifacts together). Do **not** rewrite paths to absolute in `to_config` +or in IPC writers — that fights portable folder export and is easy to get +wrong for non-path strings (URLs, roles, `host:port`). + +v1 rules: + +1. `to_config`: `Path` → `str(path)` as given; `str` paths unchanged. Relative + stays relative; absolute stays absolute. +2. Resolution is against the **process cwd** at `instantiate` / constructor + open (for example `Path(calibration).read_text()`). +3. Owner/publisher children are started with `Popen` and **no `cwd=` + override**, so they inherit the parent cwd at spawn. That **is** the v1 + IPC contract for relative paths — the same behavior today’s transport + already uses. +4. **Unsupported:** calling `os.chdir` (or otherwise changing the process cwd) + between capturing/exporting a relative-path config and owner/publisher + spawn when those configs still contain relatives. +5. Future: an explicit bundle/export-folder root may pin resolution without + absolutizing paths; that is out of scope for v1. + +`port` remains absolute (`/dev/…`); relative serial ports are unsupported. +Camera URL/stream fields are not filesystem paths. The built-in camera +`class_path` → `CameraType` map lives in **capture transport**, not in +`physicalai.config`. + +### Public SharedRobot, CLI, and metadata + +Private stdin hard cutover does not force a public API break. Keep legacy flat +kwargs as **adapters** that pack `ComponentConfig` and write only the new +stdin. Removing those adapters is a later cleanup PR, not a calendar dual-read +window on the private wire. + +- **`SharedRobot` constructor:** accept `robot: ComponentConfig` **XOR** + legacy `robot_class` + `robot_kwargs`. Passing both is an error — no merge. + Legacy flat kwargs are an adapter only; they immediately become + `robot: {class_path, init_args}` before spawn. New code and docs prefer + `SharedRobot.from_config(...)` and `from_robot(...)`. +- **Public path normalization:** when accepting a class object or a legacy + dotted path, normalize through the same public-path resolution / + `__config_class_path__` used by `to_config` **before** store, metadata + advertise, and conflict compare. This prevents false mismatches between + defining-module paths (for example + `physicalai.robot.so101.so101.SO101`) and public re-exports + (`physicalai.robot.SO101`). +- **CLI `physicalai robot`:** accept `--robot` (ComponentConfig JSON/YAML) + **XOR** legacy `--robot_class` / `--robot_kwargs`; both paths write only the + new stdin shape. +- **Network metadata:** do not rename the advertised key. Keep `robot_class` as + the metadata field so `ROBOT_TRANSPORT_PROTOCOL_VERSION` stays unchanged. + Populate it from the normalized public `robot["class_path"]`. Conflict + checks compare that string unchanged. Attach-only / discover paths are + unchanged. + +### Public SharedCamera + +Mirror the SharedRobot public story so step-5 implementers do not invent +three APIs: + +- **`SharedCamera.from_config(camera, *, service_name=None, …)`** — primary + API. Transport kwargs (zero-copy, validation, idle timeout, …) stay on the + SharedCamera / publisher side, never inside `ComponentConfig`. +- **`SharedCamera.from_camera(camera, *, service_name=None, …)`** — sugar: + require `is_config_exportable(camera)`, reject if `camera.is_connected()` + (fail before publisher spawn), never disconnect a caller-owned live camera + implicitly, then `from_config(to_config(camera), …)`. No ad-hoc kwargs + scrape. +- **Constructor adapter:** accept `camera: ComponentConfig` **XOR** legacy + `camera_type` + kwargs. Passing both is an error. Legacy flat form packs + `camera: ComponentConfig` and writes only the new stdin. +- **Who derives `service_name`:** `from_config` and the adapter constructor. + If `service_name` is omitted and `class_path` is a known built-in, derive + via the transport map + device id from `init_args`. If third-party and + `service_name` is omitted, fail before spawn. The publisher envelope always + carries a concrete `service_name`; the child never re-derives it. +- **Attach-only / `from_publisher(service_name=…)`:** unchanged. + +`ComponentConfig` does not import transport code. Instead, transport envelopes +consume the plain config: + +```python +@dataclass(frozen=True) +class RobotOwnerConfig: + name: str + robot: ComponentConfig + allow_remote: bool = False + rate_hz: float = 100.0 + idle_timeout: float | None = 10.0 +``` + +The same separation applies to camera service names, validation settings, +zero-copy mode, publisher rates, and idle timeouts. These settings describe +how a component is shared, not how the underlying component was constructed. + +Config export capability does not imply process sharing. Studio owns an +explicit product/deployment policy such as `robot.share`; +`is_config_exportable` only determines whether the selected sharing path can +obtain a component config. Studio consumption becomes: + +```python +driver = await builder(robot, self) +driver = share_if_requested(driver, name=robot.name, enabled=robot.share) +``` + +`share_if_requested()` leaves the direct driver unchanged when disabled. When +enabled, it requires `is_config_exportable(driver)`, rejects a connected +driver, then calls `SharedRobot.from_config(to_config(driver), name=name)`. +The helper implements Studio policy; it is not part of the generic +component-config API. + +Catalog and plugin builders return ordinary runtime objects. Plugins never +import Zenoh, iceoryx2, `SharedRobot`, or `SharedCamera`. + +## jsonargparse relationship + +jsonargparse remains responsible for: + +- generating CLI schemas from typed constructor signatures; +- merging command-line, environment, and YAML values; +- validating CLI input; +- instantiating runtime configs through the existing CLI. + +The component-config layer is responsible for: + +- recovering a replay recipe from an opted-in live object; +- validating the JSON-safe recipe; +- reconstructing it outside the CLI; +- feeding emitted **nested** `class_path` + `init_args` fragments back into + jsonargparse without translation. A document root may still use a CLI + wrapper key such as `runtime:`. + +Do not depend on jsonargparse internals to remember live-object construction. +The explicit construction contract is also needed in subprocesses and Studio, +where no parser namespace exists. + +## Security boundary + +`class_path` is executable local configuration. `instantiate()` only accepts +trusted application or user-authored config. Never instantiate a config +received from robot metadata, camera metadata, Zenoh, shared memory, or +another untrusted peer. + +Validation makes malformed input predictable; it does not make arbitrary +imports safe. Transport wire protocols may carry a config only from a trusted +parent process to the child it spawned. They must not accept component +configs from network subscribers. + +An allowlist resolver can be added for contexts that need a narrower trust +policy, but it does not replace the trusted-input rule for v1. + +## Plugin contract + +1. Implement the relevant runtime protocol (`Robot`, `Camera`, + `ActionSource`, callback, or another typed component). +2. Opt into config export with `@export_config`. A factory-oriented class can + instead provide `__component_config__()`; both paths satisfy + `is_config_exportable`. +3. Ensure every captured value is JSON-normalizable and constructor-compatible; + domain dictionaries containing reserved `class_path` require an explicit + codec or wrapper. +4. Path-shaped constructor args may be relative. They resolve against the + process cwd at open/`instantiate`. Owner/publisher children inherit the + parent cwd at spawn — keep that cwd stable between export and spawn when + using relatives. Absolute paths and `Path` are fine when you need them. +5. Export the class from a stable public import path. +6. Add construction round-trip tests. + +Opt-in is transitive: a container component can export config only when all +captured nested component values can also export config. + +## Failure semantics + +- `to_config(value)` raises `ComponentConfigError` when a captured value cannot + be normalized. +- `instantiate()` raises `ComponentConfigError` for malformed data before + importing anything. +- `instantiate()` raises `ComponentImportError` when `class_path` cannot + resolve to an importable class. +- Constructor validation and hardware configuration errors propagate from the + constructor with the component path added as context. + +Callers generally recover from these failures by correcting configuration, so +the first implementation can use one public `ComponentConfigError` base with +specific subclasses only where tests or callers distinguish phases. + +## Suggested rollout + +1. Add `ComponentConfig`, bounded normalization/instantiation, cycle checks, + and one shared dotted-path resolver to `physicalai.config`. Consolidate the + duplicated inference and robot importers behind that resolver + (behavior-preserving). Do not change inference factory behavior. +2. Add `@export_config`, `to_config()`, and `is_config_exportable()` with + signature, inheritance-depth, hook-export, and mutable-container tests. +3. Wire SO101, WidowXAI, and `BimanualWidowXAI` (nested `left`/`right` + composite round-trips); add JSON and construction round-trip tests. +4. Add `SharedRobot.from_config()` / `from_robot()`; hard-cutover + `RobotOwnerConfig` stdin to `robot: ComponentConfig` (rewrite writers, + readers, fixtures; no `config_format`, no dual-read). Keep public ctor and + CLI flat kwargs as adapters that always write the new stdin (XOR mutual + exclusion with `robot=` / `--robot`). Normalize legacy class paths to + public re-exports before store/advertise/compare; advertise metadata + `robot_class` from that public `class_path`. Relative path args rely on + parent cwd inheritance at `Popen` (no path-absolutizing helper). +5. Wire camera implementations, then hard-cutover the camera publisher stdin + to `camera: ComponentConfig` with `service_name` beside it (same PR: + rewrite fixtures; no dual-read). Add `SharedCamera.from_config()` / + `from_camera()`, XOR adapter ctor, and transport-owned built-in + class-path → type-token map for derived names; third-party requires + explicit `service_name`. Do not claim third-party shared camera support + before this lands. +6. Wire the exact PolicySource graph listed above, `TeleopSource`, path-rooted + `InferenceModel` configuration, and the v1 callback set. +7. Wire `RobotRuntime` and verify emitted nested configs load through both + `instantiate()` and jsonargparse (under `runtime:` where the CLI requires + it). +8. Studio drops interim serializers and applies explicit sharing policy to + exportable plugin results. +9. Document component config next to `class_path` / `init_args` and state that + persisted workflow versioning remains preview work. +10. In a separate inference design/change, audit manifest fixtures before + considering `ComponentSpec` delegation, default semantics, depth counting, + or class-path extra-field tightening. + +The public jsonargparse `class_path` + `init_args` shape remains unchanged. +Robot-owner and camera-publisher startup payloads are private wire hard +cutovers; do not describe them as schema-preserving. + +## Required tests + +- Primitive, path, enum, mapping, list, and nested-component normalization. +- Non-finite floats are rejected during normalization. +- `Path` values emit `str(path)` as given (relative stays relative; absolute + stays absolute); plain relative `str` paths are unchanged. +- Relative calibration (and similar path args) survive owner/publisher spawn + when the parent cwd is unchanged between export and `Popen`; children + inherit that cwd. Changing cwd in between with relative paths in the config + is unsupported. +- The shared depth limit applies through configs, mappings, and lists; cyclic + Python containers fail during normalization. +- JSON dump/load followed by reconstruction. +- Re-export produces the same normalized config. +- Supplied defaults remain represented; omitted defaults remain omitted. +- A config emitted with omitted optional nested components parses through + jsonargparse with the same constructor defaults; examples do not emit nulls + for arguments the caller omitted. +- Omitted `PolicySource.action_queue` reconstructs with `LerpSmoother`; bare + `ChunkedActionQueue()` without smoother reconstructs with `ReplaceSmoother`. +- Positional calls bind to names and `**kwargs` flatten into `init_args`. +- Decorated `super()` calls do not overwrite the outer constructor capture. +- An undecorated overriding constructor fails instead of emitting base-only + arguments, and the selected public path resolves to the most-derived type. +- `is_config_exportable` is true for decorator-marked and `__component_config__` + hook-only classes; `to_config` works for both; Studio share keys off that + predicate. +- jsonargparse still sees the original decorated constructor signature. +- Injected instance `to_config()` does not break structural Protocol checks; + docs prefer module `to_config`. +- Malformed and ambiguous nested configs fail before import. +- Local classes and non-class import targets fail. +- Unsupported objects and lambdas report the complete argument path. +- `instantiate()` does not invoke lifecycle methods; eager constructor side + effects such as `InferenceModel` artifact loading and `JsonlCallback` file + open remain visible in tests. +- `InferenceModel` non-scalar / live override args fail at `to_config` (no + silent drop). +- Robot owner and camera publisher subprocess handshakes remain JSON-only, + accept only the new `robot:` / `camera: ComponentConfig` shape, and reject + legacy flat stdin (`robot_class` / `camera_type` forms) before import. +- Built-in SharedCamera spawn derives the legacy `service_name` via the + transport class-path → type-token map inside `from_config` / the adapter + ctor; third-party spawn without explicit `service_name` fails before + publisher start; the publisher envelope carries a concrete `service_name` + not inside `init_args`. +- `SharedCamera.from_camera()` is sugar over `from_config(to_config(...))`, + requires exportability, rejects connected cameras before publisher spawn, + and never disconnects implicitly; public ctor XOR-rejects simultaneous + `camera` and legacy `camera_type` + kwargs. +- Third-party camera class paths survive the publisher envelope and bypass the + built-in camera registry. +- Public `SharedRobot` ctor and `physicalai robot` CLI accept legacy flat + kwargs as adapters **XOR** ComponentConfig; both paths write only the new + stdin; legacy defining-module paths normalize to public re-exports before + store/advertise/compare; metadata `robot_class` equals that public + `robot["class_path"]`. +- `SharedRobot.from_robot()` is sugar over `from_config(to_config(...))`, + requires exportability, and rejects connected drivers before owner spawn. +- Composite bimanual robots spawn as one owner; nested arm configs round-trip + under the composite. +- The exact PolicySource nested graph (including smoothers) round-trips; + unsupported nested values fail at `to_config`. +- The exact v1 callback set round-trips; other callbacks fail at `to_config`; + omitted/empty callbacks stay omitted/empty. +- Referenced local configs and inline self-contained values have distinct tests. +- One complete `RobotRuntime` tree round-trips through both the generic loader + and the jsonargparse CLI parser (nested fragments; root may use `runtime:`). +- Existing inference manifest fixtures and `ComponentSpec.from_class()` retain + their current behavior in v1. +- Untrusted transport metadata is never passed to `instantiate()`. + +## Alternatives considered + +| Option | Why not | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `to_dict()` on runtime protocols | Mixes runtime behavior with construction | +| Studio-only component specs | Creates a parallel config model and does not scale to plugins | +| Transport-specific robot/camera serializers | Duplicates the same replay problem by component type | +| jsonargparse namespace as source of truth | Live objects and subprocesses often have no parser namespace | +| Automatic reflection of object attributes | Cannot distinguish constructor input, derived state, mutation, or resources | +| Arbitrary codec registry in v1 | Adds global extension and security complexity before a second use case exists | +| Unify with inference `ComponentSpec` / `instantiate_component` in v1 | Different defaults, depth counting, extras, and registry mode; changes the inference critical path without helping robot/camera spawn | +| Embed `service_name` inside `ComponentConfig` | Mixes transport naming with construction | +| Hash `class_path` into third-party camera service names | Unstable across arg ordering and omitted defaults; collisions | +| Rename metadata field to `class_path` in v1 | Would bump `ROBOT_TRANSPORT_PROTOCOL_VERSION`; keep `robot_class` populated from `class_path` | +| Changing cwd between relative-path export and owner/publisher spawn | Unsupported in v1; relatives resolve against process cwd at open | +| Public `absolutize_component_paths` | Easy to forget; fights folder-local relative export; redundant with Popen cwd inheritance | +| Universal string heuristic for IPC path absolutization | Corrupts URLs / non-path tokens; wrong tool once relatives are first-class | +| `__component_path_keys__` in v1 | Not needed without an absolutizer; defer until a second concrete need | +| `config_format` / dual-read on owner/publisher stdin | Same-package ephemeral `Popen` handshake; hard cutover is enough — see [wire decision](shared-construction-wire-decision.md) | +| Shape dual-read (`robot` vs `robot_class`) without a version field | Soft landing only; fixture churn is in-repo, so rewrite once | +| Attach-only Studio | Clean separation, but changes operations by requiring serve-first | + +## References + +- Runtime config shape: [config schema](../reference/config-schema.md) +- Runtime YAML guide: [write runtime config](../how-to/config/write-runtime-config.md) +- Security rules: [runtime security](security.md) +- Robot owner envelope: `physicalai.robot.transport._owner_config.RobotOwnerConfig` +- Camera publisher envelope: `physicalai.capture.transport._spec.CameraSpec` +- Review context: Mark's comments on Studio PR #818 (factory wrap; plugins + should not care about `SharedRobot`) From ce399b92aac3da58eee2eedecb8f4e928908ab47 Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Wed, 22 Jul 2026 13:39:04 +0200 Subject: [PATCH 02/16] rename docs --- .../captured-component-config-report.md | 361 ------- .../robot-construction-remembered-init.md | 945 ------------------ 2 files changed, 1306 deletions(-) delete mode 100644 docs/development/captured-component-config-report.md delete mode 100644 docs/development/robot-construction-remembered-init.md diff --git a/docs/development/captured-component-config-report.md b/docs/development/captured-component-config-report.md deleted file mode 100644 index 2007765..0000000 --- a/docs/development/captured-component-config-report.md +++ /dev/null @@ -1,361 +0,0 @@ -# Captured component configuration — design report - -**Audience:** Runtime developers -**Status:** Accepted — implement per rollout -**Canonical spec:** [robot-construction-remembered-init.md](robot-construction-remembered-init.md) -**Context:** Follow-up to [Studio SharedRobot #818](https://github.com/open-edge-platform/physical-ai-studio/pull/818) - -This report is the human-readable map of the design. When behavior is ambiguous, the canonical design note wins. - -![Architecture: behavior, construction, and transport layers](captured-component-config-architecture.png) - ---- - -## One-sentence summary - -Opt-in `@export_config` keeps constructor args as JSON-safe `class_path` + `init_args`, so Studio and SharedRobot/SharedCamera can spawn fresh components **without** special-case serializers or transport imports in plugins. - ---- - -## Why it exists - -```mermaid -flowchart LR - subgraph today [Today] - JA[jsonargparse YAML] -->|instantiate| Live1[Live component] - Live2[Live driver] -.->|cannot recover args| Gap[Gap] - Gap --> Studio[Studio / owner spawn] - end - - subgraph after [After this design] - Live3[Live @export_config component] - Live3 -->|to_config| CC[ComponentConfig] - CC -->|instantiate| Live4[Fresh disconnected component] - CC -->|ComponentConfig stdin| Owner[Owner / publisher child] - end -``` - -| Pain | Fix | -| --------------------------------------------------------------- | ------------------------------------------------ | -| jsonargparse builds trees but cannot reverse a live object | `@export_config` + `to_config` | -| SharedRobot needs class + JSON kwargs across a process boundary | Same `ComponentConfig` in the owner envelope | -| Studio serializers per robot/camera type | Plugins opt in; Studio only checks exportability | -| Putting `to_dict` on `Robot` / `Camera` | Keep protocols behavior-only | - ---- - -## Three-layer mental model - -```mermaid -flowchart TB - subgraph behavior [Runtime behavior — unchanged] - R[Robot] - C[Camera] - A[ActionSource] - end - - subgraph construction [Construction — new] - DI["@export_config"] - TC[to_config / instantiate] - CFG["ComponentConfig
class_path + init_args"] - DI --> TC --> CFG - end - - subgraph transport [Transport envelopes — consume config] - RO[RobotOwnerConfig] - CP[Camera publisher config] - SR[SharedRobot] - SC[SharedCamera] - RO --> SR - CP --> SC - end - - behavior -.->|opt-in only| construction - CFG -->|robot: / camera:| transport -``` - -**Remember:** - -- **Construction** = how to build a fresh instance -- **Transport** = name, rate, `service_name`, timeouts, sharing -- **Exportable ≠ shared** — Studio owns `robot.share`; `@export_config` only enables config export - ---- - -## Public API (`physicalai.config`) - -```python -ComponentConfig = {"class_path": str, "init_args": dict[str, JsonValue]} - -@export_config # opt-in on concrete classes -to_config(value) # live → ComponentConfig -instantiate(config) # trusted ComponentConfig → fresh object -is_config_exportable # decorator marker OR __component_config__ -``` - -```mermaid -sequenceDiagram - participant App - participant Export as @export_config - participant API as physicalai.config - participant Child as Owner/publisher - - App->>Export: SO101(port, calibration) - Export-->>App: live robot + stored init_args - App->>API: to_config(robot) - API-->>App: ComponentConfig - App->>Child: ComponentConfig stdin JSON (same cwd) - Child->>API: instantiate(robot) - API-->>Child: fresh SO101 (not connected) -``` - -Library code always uses module-level `to_config(value)` (type-checker friendly). An injected instance method is optional sugar only. - ---- - -## End-to-end data flow - -```mermaid -flowchart LR - subgraph parent [Parent process] - B[Builder / CLI / Studio] - T[to_config] - Env[Transport envelope
robot/camera ComponentConfig] - B --> T --> Env - end - - subgraph child [Child process same cwd] - Val[Validate new shape] - Inst[instantiate] - Proto[Check Robot/Camera] - Conn[connect later] - Val --> Inst --> Proto --> Conn - end - - Env -->|trusted stdin JSON| Val -``` - -**Trust rule:** `instantiate()` is for trusted local / parent→child config only. Never run it on Zenoh metadata, camera metadata, or peer payloads. - ---- - -## What gets exported (v1) - -```mermaid -flowchart TB - RR[RobotRuntime] - RR --> Robot - RR --> AS[ActionSource] - RR --> Cams[cameras map] - RR --> Cbs[callbacks] - - Robot --> SO101 - Robot --> WidowXAI - Robot --> Bimanual["BimanualWidowXAI
left + right nested"] - - AS --> PS[PolicySource] - AS --> TS[TeleopSource] - - PS --> IM[InferenceModel
path-rooted] - PS --> Ex[Sync / Async / RTC Execution] - PS --> Q[Chunked / RTC ActionQueue] - Q --> Sm[Lerp / Replace Smoother] - - Cams --> UVC[UVC / RealSense / …] - Cbs --> CB[Console / LPF / Jsonl / Rerun / Async] -``` - -| Area | v1 rule | -| ------------------------ | ----------------------------------------------------------------------------------------- | -| Defaults | Capture only supplied args — omit ⇒ current ctor defaults on replay | -| `PolicySource` | Default queue uses **LerpSmoother**; bare `ChunkedActionQueue()` uses **ReplaceSmoother** | -| `InferenceModel` | Path-rooted (`export_dir`, scalars); live runner/processor overrides fail `to_config` | -| `TeleopSource.to_action` | Replayable only when omitted | -| Bimanual | One SharedRobot owner for the composite | -| Callbacks | Exact first-party set; observers with post-hoc `session_id` out of scope | - ---- - -## Transport migration (private wire) - -Do **not** bump `ROBOT_TRANSPORT_PROTOCOL_VERSION` or camera frame `PROTOCOL_VERSION` for this. Those are network/frame protocols. Startup stdin is a same-package `Popen` handshake — **hard cutover** to `ComponentConfig` with no `config_format` and no dual-read (see [wire decision](shared-construction-wire-decision.md)). - -```text -# Robot owner — after -{name, robot: {class_path, init_args}, allow_remote, rate_hz, idle_timeout, …} - -# Camera publisher — after -{camera: {class_path, init_args}, service_name, idle_timeout, …} -``` - -Writers and readers speak only the new shape. Legacy flat stdin (`robot_class` / `camera_type`) is rejected before import. - -```mermaid -flowchart TB - subgraph robot_stdin [Robot owner stdin] - R2[name, rate_hz, …] - R3["robot: {class_path, init_args}"] - end - - subgraph cam_stdin [Camera publisher stdin] - C2[service_name + transport fields] - C3["camera: {class_path, init_args}"] - end -``` - -### SharedCamera naming - -| Mode | `service_name` | -| ----------------- | -------------------------------------------------------------------------- | -| Built-in spawn | Derived in transport: `class_path` → legacy `CameraType` token + device id | -| Third-party spawn | **Required** explicit name (no hashing) | -| Attach-only | Required; no construction config | - -`service_name` lives **beside** `camera: ComponentConfig`, never inside `init_args`. - -### Public API (adapters) - -| Surface | Accept (XOR) | Preferred | -| ---------------------- | ------------------------------------------------------- | ----------------------------- | -| `SharedRobot` | `robot=` **xor** `robot_class`+`robot_kwargs` (adapter) | `from_config` / `from_robot` | -| `SharedCamera` | `camera=` **xor** `camera_type`+kwargs (adapter) | `from_config` / `from_camera` | -| CLI `physicalai robot` | `--robot` **xor** legacy flags (adapter) | `--robot` | -| Metadata | Keep key `robot_class` | Value = public `class_path` | - -Legacy flat forms always pack `ComponentConfig` and write only the new stdin. Removing adapters is a later cleanup PR. - -`from_robot` / `from_camera`: require exportable, **reject if connected**, never disconnect implicitly. - ---- - -## Paths: relative-first, cwd at open - -```mermaid -flowchart LR - subgraph export [to_config] - P1["Path → str(path) as given"] - P2["str unchanged
e.g. ./calibration.json"] - end - - subgraph resolve [instantiate / ctor open] - P3[Resolve against process cwd] - P4[Child inherits parent cwd at Popen] - P3 --> P4 - end -``` - -- Prefer relative paths for project- or folder-local configs (portable export directories). -- Do not absolutize in `to_config` or IPC writers. -- Unsupported: `chdir` between export and spawn when configs still contain relatives. -- Future bundle/export-folder root may pin resolution without absolutizing — not v1. - ---- - -## Studio consumption - -```mermaid -flowchart TD - Builder[Plugin builder] --> Driver[Ordinary Robot/Camera] - Driver --> Share{robot.share?} - Share -->|no| Direct[In-process driver] - Share -->|yes| Exp{is_config_exportable?} - Exp -->|no| Fail[Fail loud] - Exp -->|yes| Conn{connected?} - Conn -->|yes| Fail2[Fail — caller must disconnect] - Conn -->|no| SR[SharedRobot.from_config] -``` - -Plugins never import Zenoh, iceoryx2, `SharedRobot`, or `SharedCamera`. - ---- - -## Security (developer checklist) - -| Do | Don't | -| ----------------------------------------------------- | ----------------------------------------------------- | -| Treat `class_path` as executable trusted config | Instantiate configs from `/metadata` or network peers | -| Validate malformed input before import | Assume validation = sandbox | -| Carry config parent→child stdin only | Accept component configs from subscribers | -| Keep nesting depth bounded (`_MAX_CONFIG_DEPTH = 10`) | Unbounded recursive instantiate | - -See also [security.md](security.md) rules 4, 5, 11, 12. - ---- - -## Out of scope for v1 - -- Inference `ComponentSpec` / `instantiate_component` unification (different defaults, extras, registry) -- Self-contained portable bundles / `to_config(..., profile="self_contained")` / explicit bundle root -- `__component_path_keys__` / public path absolutizers -- Callable references for `TeleopSource.to_action` -- Per-arm SharedRobot for bimanual nests -- Persisted workflow document versioning - ---- - -## Implementation rollout - -```mermaid -gantt - title Suggested implementation order - dateFormat X - axisFormat %s - - section Core - physicalai.config engine :a1, 0, 1 - export_config + to_config :a2, 1, 2 - SO101 / WidowX / Bimanual :a3, 2, 3 - - section Transport - SharedRobot + owner hard cutover :b1, 3, 4 - SharedCamera + publisher cutover :b2, 4, 5 - - section Runtime tree - PolicySource graph + InferenceModel :c1, 5, 6 - RobotRuntime + callbacks :c2, 6, 7 - - section Downstream - Studio share policy :d1, 7, 8 - Docs / preview versioning note :d2, 8, 9 -``` - -| Step | Deliverable | -| ---: | -------------------------------------------------------------------------------------------- | -| 1 | `ComponentConfig`, instantiate, depth/cycles, shared importer (no inference behavior change) | -| 2 | `@export_config`, `to_config`, `is_config_exportable` | -| 3 | SO101, WidowXAI, BimanualWidowXAI round-trips | -| 4 | SharedRobot + owner stdin hard cutover + public/CLI adapters (cwd inherit for relatives) | -| 5 | SharedCamera + publisher stdin hard cutover + `service_name` rules | -| 6 | PolicySource graph, TeleopSource, path-rooted InferenceModel, v1 callbacks | -| 7 | RobotRuntime -> `instantiate` + jsonargparse | -| 8 | Studio drops interim serializers | -| 9 | User-facing docs | -| 10 | Separate: inference factory follow-up (optional) | - -Implement **one step at a time**. Keep the design note open as the contract. - ---- - -## Plugin checklist - -1. Implement `Robot` / `Camera` / `ActionSource` / callback protocol -2. `@export_config` (or `__component_config__`) -3. JSON-normalizable args; constructors accept normalized forms -4. Relative path args OK; keep cwd stable through Shared\* spawn (or use absolute/`Path` as given) -5. Stable public import path + `__config_class_path__` when re-exported -6. Round-trip test: `to_config` → `json` → `instantiate` → `to_config` equal - ---- - -## Quick reference - -| Symbol | Role | -| --------------------------- | ------------------------------------------------------------------- | -| `@export_config` | Opt into `ComponentConfig` export (stores supplied `__init__` args) | -| `ComponentConfig` | Wire shape shared with jsonargparse | -| `to_config` / `instantiate` | Export / trusted rebuild | -| `is_config_exportable` | Can we get a recipe? | -| Owner/publisher stdin | Hard cutover to `robot:` / `camera: ComponentConfig` | -| `service_name` | Camera transport identity (not construction) | - -**Full rules and required tests:** [robot-construction-remembered-init.md](robot-construction-remembered-init.md) diff --git a/docs/development/robot-construction-remembered-init.md b/docs/development/robot-construction-remembered-init.md deleted file mode 100644 index f308c47..0000000 --- a/docs/development/robot-construction-remembered-init.md +++ /dev/null @@ -1,945 +0,0 @@ -# Captured component configuration (design note) - -Status: **accepted — implement per rollout**. Follow-up to Studio SharedRobot -integration -([physical-ai-studio#818](https://github.com/open-edge-platform/physical-ai-studio/pull/818)); -out of scope for that PR, now ready for Runtime implementation. - -## Decision - -Add one opt-in, transport-agnostic decorator for components whose constructor -configuration must be exportable so a fresh instance can be created later. - -```text -Robot / Camera / ActionSource = runtime behavior (unchanged) -@export_config = opt into ComponentConfig export from ctor args -ComponentConfig = plain class_path + init_args data -``` - -Use the same contract for robots, cameras, action sources, runtimes, -callbacks, and nested components. Keep process names, rates, timeouts, and -other transport settings in their existing transport envelopes. - -The contract closes both directions: - -```text -live component -> to_config(value) -> JSON/YAML -> instantiate(config) -> new component -``` - -It uses the same `class_path` + `init_args` vocabulary already consumed by -jsonargparse runtime configs. - -## Problem - -jsonargparse can instantiate a component tree from typed constructor -signatures, but it cannot recover constructor inputs from an arbitrary live -object. `SharedRobot` has the same gap: owner spawn needs an importable class -and JSON-safe constructor arguments, not a live driver with open hardware -resources. - -Studio must not special-case SO101, WidowXAI, camera, runtime, or action-source -serializers. Plugins must not import `SharedRobot` or `SharedCamera`. Adding -serialization methods to `Robot`, `Camera`, or `ActionSource` would mix -construction with runtime behavior. - -## Goals - -1. Export an opted-in live component as JSON-safe `class_path` + - `init_args` data. -2. Instantiate that data as a new component without invoking lifecycle methods - beyond its constructor. -3. Recursively support nested components and collections of components. -4. Emit nested `class_path` + `init_args` fragments that jsonargparse accepts - without translation. A root config may still sit under a CLI wrapper key - such as `runtime:`. -5. Keep hardware and action-source protocols unchanged. -6. Let third-party plugins opt in without depending on transports. -7. Fail at serialization or validation time when replay is not possible. - -## Non-goals - -- Snapshot live state, open handles, queues, threads, or connections. -- Reflect over arbitrary objects and guess how to reconstruct them. -- Serialize lambdas, closures, or arbitrary callables. -- Make untrusted `class_path` imports safe. -- Replace transport configuration such as robot names or camera service names. -- Guarantee that mutation after construction appears in the config. - -## Public API - -Place this API in a neutral module such as `physicalai.config`, -not under robot, capture, runtime, or inference. - -```python -from typing import TypedDict - -JsonScalar = None | bool | int | float | str -JsonValue = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] - - -ComponentConfig = TypedDict("ComponentConfig", {"class_path": str, "init_args": dict[str, JsonValue]}) - - -def export_config(cls: type) -> type: ... - - -def to_config(value: object) -> ComponentConfig: ... - - -def instantiate(config: ComponentConfig) -> object: ... - - -def is_config_exportable(value: object) -> bool: ... -``` - -Normal use stays small: - -```python -@export_config -class SO101: - def __init__(self, port: str, calibration: dict | str): ... - - -robot = SO101("/dev/ttyACM0", "./calibration.json") -config = to_config(robot) -restored = instantiate(config) -``` - -The decorator can also inject `robot.to_config()` as a runtime convenience. -Library code and documentation always use module-level `to_config(robot)` -because static type checkers cannot express that a class decorator adds an -instance method without changing the decorated class's declared type. This -avoids introducing a public protocol only to make the convenience method -type-check. Structural `Protocol` checks ignore the extra method, so the -injection does not break robot/camera/action-source conformance. - -`ComponentConfig` is a typed description of the existing jsonargparse wire -shape, not a stateful model with its own methods. Callers can pass ordinary -dictionaries loaded from JSON or YAML to `instantiate()`; it validates them -before importing code. `instantiate()` is the one shared, explicit trusted-code -boundary: it imports `class_path`, recursively instantiates nested configs, -then calls the class with decoded keyword arguments. - -The implementation rejects local classes (``), non-class import -targets, non-string mapping keys, malformed nested configs, and values outside -the supported JSON model. - -Do not add a public `Constructible` or `Replayable` protocol. Runtime and -Studio code that needs to test exportability uses `is_config_exportable(value)`. -A value is exportable if and only if it carries the private `@export_config` -decorator marker **or** provides a callable `__component_config__` hook. -`to_config(value)` uses the same predicate. This keeps the plugin-facing -contract to one decorator plus one private escape hatch, and gives -`share_if_requested` a single key for both paths. - -### Naming - -Use `export_config` because it names the public capability: the class can export -a `ComponentConfig` via `to_config`. That aligns with `is_config_exportable` -and does not claim to serialize live object state. The decorator works by -remembering supplied `__init__` arguments; that mechanism is an implementation -detail documented here, not part of the public name. - -Rejected names: - -| Name | Problem | -| --------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `capture_init` | Collides with the `physicalai.capture` camera package | -| `record_init` | Will collide with planned runtime recording callbacks | -| `serializable` | Implies complete object-state serialization | -| `configurable` | Usually means an object accepts configuration, not that it exports it | -| `replayable` | Does not say what is replayed and can suggest runtime/action replay | -| `reconstructible` | Accurate but cumbersome and still hides the config-export contract | -| `remember_init` / `snapshot_init` | Mechanism-only; weaker fit next to `to_config` | -| `config_exportable` | Accurate but clumsy as a class decorator | -| `export_init` | Precise about ctor args, but understates hook-only export and rhymes less with `is_config_exportable` | -| `has_captured_init` | Misleading for hook-only classes; use `is_config_exportable` | - -Use `to_config(value)` for export because the result is directly consumable -jsonargparse component configuration. Use `instantiate()` for the trusted -inverse because it creates a fresh instance; avoid `deserialize()`, which -suggests restoring prior object state. - -### Relationship to inference `ComponentSpec` - -Inference factory unification is explicitly out of scope for v1. -`ComponentSpec` is a manifest compatibility model with registry aliases, flat -parameters, artifact handling, and `extra="allow"` semantics. Tightening its -class-path mode or routing registry mode through `instantiate()` changes the -manifest schema and inference critical path; the robot/camera construction use -case does not require either change. - -For v1: - -- `ComponentConfig` owns strict `class_path` + `init_args` validation and - bounded recursive instantiation in `physicalai.config`. -- `ComponentSpec`, `ComponentSpec.from_class()`, `_MAX_COMPONENT_DEPTH`, and - `instantiate_component()` retain their current behavior. -- Rollout step 1 consolidates the duplicated inference and robot dotted-path - import helpers into `physicalai.config` in a behavior-preserving refactor. - Transports call `instantiate()` and add protocol checks; they do not add - another importer. - -A follow-up design can audit exported manifest fixtures, then decide whether -`ComponentSpec` class-path mode should delegate to `ComponentConfig`. That -work must address two known differences: `ComponentSpec.from_class()` applies -defaults while `@export_config` omits unsupplied defaults, and class-path extra -fields are currently accepted and ignored. Registry mode remains an inference -adapter even if class-path construction is later shared. - -Use `_MAX_CONFIG_DEPTH = 10` for the new config engine. Count traversal through -lists and mappings as well as nested component configs, and track container -identities to reject cycles before JSON serialization. Do not apply this -stricter counting rule to existing inference manifests in v1. - -## Exporting constructor configuration - -`@export_config` is the opt-in contract. Constructor signature changes flow -into exported configuration automatically: - -```python -@export_config -class PolicySource(ActionSource): - def __init__(self, model, execution=None, action_queue=None, *, task=None): - self._execution = execution or SyncExecution() - self._action_queue = action_queue or ChunkedActionQueue(...) -``` - -The wrapper uses `inspect.signature()` and `Signature.bind()` before invoking -the constructor. It converts positional arguments to their parameter names, -removes `self`, and flattens a bound `**kwargs` mapping into `init_args`. -Positional-only parameters and `*args` are not replayable through -`class_path` + keyword `init_args`; reject decorated constructors that declare -them. - -Do not call `BoundArguments.apply_defaults()`. Capture only arguments the -caller supplied. Omitted arguments remain omitted so reconstruction invokes -the current constructor defaults. An explicitly supplied `None` remains in -the spec. This produces compact configs and avoids freezing default component -objects selected internally by the current release. - -The wrapper must preserve `__wrapped__` and the original signature through -`functools.wraps()`. jsonargparse and documentation tools must continue to see -the real constructor signature. - -Call the constructor before committing the captured arguments. A failed -constructor must not leave a usable construction record. Do not normalize or -reject captured values during `__init__`; unsupported values fail later when -`to_config(value)` is requested, so opting in does not change ordinary -construction behavior. - -Snapshot built-in mutable containers recursively when capturing them. Keep -nested decorated components and explicit domain values by reference so their -own conversion remains authoritative. This prevents later mutation of a -caller-owned list or dictionary from silently changing the recipe. - -### Inheritance rule - -Decorate every concrete class that should export constructor configuration. Do -not decorate a shared base solely to make all subclasses exportable: a base -signature cannot capture arguments owned by a subclass. - -The decorator tracks construction depth on the instance. If decorated -constructors call other decorated constructors through `super()`, only the -outermost successful call commits its bound arguments. This prevents a camera -base constructor from overwriting the complete concrete-camera recipe with -only `color_mode`. - -A concrete subclass that overrides `__init__` must apply `@export_config` -again. Tests and contributor documentation must state this explicitly. - -There is no merge or last-write behavior. The outermost decorated constructor -owns the complete `init_args`. `to_config(value)` verifies that the -most-derived class's effective `__init__` carries the decorator marker **or** -that the instance provides `__component_config__`; an overriding, undecorated -`__init__` without the hook fails loudly instead of emitting a partial recipe. -A subclass that inherits a decorated constructor unchanged remains valid -because its effective constructor has the marker and the inherited signature -is complete. - -Select `class_path` from the most-derived `type(self)`, never from the class -that defined an inner decorated constructor. Classes can declare a stable -`__config_class_path__` public re-export; otherwise use -`type(self).__module__ + "." + type(self).__qualname__`. Before emitting the -spec, resolve the selected path and verify that it identifies exactly -`type(self)`. - -First-party public robot and camera classes must declare their stable public -re-export path (for example, `physicalai.robot.SO101`) instead of emitting an -internal defining-module path. Plugins must do the same when they promise a -stable public import surface. - -Factory or legacy classes whose configuration does not correspond directly to -one constructor call can implement a private `__component_config__()` hook. -`to_config(value)` and `is_config_exportable(value)` recognize that hook. This -is an internal escape hatch, not a second public protocol; such classes are -the exception, not the default. - -`to_config(value)` describes the object **as constructed**, not its current -mutable state. - -## Serialization and decoding rules - -Normalization is recursive and produces JSON-safe values. - -| Constructor value | Stored value | Value passed on replay | -| -------------------------------------------- | ----------------------------- | ---------------------------- | -| `str`, `int`, finite `float`, `bool`, `None` | as-is | as-is | -| non-finite `float` (`NaN`, `±Inf`) | error | not applicable | -| `Path` | string as given (`str(path)`) | string | -| `Enum` | JSON-safe `.value` | enum value representation | -| decorated or hook-exportable component | `{class_path, init_args}` | instantiated component | -| mapping with string keys | normalized mapping | decoded mapping | -| list or tuple | normalized list | list | -| domain value with an explicit codec | codec output | constructor-compatible value | -| any other object | error | not applicable | - -`Path` values serialize as `str(path)` **without** `resolve()`. Relative paths -stay relative; absolute paths stay absolute. Prefer relative paths for -project- or folder-local configs so an exported directory of config + -artifacts remains portable. Resolution happens at `instantiate` / -constructor open against the process cwd (see Transport integration). - -Reject non-finite floats during normalization. Python's default `json` encoder -permits `NaN` / `Infinity`, which are not portable across strict JSON -consumers. - -For v1, domain codecs remain minimal. `SO101Calibration` can normalize with -`to_dict()` because the SO101 constructor already accepts a dictionary. Do not -add a global arbitrary-object codec registry until a second concrete domain -type requires one. - -Constructors participating in this contract must accept the normalized JSON -representation of their arguments. For example, constructors typed with an -enum must also accept its string value or normalize it internally. This keeps -direct `instantiate()` and jsonargparse reconstruction -equivalent. - -This requirement is enforced at the opt-in boundary. Every first-party class -gains a test that serializes through `json.dumps()` / `json.loads()`, invokes -its constructor through `instantiate()`, and verifies normalized -re-serialization. A class does not ship with `@export_config` until that test -passes. Provide small constructor-side coercion helpers for repeated types such -as `StrEnum`; do not build a general annotation-driven coercion engine. Plugins -have the same mandatory JSON-boundary round-trip test. - -Any dictionary containing `class_path` is reserved as a nested component -config. Its only allowed keys are `class_path` and `init_args`; omitted -`init_args` means an empty mapping. Extra keys or invalid values are malformed -configs, not ordinary dictionaries. A caller that needs `class_path` as a normal -data key must use an explicit domain codec or wrapper. Error messages and the -plugin contract must name this escape hatch. This rule removes ambiguity and -fails closed during generic recursive deserialization. Do not add a sentinel -marker unless a second concrete domain collision demonstrates the need. - -Errors identify the full argument path: - -```text -physicalai.runtime.TeleopSource.init_args.to_action: -cannot encode function ''; omit it or use a supported component value -``` - -## Round-trip contract - -For every opted-in component `value`, tests establish: - -```python -config = to_config(value) -wire = json.loads(json.dumps(config)) -restored = instantiate(wire) -``` - -The guarantee is construction equivalence, not object equality: - -- `type(restored)` matches the represented class. -- `instantiate()` invokes constructors but does not call lifecycle methods such - as `connect()`, `start()`, or `run()`. -- Constructors may still read files, allocate memory, initialize SDKs, or load - models according to their existing contract. -- Calling `to_config(restored)` produces the same normalized config. -- Connecting it addresses the same configured hardware or service. -- Runtime-selected defaults may change between package versions when the - original constructor argument was omitted or `None`. - -Round-trip tests use the public import path emitted in `class_path`, not only -the defining module path. Public paths keep serialized configs stable across -internal module moves. - -### External references and portability - -A component config is a replay recipe, not necessarily a self-contained -artifact. Classify emitted values in two practical profiles: - -- **Local replay** permits paths and other external references. Relative - paths are preferred for project- or folder-local configs. Owner/publisher - subprocess IPC inherits the parent cwd at `Popen` (see Transport - integration); that is the v1 resolution root. -- **Self-contained replay** requires domain values to be inline and rejects - unresolved external dependencies. For example, SO101 calibration must be an - inline dictionary rather than a path. - -The full runtime example below is a local-replay config because both calibration -and `export_dir` reference the filesystem. Relative paths stay meaningful when -the working directory (or a future exported folder layout) is the project root. -A future config-bundling API can copy referenced artifacts and keep or rewrite -relative paths; that is separate from construction replay. - -`to_config(value)` emits local replay by default. A later -`to_config(value, profile="self_contained")` API should be added only when a -concrete portable-export workflow owns artifact bundling and validation. - -`ComponentConfig` has no version field in v1. It is a nested jsonargparse shape, -not an owned persistent document format. IPC envelopes version their own wire -formats as described below. Before advertising generated runtime YAML as a -stable persisted artifact, define a versioned root document that contains the -runtime component config; do not add a version field to every nested component. - -## Full runtime example - -A `RobotRuntime` config recursively contains all participating components: - -```yaml -class_path: physicalai.runtime.RobotRuntime -init_args: - robot: - class_path: physicalai.robot.SO101 - init_args: - port: /dev/ttyACM0 - calibration: ./calibration.json - action_source: - class_path: physicalai.runtime.PolicySource - init_args: - model: - class_path: physicalai.inference.InferenceModel - init_args: - export_dir: ./exports/act_policy - fps: 30 - cameras: - wrist: - class_path: physicalai.capture.UVCCamera - init_args: - device: /dev/video0 - width: 640 - height: 480 - fps: 30 -``` - -This nested fragment is accepted by jsonargparse without translation. For the -existing CLI parser it is nested under `runtime:`; trusted local code may also -pass the bare fragment to `instantiate()`. - -## Component-specific decisions - -### Robots - -SO101 and WidowXAI opt in directly. SO101 calibration is stored as a path when -constructed from a path and as a normalized dictionary when constructed from -an object. - -Private arguments are not excluded by naming convention. If replay needs an -argument, capture it. `SO101.uncalibrated()` currently calls the constructor -with `_allow_uncalibrated=True`, so the decorated outer constructor must retain -that supplied private argument. Uncalibrated round-trip tests are required -before claiming SO101 support; a future public constructor parameter can -replace the private replay detail. - -Bimanual robots no longer need a special serialization design once each arm -uses `@export_config`: `left` and `right` become nested configs under -`BimanualWidowXAI`. v1 `SharedRobot` spawn treats the composite as **one -owner**. Per-arm `SharedRobot` wrapping of nested arms is out of scope. - -### Cameras - -Camera implementations opt in using public class paths. This supports -third-party cameras without extending the `create_camera()` registry. - -Today's `CameraSpec(camera_type, camera_kwargs)` cannot preserve a third-party -class path because `build()` routes through the built-in `create_camera()` -registry. Supporting third-party replay therefore requires a semantic -transport migration, not a wrapper translation. - -Change the private publisher envelope to contain `camera: ComponentConfig` -plus transport fields including `service_name`; `build()` delegates to -`instantiate()` and verifies the result satisfies `Camera`. This is a private -startup-wire **hard cutover** in the same PR as the SharedCamera spawn path: -rewrite the envelope, update fixtures/tests, and drop the legacy -`camera_type` + `camera_kwargs` reader. No `config_format` field and no -dual-read. Normalize built-in names to public class paths in the new shape. -Third-party camera sharing is not supported until this step lands. - -#### SharedCamera service naming - -Transport owns naming; `ComponentConfig` never embeds `service_name`. - -Today spawn derives -`physicalai/camera/{camera_type}/{device_id}/frame` from the registry enum. -After the envelope migration there is no `camera_type` on the construction -config. Rules: - -- **Built-in spawn:** a private map from public `class_path` to the legacy - `CameraType` token (`uvc`, `ip`, `realsense`, `basler`, `genicam`). That map - lives in **capture transport**, not in `physicalai.config`. Derive - `service_name` with the existing scheme using that token plus device id from - `init_args` (`serial_number` else `device`, including `/dev/` symlink - resolve). This preserves existing attacher discovery. -- **Third-party spawn:** require an explicit `service_name` on `SharedCamera` / - the publisher envelope; fail before publisher start if missing. Do not hash - `class_path` or `init_args` into a name (unstable across arg ordering and - omitted defaults). -- **Attach-only:** unchanged — `service_name` required; no construction config - needed. -- The publisher payload carries `service_name` **alongside** - `camera: ComponentConfig`, never inside `init_args`. - -### Action sources - -v1 first-party graph that must round-trip under `PolicySource` (anything else -fails at `to_config`): - -| Class | Notes | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `PolicySource` | Captures `model`, optional `execution`, `action_queue`, `task` | -| `InferenceModel` | Path-rooted (see below) | -| `SyncExecution` | | -| `AsyncExecution` | | -| `RTCExecution` | JSON-safe / scalar constructor args only; `latency_tracker` and live `postprocessors` must be omitted or `to_config` fails | -| `ChunkedActionQueue` | Includes nested `smoother` when supplied | -| `RTCActionQueue` | | -| `LerpSmoother` | Required when an explicit `ChunkedActionQueue(smoother=...)` is captured | -| `ReplaceSmoother` | Same | - -Omitted `execution` / `action_queue` stay omitted so reconstruction uses -current constructor defaults. `PolicySource` itself defaults to -`SyncExecution()` and `ChunkedActionQueue(smoother=LerpSmoother(...))`. -Bare `ChunkedActionQueue()` (smoother omitted) restores `ReplaceSmoother()` — -that is the queue's own default, not the PolicySource default. Explicit nested -values must themselves be exportable. - -`TeleopSource.leader` can be a nested robot config. Its optional `to_action` -callable is replayable only when omitted in v1. Module-level callable support -can be added later with a distinct callable-reference type; do not treat -arbitrary dotted paths as both classes and functions in `ComponentConfig`. - -### Inference model - -`InferenceModel.__init__` eagerly reads the manifest, creates an adapter, and -loads model artifacts. In v1 it is a path-rooted component: capture -`export_dir`, `policy_name`, `backend`, `device`, and JSON-safe scalar adapter -kwargs. Omitted runner, processor, and callback overrides remain omitted and -are reconstructed from the exported package. - -Non-scalar or live override arguments among captured kwargs (including -non-scalar `adapter_kwargs`, and live `runner` / `preprocessors` / -`postprocessors` / `callbacks`) make `to_config` fail. Do not silently drop -them. Live overrides are in scope only when every nested value independently -exports component config or `InferenceModel` supplies the private manual -config hook. Instantiating an inference config can allocate substantial -resources; it is not analogous to constructing a disconnected robot driver. - -### Runtime - -`RobotRuntime` captures its robot, action source, FPS, camera mapping, and -callbacks. A runtime config is therefore the root of a complete jsonargparse -workflow config. It never captures connection state, session IDs, callback -bus state, observations, or action queues created during a run. - -v1 first-party callbacks that must round-trip when supplied (anything else -fails at `to_config`): - -| Class | Notes | -| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `ConsoleCallback` | scalar args | -| `LowPassFilterCallback` | scalar args | -| `JsonlCallback` | path arg; `Path` → `str(path)` as given; ctor opens the file immediately (eager I/O like `InferenceModel`); tests use temp paths | -| `RerunCallback` | JSON-safe / scalar args only (`save_path`, `connect_addr`, mode, …) | -| `AsyncCallback` | nested `inner` must itself be exportable; reject inners with action hooks per the existing `AsyncCallback` guard | - -Omitted or empty `callbacks` stay omitted or empty. Observer helpers under -`runtime/observer/` that take `session_id` after construction are out of scope -for `RobotRuntime` capture in v1. - -## Transport integration - -Keep construction and transport ownership separate: - -```python -SharedRobot.from_robot(robot, name="follower") -SharedRobot.from_config(to_config(robot), name="follower") -``` - -`SharedRobot.from_robot()` is sugar only. It requires `is_config_exportable(robot)`, -rejects a connected driver via `robot.is_connected()`, then calls -`from_config(to_config(robot), ...)`. It must not scrape constructor kwargs -ad hoc. It never disconnects a caller-owned live driver implicitly. Studio -builders must return disconnected drivers for wrapping, or Studio must -explicitly release a driver it owns before calling `from_robot()`. Prefer -`from_config()` when no live instance is otherwise needed. - -### Private startup envelopes (hard cutover) - -Changing `RobotOwnerConfig` from `robot_class` + `robot_kwargs` to -`robot: ComponentConfig` also changes private startup JSON. Parent and child -are always the same installed package at `Popen`, and stdin is ephemeral — not -a persisted document or peer protocol. Hard-cutover both envelopes in the same -PR as the Shared\* spawn path: rewrite writers, readers, and fixtures; do **not** -add a `config_format` field, dual-read, or shape-detection fallback. - -```text -# Robot owner stdin — before -{name, robot_class, robot_kwargs, allow_remote, rate_hz, idle_timeout, …} - -# Robot owner stdin — after -{name, robot: {class_path, init_args}, allow_remote, rate_hz, idle_timeout, …} - -# Camera publisher stdin — before -{camera_type, camera_kwargs, service_name, idle_timeout, …} - -# Camera publisher stdin — after -{camera: {class_path, init_args}, service_name, idle_timeout, …} -``` - -Writers and readers speak only the new shape. Reject payloads that still carry -legacy flat keys (`robot_class` / `robot_kwargs`, or `camera_type` / -`camera_kwargs`) before import or hardware access — no silent translation. - -Do not reuse `capture.transport.PROTOCOL_VERSION` or -`ROBOT_TRANSPORT_PROTOCOL_VERSION` for these changes. Those version frame and -robot network payloads respectively, not the one-shot stdin construction -envelopes. Existing detached owners/publishers are discovered through their -current transport protocols and do not receive a new startup config. - -Decision record: [shared construction wire decision](shared-construction-wire-decision.md). - -### Paths and cwd for local replay / IPC - -Relative paths are preferred for project- and folder-local configs (for -example an exported directory that contains `runtime.yaml`, calibration, and -model artifacts together). Do **not** rewrite paths to absolute in `to_config` -or in IPC writers — that fights portable folder export and is easy to get -wrong for non-path strings (URLs, roles, `host:port`). - -v1 rules: - -1. `to_config`: `Path` → `str(path)` as given; `str` paths unchanged. Relative - stays relative; absolute stays absolute. -2. Resolution is against the **process cwd** at `instantiate` / constructor - open (for example `Path(calibration).read_text()`). -3. Owner/publisher children are started with `Popen` and **no `cwd=` - override**, so they inherit the parent cwd at spawn. That **is** the v1 - IPC contract for relative paths — the same behavior today’s transport - already uses. -4. **Unsupported:** calling `os.chdir` (or otherwise changing the process cwd) - between capturing/exporting a relative-path config and owner/publisher - spawn when those configs still contain relatives. -5. Future: an explicit bundle/export-folder root may pin resolution without - absolutizing paths; that is out of scope for v1. - -`port` remains absolute (`/dev/…`); relative serial ports are unsupported. -Camera URL/stream fields are not filesystem paths. The built-in camera -`class_path` → `CameraType` map lives in **capture transport**, not in -`physicalai.config`. - -### Public SharedRobot, CLI, and metadata - -Private stdin hard cutover does not force a public API break. Keep legacy flat -kwargs as **adapters** that pack `ComponentConfig` and write only the new -stdin. Removing those adapters is a later cleanup PR, not a calendar dual-read -window on the private wire. - -- **`SharedRobot` constructor:** accept `robot: ComponentConfig` **XOR** - legacy `robot_class` + `robot_kwargs`. Passing both is an error — no merge. - Legacy flat kwargs are an adapter only; they immediately become - `robot: {class_path, init_args}` before spawn. New code and docs prefer - `SharedRobot.from_config(...)` and `from_robot(...)`. -- **Public path normalization:** when accepting a class object or a legacy - dotted path, normalize through the same public-path resolution / - `__config_class_path__` used by `to_config` **before** store, metadata - advertise, and conflict compare. This prevents false mismatches between - defining-module paths (for example - `physicalai.robot.so101.so101.SO101`) and public re-exports - (`physicalai.robot.SO101`). -- **CLI `physicalai robot`:** accept `--robot` (ComponentConfig JSON/YAML) - **XOR** legacy `--robot_class` / `--robot_kwargs`; both paths write only the - new stdin shape. -- **Network metadata:** do not rename the advertised key. Keep `robot_class` as - the metadata field so `ROBOT_TRANSPORT_PROTOCOL_VERSION` stays unchanged. - Populate it from the normalized public `robot["class_path"]`. Conflict - checks compare that string unchanged. Attach-only / discover paths are - unchanged. - -### Public SharedCamera - -Mirror the SharedRobot public story so step-5 implementers do not invent -three APIs: - -- **`SharedCamera.from_config(camera, *, service_name=None, …)`** — primary - API. Transport kwargs (zero-copy, validation, idle timeout, …) stay on the - SharedCamera / publisher side, never inside `ComponentConfig`. -- **`SharedCamera.from_camera(camera, *, service_name=None, …)`** — sugar: - require `is_config_exportable(camera)`, reject if `camera.is_connected()` - (fail before publisher spawn), never disconnect a caller-owned live camera - implicitly, then `from_config(to_config(camera), …)`. No ad-hoc kwargs - scrape. -- **Constructor adapter:** accept `camera: ComponentConfig` **XOR** legacy - `camera_type` + kwargs. Passing both is an error. Legacy flat form packs - `camera: ComponentConfig` and writes only the new stdin. -- **Who derives `service_name`:** `from_config` and the adapter constructor. - If `service_name` is omitted and `class_path` is a known built-in, derive - via the transport map + device id from `init_args`. If third-party and - `service_name` is omitted, fail before spawn. The publisher envelope always - carries a concrete `service_name`; the child never re-derives it. -- **Attach-only / `from_publisher(service_name=…)`:** unchanged. - -`ComponentConfig` does not import transport code. Instead, transport envelopes -consume the plain config: - -```python -@dataclass(frozen=True) -class RobotOwnerConfig: - name: str - robot: ComponentConfig - allow_remote: bool = False - rate_hz: float = 100.0 - idle_timeout: float | None = 10.0 -``` - -The same separation applies to camera service names, validation settings, -zero-copy mode, publisher rates, and idle timeouts. These settings describe -how a component is shared, not how the underlying component was constructed. - -Config export capability does not imply process sharing. Studio owns an -explicit product/deployment policy such as `robot.share`; -`is_config_exportable` only determines whether the selected sharing path can -obtain a component config. Studio consumption becomes: - -```python -driver = await builder(robot, self) -driver = share_if_requested(driver, name=robot.name, enabled=robot.share) -``` - -`share_if_requested()` leaves the direct driver unchanged when disabled. When -enabled, it requires `is_config_exportable(driver)`, rejects a connected -driver, then calls `SharedRobot.from_config(to_config(driver), name=name)`. -The helper implements Studio policy; it is not part of the generic -component-config API. - -Catalog and plugin builders return ordinary runtime objects. Plugins never -import Zenoh, iceoryx2, `SharedRobot`, or `SharedCamera`. - -## jsonargparse relationship - -jsonargparse remains responsible for: - -- generating CLI schemas from typed constructor signatures; -- merging command-line, environment, and YAML values; -- validating CLI input; -- instantiating runtime configs through the existing CLI. - -The component-config layer is responsible for: - -- recovering a replay recipe from an opted-in live object; -- validating the JSON-safe recipe; -- reconstructing it outside the CLI; -- feeding emitted **nested** `class_path` + `init_args` fragments back into - jsonargparse without translation. A document root may still use a CLI - wrapper key such as `runtime:`. - -Do not depend on jsonargparse internals to remember live-object construction. -The explicit construction contract is also needed in subprocesses and Studio, -where no parser namespace exists. - -## Security boundary - -`class_path` is executable local configuration. `instantiate()` only accepts -trusted application or user-authored config. Never instantiate a config -received from robot metadata, camera metadata, Zenoh, shared memory, or -another untrusted peer. - -Validation makes malformed input predictable; it does not make arbitrary -imports safe. Transport wire protocols may carry a config only from a trusted -parent process to the child it spawned. They must not accept component -configs from network subscribers. - -An allowlist resolver can be added for contexts that need a narrower trust -policy, but it does not replace the trusted-input rule for v1. - -## Plugin contract - -1. Implement the relevant runtime protocol (`Robot`, `Camera`, - `ActionSource`, callback, or another typed component). -2. Opt into config export with `@export_config`. A factory-oriented class can - instead provide `__component_config__()`; both paths satisfy - `is_config_exportable`. -3. Ensure every captured value is JSON-normalizable and constructor-compatible; - domain dictionaries containing reserved `class_path` require an explicit - codec or wrapper. -4. Path-shaped constructor args may be relative. They resolve against the - process cwd at open/`instantiate`. Owner/publisher children inherit the - parent cwd at spawn — keep that cwd stable between export and spawn when - using relatives. Absolute paths and `Path` are fine when you need them. -5. Export the class from a stable public import path. -6. Add construction round-trip tests. - -Opt-in is transitive: a container component can export config only when all -captured nested component values can also export config. - -## Failure semantics - -- `to_config(value)` raises `ComponentConfigError` when a captured value cannot - be normalized. -- `instantiate()` raises `ComponentConfigError` for malformed data before - importing anything. -- `instantiate()` raises `ComponentImportError` when `class_path` cannot - resolve to an importable class. -- Constructor validation and hardware configuration errors propagate from the - constructor with the component path added as context. - -Callers generally recover from these failures by correcting configuration, so -the first implementation can use one public `ComponentConfigError` base with -specific subclasses only where tests or callers distinguish phases. - -## Suggested rollout - -1. Add `ComponentConfig`, bounded normalization/instantiation, cycle checks, - and one shared dotted-path resolver to `physicalai.config`. Consolidate the - duplicated inference and robot importers behind that resolver - (behavior-preserving). Do not change inference factory behavior. -2. Add `@export_config`, `to_config()`, and `is_config_exportable()` with - signature, inheritance-depth, hook-export, and mutable-container tests. -3. Wire SO101, WidowXAI, and `BimanualWidowXAI` (nested `left`/`right` - composite round-trips); add JSON and construction round-trip tests. -4. Add `SharedRobot.from_config()` / `from_robot()`; hard-cutover - `RobotOwnerConfig` stdin to `robot: ComponentConfig` (rewrite writers, - readers, fixtures; no `config_format`, no dual-read). Keep public ctor and - CLI flat kwargs as adapters that always write the new stdin (XOR mutual - exclusion with `robot=` / `--robot`). Normalize legacy class paths to - public re-exports before store/advertise/compare; advertise metadata - `robot_class` from that public `class_path`. Relative path args rely on - parent cwd inheritance at `Popen` (no path-absolutizing helper). -5. Wire camera implementations, then hard-cutover the camera publisher stdin - to `camera: ComponentConfig` with `service_name` beside it (same PR: - rewrite fixtures; no dual-read). Add `SharedCamera.from_config()` / - `from_camera()`, XOR adapter ctor, and transport-owned built-in - class-path → type-token map for derived names; third-party requires - explicit `service_name`. Do not claim third-party shared camera support - before this lands. -6. Wire the exact PolicySource graph listed above, `TeleopSource`, path-rooted - `InferenceModel` configuration, and the v1 callback set. -7. Wire `RobotRuntime` and verify emitted nested configs load through both - `instantiate()` and jsonargparse (under `runtime:` where the CLI requires - it). -8. Studio drops interim serializers and applies explicit sharing policy to - exportable plugin results. -9. Document component config next to `class_path` / `init_args` and state that - persisted workflow versioning remains preview work. -10. In a separate inference design/change, audit manifest fixtures before - considering `ComponentSpec` delegation, default semantics, depth counting, - or class-path extra-field tightening. - -The public jsonargparse `class_path` + `init_args` shape remains unchanged. -Robot-owner and camera-publisher startup payloads are private wire hard -cutovers; do not describe them as schema-preserving. - -## Required tests - -- Primitive, path, enum, mapping, list, and nested-component normalization. -- Non-finite floats are rejected during normalization. -- `Path` values emit `str(path)` as given (relative stays relative; absolute - stays absolute); plain relative `str` paths are unchanged. -- Relative calibration (and similar path args) survive owner/publisher spawn - when the parent cwd is unchanged between export and `Popen`; children - inherit that cwd. Changing cwd in between with relative paths in the config - is unsupported. -- The shared depth limit applies through configs, mappings, and lists; cyclic - Python containers fail during normalization. -- JSON dump/load followed by reconstruction. -- Re-export produces the same normalized config. -- Supplied defaults remain represented; omitted defaults remain omitted. -- A config emitted with omitted optional nested components parses through - jsonargparse with the same constructor defaults; examples do not emit nulls - for arguments the caller omitted. -- Omitted `PolicySource.action_queue` reconstructs with `LerpSmoother`; bare - `ChunkedActionQueue()` without smoother reconstructs with `ReplaceSmoother`. -- Positional calls bind to names and `**kwargs` flatten into `init_args`. -- Decorated `super()` calls do not overwrite the outer constructor capture. -- An undecorated overriding constructor fails instead of emitting base-only - arguments, and the selected public path resolves to the most-derived type. -- `is_config_exportable` is true for decorator-marked and `__component_config__` - hook-only classes; `to_config` works for both; Studio share keys off that - predicate. -- jsonargparse still sees the original decorated constructor signature. -- Injected instance `to_config()` does not break structural Protocol checks; - docs prefer module `to_config`. -- Malformed and ambiguous nested configs fail before import. -- Local classes and non-class import targets fail. -- Unsupported objects and lambdas report the complete argument path. -- `instantiate()` does not invoke lifecycle methods; eager constructor side - effects such as `InferenceModel` artifact loading and `JsonlCallback` file - open remain visible in tests. -- `InferenceModel` non-scalar / live override args fail at `to_config` (no - silent drop). -- Robot owner and camera publisher subprocess handshakes remain JSON-only, - accept only the new `robot:` / `camera: ComponentConfig` shape, and reject - legacy flat stdin (`robot_class` / `camera_type` forms) before import. -- Built-in SharedCamera spawn derives the legacy `service_name` via the - transport class-path → type-token map inside `from_config` / the adapter - ctor; third-party spawn without explicit `service_name` fails before - publisher start; the publisher envelope carries a concrete `service_name` - not inside `init_args`. -- `SharedCamera.from_camera()` is sugar over `from_config(to_config(...))`, - requires exportability, rejects connected cameras before publisher spawn, - and never disconnects implicitly; public ctor XOR-rejects simultaneous - `camera` and legacy `camera_type` + kwargs. -- Third-party camera class paths survive the publisher envelope and bypass the - built-in camera registry. -- Public `SharedRobot` ctor and `physicalai robot` CLI accept legacy flat - kwargs as adapters **XOR** ComponentConfig; both paths write only the new - stdin; legacy defining-module paths normalize to public re-exports before - store/advertise/compare; metadata `robot_class` equals that public - `robot["class_path"]`. -- `SharedRobot.from_robot()` is sugar over `from_config(to_config(...))`, - requires exportability, and rejects connected drivers before owner spawn. -- Composite bimanual robots spawn as one owner; nested arm configs round-trip - under the composite. -- The exact PolicySource nested graph (including smoothers) round-trips; - unsupported nested values fail at `to_config`. -- The exact v1 callback set round-trips; other callbacks fail at `to_config`; - omitted/empty callbacks stay omitted/empty. -- Referenced local configs and inline self-contained values have distinct tests. -- One complete `RobotRuntime` tree round-trips through both the generic loader - and the jsonargparse CLI parser (nested fragments; root may use `runtime:`). -- Existing inference manifest fixtures and `ComponentSpec.from_class()` retain - their current behavior in v1. -- Untrusted transport metadata is never passed to `instantiate()`. - -## Alternatives considered - -| Option | Why not | -| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `to_dict()` on runtime protocols | Mixes runtime behavior with construction | -| Studio-only component specs | Creates a parallel config model and does not scale to plugins | -| Transport-specific robot/camera serializers | Duplicates the same replay problem by component type | -| jsonargparse namespace as source of truth | Live objects and subprocesses often have no parser namespace | -| Automatic reflection of object attributes | Cannot distinguish constructor input, derived state, mutation, or resources | -| Arbitrary codec registry in v1 | Adds global extension and security complexity before a second use case exists | -| Unify with inference `ComponentSpec` / `instantiate_component` in v1 | Different defaults, depth counting, extras, and registry mode; changes the inference critical path without helping robot/camera spawn | -| Embed `service_name` inside `ComponentConfig` | Mixes transport naming with construction | -| Hash `class_path` into third-party camera service names | Unstable across arg ordering and omitted defaults; collisions | -| Rename metadata field to `class_path` in v1 | Would bump `ROBOT_TRANSPORT_PROTOCOL_VERSION`; keep `robot_class` populated from `class_path` | -| Changing cwd between relative-path export and owner/publisher spawn | Unsupported in v1; relatives resolve against process cwd at open | -| Public `absolutize_component_paths` | Easy to forget; fights folder-local relative export; redundant with Popen cwd inheritance | -| Universal string heuristic for IPC path absolutization | Corrupts URLs / non-path tokens; wrong tool once relatives are first-class | -| `__component_path_keys__` in v1 | Not needed without an absolutizer; defer until a second concrete need | -| `config_format` / dual-read on owner/publisher stdin | Same-package ephemeral `Popen` handshake; hard cutover is enough — see [wire decision](shared-construction-wire-decision.md) | -| Shape dual-read (`robot` vs `robot_class`) without a version field | Soft landing only; fixture churn is in-repo, so rewrite once | -| Attach-only Studio | Clean separation, but changes operations by requiring serve-first | - -## References - -- Runtime config shape: [config schema](../reference/config-schema.md) -- Runtime YAML guide: [write runtime config](../how-to/config/write-runtime-config.md) -- Security rules: [runtime security](security.md) -- Robot owner envelope: `physicalai.robot.transport._owner_config.RobotOwnerConfig` -- Camera publisher envelope: `physicalai.capture.transport._spec.CameraSpec` -- Review context: Mark's comments on Studio PR #818 (factory wrap; plugins - should not care about `SharedRobot`) From cb561ffe6835024cfd8d993100ca11b88eafc652 Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Wed, 22 Jul 2026 13:39:32 +0200 Subject: [PATCH 03/16] rename docs --- docs/development/component-config-report.md | 361 ++++++++ docs/development/component-config.md | 945 ++++++++++++++++++++ 2 files changed, 1306 insertions(+) create mode 100644 docs/development/component-config-report.md create mode 100644 docs/development/component-config.md diff --git a/docs/development/component-config-report.md b/docs/development/component-config-report.md new file mode 100644 index 0000000..2007765 --- /dev/null +++ b/docs/development/component-config-report.md @@ -0,0 +1,361 @@ +# Captured component configuration — design report + +**Audience:** Runtime developers +**Status:** Accepted — implement per rollout +**Canonical spec:** [robot-construction-remembered-init.md](robot-construction-remembered-init.md) +**Context:** Follow-up to [Studio SharedRobot #818](https://github.com/open-edge-platform/physical-ai-studio/pull/818) + +This report is the human-readable map of the design. When behavior is ambiguous, the canonical design note wins. + +![Architecture: behavior, construction, and transport layers](captured-component-config-architecture.png) + +--- + +## One-sentence summary + +Opt-in `@export_config` keeps constructor args as JSON-safe `class_path` + `init_args`, so Studio and SharedRobot/SharedCamera can spawn fresh components **without** special-case serializers or transport imports in plugins. + +--- + +## Why it exists + +```mermaid +flowchart LR + subgraph today [Today] + JA[jsonargparse YAML] -->|instantiate| Live1[Live component] + Live2[Live driver] -.->|cannot recover args| Gap[Gap] + Gap --> Studio[Studio / owner spawn] + end + + subgraph after [After this design] + Live3[Live @export_config component] + Live3 -->|to_config| CC[ComponentConfig] + CC -->|instantiate| Live4[Fresh disconnected component] + CC -->|ComponentConfig stdin| Owner[Owner / publisher child] + end +``` + +| Pain | Fix | +| --------------------------------------------------------------- | ------------------------------------------------ | +| jsonargparse builds trees but cannot reverse a live object | `@export_config` + `to_config` | +| SharedRobot needs class + JSON kwargs across a process boundary | Same `ComponentConfig` in the owner envelope | +| Studio serializers per robot/camera type | Plugins opt in; Studio only checks exportability | +| Putting `to_dict` on `Robot` / `Camera` | Keep protocols behavior-only | + +--- + +## Three-layer mental model + +```mermaid +flowchart TB + subgraph behavior [Runtime behavior — unchanged] + R[Robot] + C[Camera] + A[ActionSource] + end + + subgraph construction [Construction — new] + DI["@export_config"] + TC[to_config / instantiate] + CFG["ComponentConfig
class_path + init_args"] + DI --> TC --> CFG + end + + subgraph transport [Transport envelopes — consume config] + RO[RobotOwnerConfig] + CP[Camera publisher config] + SR[SharedRobot] + SC[SharedCamera] + RO --> SR + CP --> SC + end + + behavior -.->|opt-in only| construction + CFG -->|robot: / camera:| transport +``` + +**Remember:** + +- **Construction** = how to build a fresh instance +- **Transport** = name, rate, `service_name`, timeouts, sharing +- **Exportable ≠ shared** — Studio owns `robot.share`; `@export_config` only enables config export + +--- + +## Public API (`physicalai.config`) + +```python +ComponentConfig = {"class_path": str, "init_args": dict[str, JsonValue]} + +@export_config # opt-in on concrete classes +to_config(value) # live → ComponentConfig +instantiate(config) # trusted ComponentConfig → fresh object +is_config_exportable # decorator marker OR __component_config__ +``` + +```mermaid +sequenceDiagram + participant App + participant Export as @export_config + participant API as physicalai.config + participant Child as Owner/publisher + + App->>Export: SO101(port, calibration) + Export-->>App: live robot + stored init_args + App->>API: to_config(robot) + API-->>App: ComponentConfig + App->>Child: ComponentConfig stdin JSON (same cwd) + Child->>API: instantiate(robot) + API-->>Child: fresh SO101 (not connected) +``` + +Library code always uses module-level `to_config(value)` (type-checker friendly). An injected instance method is optional sugar only. + +--- + +## End-to-end data flow + +```mermaid +flowchart LR + subgraph parent [Parent process] + B[Builder / CLI / Studio] + T[to_config] + Env[Transport envelope
robot/camera ComponentConfig] + B --> T --> Env + end + + subgraph child [Child process same cwd] + Val[Validate new shape] + Inst[instantiate] + Proto[Check Robot/Camera] + Conn[connect later] + Val --> Inst --> Proto --> Conn + end + + Env -->|trusted stdin JSON| Val +``` + +**Trust rule:** `instantiate()` is for trusted local / parent→child config only. Never run it on Zenoh metadata, camera metadata, or peer payloads. + +--- + +## What gets exported (v1) + +```mermaid +flowchart TB + RR[RobotRuntime] + RR --> Robot + RR --> AS[ActionSource] + RR --> Cams[cameras map] + RR --> Cbs[callbacks] + + Robot --> SO101 + Robot --> WidowXAI + Robot --> Bimanual["BimanualWidowXAI
left + right nested"] + + AS --> PS[PolicySource] + AS --> TS[TeleopSource] + + PS --> IM[InferenceModel
path-rooted] + PS --> Ex[Sync / Async / RTC Execution] + PS --> Q[Chunked / RTC ActionQueue] + Q --> Sm[Lerp / Replace Smoother] + + Cams --> UVC[UVC / RealSense / …] + Cbs --> CB[Console / LPF / Jsonl / Rerun / Async] +``` + +| Area | v1 rule | +| ------------------------ | ----------------------------------------------------------------------------------------- | +| Defaults | Capture only supplied args — omit ⇒ current ctor defaults on replay | +| `PolicySource` | Default queue uses **LerpSmoother**; bare `ChunkedActionQueue()` uses **ReplaceSmoother** | +| `InferenceModel` | Path-rooted (`export_dir`, scalars); live runner/processor overrides fail `to_config` | +| `TeleopSource.to_action` | Replayable only when omitted | +| Bimanual | One SharedRobot owner for the composite | +| Callbacks | Exact first-party set; observers with post-hoc `session_id` out of scope | + +--- + +## Transport migration (private wire) + +Do **not** bump `ROBOT_TRANSPORT_PROTOCOL_VERSION` or camera frame `PROTOCOL_VERSION` for this. Those are network/frame protocols. Startup stdin is a same-package `Popen` handshake — **hard cutover** to `ComponentConfig` with no `config_format` and no dual-read (see [wire decision](shared-construction-wire-decision.md)). + +```text +# Robot owner — after +{name, robot: {class_path, init_args}, allow_remote, rate_hz, idle_timeout, …} + +# Camera publisher — after +{camera: {class_path, init_args}, service_name, idle_timeout, …} +``` + +Writers and readers speak only the new shape. Legacy flat stdin (`robot_class` / `camera_type`) is rejected before import. + +```mermaid +flowchart TB + subgraph robot_stdin [Robot owner stdin] + R2[name, rate_hz, …] + R3["robot: {class_path, init_args}"] + end + + subgraph cam_stdin [Camera publisher stdin] + C2[service_name + transport fields] + C3["camera: {class_path, init_args}"] + end +``` + +### SharedCamera naming + +| Mode | `service_name` | +| ----------------- | -------------------------------------------------------------------------- | +| Built-in spawn | Derived in transport: `class_path` → legacy `CameraType` token + device id | +| Third-party spawn | **Required** explicit name (no hashing) | +| Attach-only | Required; no construction config | + +`service_name` lives **beside** `camera: ComponentConfig`, never inside `init_args`. + +### Public API (adapters) + +| Surface | Accept (XOR) | Preferred | +| ---------------------- | ------------------------------------------------------- | ----------------------------- | +| `SharedRobot` | `robot=` **xor** `robot_class`+`robot_kwargs` (adapter) | `from_config` / `from_robot` | +| `SharedCamera` | `camera=` **xor** `camera_type`+kwargs (adapter) | `from_config` / `from_camera` | +| CLI `physicalai robot` | `--robot` **xor** legacy flags (adapter) | `--robot` | +| Metadata | Keep key `robot_class` | Value = public `class_path` | + +Legacy flat forms always pack `ComponentConfig` and write only the new stdin. Removing adapters is a later cleanup PR. + +`from_robot` / `from_camera`: require exportable, **reject if connected**, never disconnect implicitly. + +--- + +## Paths: relative-first, cwd at open + +```mermaid +flowchart LR + subgraph export [to_config] + P1["Path → str(path) as given"] + P2["str unchanged
e.g. ./calibration.json"] + end + + subgraph resolve [instantiate / ctor open] + P3[Resolve against process cwd] + P4[Child inherits parent cwd at Popen] + P3 --> P4 + end +``` + +- Prefer relative paths for project- or folder-local configs (portable export directories). +- Do not absolutize in `to_config` or IPC writers. +- Unsupported: `chdir` between export and spawn when configs still contain relatives. +- Future bundle/export-folder root may pin resolution without absolutizing — not v1. + +--- + +## Studio consumption + +```mermaid +flowchart TD + Builder[Plugin builder] --> Driver[Ordinary Robot/Camera] + Driver --> Share{robot.share?} + Share -->|no| Direct[In-process driver] + Share -->|yes| Exp{is_config_exportable?} + Exp -->|no| Fail[Fail loud] + Exp -->|yes| Conn{connected?} + Conn -->|yes| Fail2[Fail — caller must disconnect] + Conn -->|no| SR[SharedRobot.from_config] +``` + +Plugins never import Zenoh, iceoryx2, `SharedRobot`, or `SharedCamera`. + +--- + +## Security (developer checklist) + +| Do | Don't | +| ----------------------------------------------------- | ----------------------------------------------------- | +| Treat `class_path` as executable trusted config | Instantiate configs from `/metadata` or network peers | +| Validate malformed input before import | Assume validation = sandbox | +| Carry config parent→child stdin only | Accept component configs from subscribers | +| Keep nesting depth bounded (`_MAX_CONFIG_DEPTH = 10`) | Unbounded recursive instantiate | + +See also [security.md](security.md) rules 4, 5, 11, 12. + +--- + +## Out of scope for v1 + +- Inference `ComponentSpec` / `instantiate_component` unification (different defaults, extras, registry) +- Self-contained portable bundles / `to_config(..., profile="self_contained")` / explicit bundle root +- `__component_path_keys__` / public path absolutizers +- Callable references for `TeleopSource.to_action` +- Per-arm SharedRobot for bimanual nests +- Persisted workflow document versioning + +--- + +## Implementation rollout + +```mermaid +gantt + title Suggested implementation order + dateFormat X + axisFormat %s + + section Core + physicalai.config engine :a1, 0, 1 + export_config + to_config :a2, 1, 2 + SO101 / WidowX / Bimanual :a3, 2, 3 + + section Transport + SharedRobot + owner hard cutover :b1, 3, 4 + SharedCamera + publisher cutover :b2, 4, 5 + + section Runtime tree + PolicySource graph + InferenceModel :c1, 5, 6 + RobotRuntime + callbacks :c2, 6, 7 + + section Downstream + Studio share policy :d1, 7, 8 + Docs / preview versioning note :d2, 8, 9 +``` + +| Step | Deliverable | +| ---: | -------------------------------------------------------------------------------------------- | +| 1 | `ComponentConfig`, instantiate, depth/cycles, shared importer (no inference behavior change) | +| 2 | `@export_config`, `to_config`, `is_config_exportable` | +| 3 | SO101, WidowXAI, BimanualWidowXAI round-trips | +| 4 | SharedRobot + owner stdin hard cutover + public/CLI adapters (cwd inherit for relatives) | +| 5 | SharedCamera + publisher stdin hard cutover + `service_name` rules | +| 6 | PolicySource graph, TeleopSource, path-rooted InferenceModel, v1 callbacks | +| 7 | RobotRuntime -> `instantiate` + jsonargparse | +| 8 | Studio drops interim serializers | +| 9 | User-facing docs | +| 10 | Separate: inference factory follow-up (optional) | + +Implement **one step at a time**. Keep the design note open as the contract. + +--- + +## Plugin checklist + +1. Implement `Robot` / `Camera` / `ActionSource` / callback protocol +2. `@export_config` (or `__component_config__`) +3. JSON-normalizable args; constructors accept normalized forms +4. Relative path args OK; keep cwd stable through Shared\* spawn (or use absolute/`Path` as given) +5. Stable public import path + `__config_class_path__` when re-exported +6. Round-trip test: `to_config` → `json` → `instantiate` → `to_config` equal + +--- + +## Quick reference + +| Symbol | Role | +| --------------------------- | ------------------------------------------------------------------- | +| `@export_config` | Opt into `ComponentConfig` export (stores supplied `__init__` args) | +| `ComponentConfig` | Wire shape shared with jsonargparse | +| `to_config` / `instantiate` | Export / trusted rebuild | +| `is_config_exportable` | Can we get a recipe? | +| Owner/publisher stdin | Hard cutover to `robot:` / `camera: ComponentConfig` | +| `service_name` | Camera transport identity (not construction) | + +**Full rules and required tests:** [robot-construction-remembered-init.md](robot-construction-remembered-init.md) diff --git a/docs/development/component-config.md b/docs/development/component-config.md new file mode 100644 index 0000000..f308c47 --- /dev/null +++ b/docs/development/component-config.md @@ -0,0 +1,945 @@ +# Captured component configuration (design note) + +Status: **accepted — implement per rollout**. Follow-up to Studio SharedRobot +integration +([physical-ai-studio#818](https://github.com/open-edge-platform/physical-ai-studio/pull/818)); +out of scope for that PR, now ready for Runtime implementation. + +## Decision + +Add one opt-in, transport-agnostic decorator for components whose constructor +configuration must be exportable so a fresh instance can be created later. + +```text +Robot / Camera / ActionSource = runtime behavior (unchanged) +@export_config = opt into ComponentConfig export from ctor args +ComponentConfig = plain class_path + init_args data +``` + +Use the same contract for robots, cameras, action sources, runtimes, +callbacks, and nested components. Keep process names, rates, timeouts, and +other transport settings in their existing transport envelopes. + +The contract closes both directions: + +```text +live component -> to_config(value) -> JSON/YAML -> instantiate(config) -> new component +``` + +It uses the same `class_path` + `init_args` vocabulary already consumed by +jsonargparse runtime configs. + +## Problem + +jsonargparse can instantiate a component tree from typed constructor +signatures, but it cannot recover constructor inputs from an arbitrary live +object. `SharedRobot` has the same gap: owner spawn needs an importable class +and JSON-safe constructor arguments, not a live driver with open hardware +resources. + +Studio must not special-case SO101, WidowXAI, camera, runtime, or action-source +serializers. Plugins must not import `SharedRobot` or `SharedCamera`. Adding +serialization methods to `Robot`, `Camera`, or `ActionSource` would mix +construction with runtime behavior. + +## Goals + +1. Export an opted-in live component as JSON-safe `class_path` + + `init_args` data. +2. Instantiate that data as a new component without invoking lifecycle methods + beyond its constructor. +3. Recursively support nested components and collections of components. +4. Emit nested `class_path` + `init_args` fragments that jsonargparse accepts + without translation. A root config may still sit under a CLI wrapper key + such as `runtime:`. +5. Keep hardware and action-source protocols unchanged. +6. Let third-party plugins opt in without depending on transports. +7. Fail at serialization or validation time when replay is not possible. + +## Non-goals + +- Snapshot live state, open handles, queues, threads, or connections. +- Reflect over arbitrary objects and guess how to reconstruct them. +- Serialize lambdas, closures, or arbitrary callables. +- Make untrusted `class_path` imports safe. +- Replace transport configuration such as robot names or camera service names. +- Guarantee that mutation after construction appears in the config. + +## Public API + +Place this API in a neutral module such as `physicalai.config`, +not under robot, capture, runtime, or inference. + +```python +from typing import TypedDict + +JsonScalar = None | bool | int | float | str +JsonValue = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] + + +ComponentConfig = TypedDict("ComponentConfig", {"class_path": str, "init_args": dict[str, JsonValue]}) + + +def export_config(cls: type) -> type: ... + + +def to_config(value: object) -> ComponentConfig: ... + + +def instantiate(config: ComponentConfig) -> object: ... + + +def is_config_exportable(value: object) -> bool: ... +``` + +Normal use stays small: + +```python +@export_config +class SO101: + def __init__(self, port: str, calibration: dict | str): ... + + +robot = SO101("/dev/ttyACM0", "./calibration.json") +config = to_config(robot) +restored = instantiate(config) +``` + +The decorator can also inject `robot.to_config()` as a runtime convenience. +Library code and documentation always use module-level `to_config(robot)` +because static type checkers cannot express that a class decorator adds an +instance method without changing the decorated class's declared type. This +avoids introducing a public protocol only to make the convenience method +type-check. Structural `Protocol` checks ignore the extra method, so the +injection does not break robot/camera/action-source conformance. + +`ComponentConfig` is a typed description of the existing jsonargparse wire +shape, not a stateful model with its own methods. Callers can pass ordinary +dictionaries loaded from JSON or YAML to `instantiate()`; it validates them +before importing code. `instantiate()` is the one shared, explicit trusted-code +boundary: it imports `class_path`, recursively instantiates nested configs, +then calls the class with decoded keyword arguments. + +The implementation rejects local classes (``), non-class import +targets, non-string mapping keys, malformed nested configs, and values outside +the supported JSON model. + +Do not add a public `Constructible` or `Replayable` protocol. Runtime and +Studio code that needs to test exportability uses `is_config_exportable(value)`. +A value is exportable if and only if it carries the private `@export_config` +decorator marker **or** provides a callable `__component_config__` hook. +`to_config(value)` uses the same predicate. This keeps the plugin-facing +contract to one decorator plus one private escape hatch, and gives +`share_if_requested` a single key for both paths. + +### Naming + +Use `export_config` because it names the public capability: the class can export +a `ComponentConfig` via `to_config`. That aligns with `is_config_exportable` +and does not claim to serialize live object state. The decorator works by +remembering supplied `__init__` arguments; that mechanism is an implementation +detail documented here, not part of the public name. + +Rejected names: + +| Name | Problem | +| --------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `capture_init` | Collides with the `physicalai.capture` camera package | +| `record_init` | Will collide with planned runtime recording callbacks | +| `serializable` | Implies complete object-state serialization | +| `configurable` | Usually means an object accepts configuration, not that it exports it | +| `replayable` | Does not say what is replayed and can suggest runtime/action replay | +| `reconstructible` | Accurate but cumbersome and still hides the config-export contract | +| `remember_init` / `snapshot_init` | Mechanism-only; weaker fit next to `to_config` | +| `config_exportable` | Accurate but clumsy as a class decorator | +| `export_init` | Precise about ctor args, but understates hook-only export and rhymes less with `is_config_exportable` | +| `has_captured_init` | Misleading for hook-only classes; use `is_config_exportable` | + +Use `to_config(value)` for export because the result is directly consumable +jsonargparse component configuration. Use `instantiate()` for the trusted +inverse because it creates a fresh instance; avoid `deserialize()`, which +suggests restoring prior object state. + +### Relationship to inference `ComponentSpec` + +Inference factory unification is explicitly out of scope for v1. +`ComponentSpec` is a manifest compatibility model with registry aliases, flat +parameters, artifact handling, and `extra="allow"` semantics. Tightening its +class-path mode or routing registry mode through `instantiate()` changes the +manifest schema and inference critical path; the robot/camera construction use +case does not require either change. + +For v1: + +- `ComponentConfig` owns strict `class_path` + `init_args` validation and + bounded recursive instantiation in `physicalai.config`. +- `ComponentSpec`, `ComponentSpec.from_class()`, `_MAX_COMPONENT_DEPTH`, and + `instantiate_component()` retain their current behavior. +- Rollout step 1 consolidates the duplicated inference and robot dotted-path + import helpers into `physicalai.config` in a behavior-preserving refactor. + Transports call `instantiate()` and add protocol checks; they do not add + another importer. + +A follow-up design can audit exported manifest fixtures, then decide whether +`ComponentSpec` class-path mode should delegate to `ComponentConfig`. That +work must address two known differences: `ComponentSpec.from_class()` applies +defaults while `@export_config` omits unsupplied defaults, and class-path extra +fields are currently accepted and ignored. Registry mode remains an inference +adapter even if class-path construction is later shared. + +Use `_MAX_CONFIG_DEPTH = 10` for the new config engine. Count traversal through +lists and mappings as well as nested component configs, and track container +identities to reject cycles before JSON serialization. Do not apply this +stricter counting rule to existing inference manifests in v1. + +## Exporting constructor configuration + +`@export_config` is the opt-in contract. Constructor signature changes flow +into exported configuration automatically: + +```python +@export_config +class PolicySource(ActionSource): + def __init__(self, model, execution=None, action_queue=None, *, task=None): + self._execution = execution or SyncExecution() + self._action_queue = action_queue or ChunkedActionQueue(...) +``` + +The wrapper uses `inspect.signature()` and `Signature.bind()` before invoking +the constructor. It converts positional arguments to their parameter names, +removes `self`, and flattens a bound `**kwargs` mapping into `init_args`. +Positional-only parameters and `*args` are not replayable through +`class_path` + keyword `init_args`; reject decorated constructors that declare +them. + +Do not call `BoundArguments.apply_defaults()`. Capture only arguments the +caller supplied. Omitted arguments remain omitted so reconstruction invokes +the current constructor defaults. An explicitly supplied `None` remains in +the spec. This produces compact configs and avoids freezing default component +objects selected internally by the current release. + +The wrapper must preserve `__wrapped__` and the original signature through +`functools.wraps()`. jsonargparse and documentation tools must continue to see +the real constructor signature. + +Call the constructor before committing the captured arguments. A failed +constructor must not leave a usable construction record. Do not normalize or +reject captured values during `__init__`; unsupported values fail later when +`to_config(value)` is requested, so opting in does not change ordinary +construction behavior. + +Snapshot built-in mutable containers recursively when capturing them. Keep +nested decorated components and explicit domain values by reference so their +own conversion remains authoritative. This prevents later mutation of a +caller-owned list or dictionary from silently changing the recipe. + +### Inheritance rule + +Decorate every concrete class that should export constructor configuration. Do +not decorate a shared base solely to make all subclasses exportable: a base +signature cannot capture arguments owned by a subclass. + +The decorator tracks construction depth on the instance. If decorated +constructors call other decorated constructors through `super()`, only the +outermost successful call commits its bound arguments. This prevents a camera +base constructor from overwriting the complete concrete-camera recipe with +only `color_mode`. + +A concrete subclass that overrides `__init__` must apply `@export_config` +again. Tests and contributor documentation must state this explicitly. + +There is no merge or last-write behavior. The outermost decorated constructor +owns the complete `init_args`. `to_config(value)` verifies that the +most-derived class's effective `__init__` carries the decorator marker **or** +that the instance provides `__component_config__`; an overriding, undecorated +`__init__` without the hook fails loudly instead of emitting a partial recipe. +A subclass that inherits a decorated constructor unchanged remains valid +because its effective constructor has the marker and the inherited signature +is complete. + +Select `class_path` from the most-derived `type(self)`, never from the class +that defined an inner decorated constructor. Classes can declare a stable +`__config_class_path__` public re-export; otherwise use +`type(self).__module__ + "." + type(self).__qualname__`. Before emitting the +spec, resolve the selected path and verify that it identifies exactly +`type(self)`. + +First-party public robot and camera classes must declare their stable public +re-export path (for example, `physicalai.robot.SO101`) instead of emitting an +internal defining-module path. Plugins must do the same when they promise a +stable public import surface. + +Factory or legacy classes whose configuration does not correspond directly to +one constructor call can implement a private `__component_config__()` hook. +`to_config(value)` and `is_config_exportable(value)` recognize that hook. This +is an internal escape hatch, not a second public protocol; such classes are +the exception, not the default. + +`to_config(value)` describes the object **as constructed**, not its current +mutable state. + +## Serialization and decoding rules + +Normalization is recursive and produces JSON-safe values. + +| Constructor value | Stored value | Value passed on replay | +| -------------------------------------------- | ----------------------------- | ---------------------------- | +| `str`, `int`, finite `float`, `bool`, `None` | as-is | as-is | +| non-finite `float` (`NaN`, `±Inf`) | error | not applicable | +| `Path` | string as given (`str(path)`) | string | +| `Enum` | JSON-safe `.value` | enum value representation | +| decorated or hook-exportable component | `{class_path, init_args}` | instantiated component | +| mapping with string keys | normalized mapping | decoded mapping | +| list or tuple | normalized list | list | +| domain value with an explicit codec | codec output | constructor-compatible value | +| any other object | error | not applicable | + +`Path` values serialize as `str(path)` **without** `resolve()`. Relative paths +stay relative; absolute paths stay absolute. Prefer relative paths for +project- or folder-local configs so an exported directory of config + +artifacts remains portable. Resolution happens at `instantiate` / +constructor open against the process cwd (see Transport integration). + +Reject non-finite floats during normalization. Python's default `json` encoder +permits `NaN` / `Infinity`, which are not portable across strict JSON +consumers. + +For v1, domain codecs remain minimal. `SO101Calibration` can normalize with +`to_dict()` because the SO101 constructor already accepts a dictionary. Do not +add a global arbitrary-object codec registry until a second concrete domain +type requires one. + +Constructors participating in this contract must accept the normalized JSON +representation of their arguments. For example, constructors typed with an +enum must also accept its string value or normalize it internally. This keeps +direct `instantiate()` and jsonargparse reconstruction +equivalent. + +This requirement is enforced at the opt-in boundary. Every first-party class +gains a test that serializes through `json.dumps()` / `json.loads()`, invokes +its constructor through `instantiate()`, and verifies normalized +re-serialization. A class does not ship with `@export_config` until that test +passes. Provide small constructor-side coercion helpers for repeated types such +as `StrEnum`; do not build a general annotation-driven coercion engine. Plugins +have the same mandatory JSON-boundary round-trip test. + +Any dictionary containing `class_path` is reserved as a nested component +config. Its only allowed keys are `class_path` and `init_args`; omitted +`init_args` means an empty mapping. Extra keys or invalid values are malformed +configs, not ordinary dictionaries. A caller that needs `class_path` as a normal +data key must use an explicit domain codec or wrapper. Error messages and the +plugin contract must name this escape hatch. This rule removes ambiguity and +fails closed during generic recursive deserialization. Do not add a sentinel +marker unless a second concrete domain collision demonstrates the need. + +Errors identify the full argument path: + +```text +physicalai.runtime.TeleopSource.init_args.to_action: +cannot encode function ''; omit it or use a supported component value +``` + +## Round-trip contract + +For every opted-in component `value`, tests establish: + +```python +config = to_config(value) +wire = json.loads(json.dumps(config)) +restored = instantiate(wire) +``` + +The guarantee is construction equivalence, not object equality: + +- `type(restored)` matches the represented class. +- `instantiate()` invokes constructors but does not call lifecycle methods such + as `connect()`, `start()`, or `run()`. +- Constructors may still read files, allocate memory, initialize SDKs, or load + models according to their existing contract. +- Calling `to_config(restored)` produces the same normalized config. +- Connecting it addresses the same configured hardware or service. +- Runtime-selected defaults may change between package versions when the + original constructor argument was omitted or `None`. + +Round-trip tests use the public import path emitted in `class_path`, not only +the defining module path. Public paths keep serialized configs stable across +internal module moves. + +### External references and portability + +A component config is a replay recipe, not necessarily a self-contained +artifact. Classify emitted values in two practical profiles: + +- **Local replay** permits paths and other external references. Relative + paths are preferred for project- or folder-local configs. Owner/publisher + subprocess IPC inherits the parent cwd at `Popen` (see Transport + integration); that is the v1 resolution root. +- **Self-contained replay** requires domain values to be inline and rejects + unresolved external dependencies. For example, SO101 calibration must be an + inline dictionary rather than a path. + +The full runtime example below is a local-replay config because both calibration +and `export_dir` reference the filesystem. Relative paths stay meaningful when +the working directory (or a future exported folder layout) is the project root. +A future config-bundling API can copy referenced artifacts and keep or rewrite +relative paths; that is separate from construction replay. + +`to_config(value)` emits local replay by default. A later +`to_config(value, profile="self_contained")` API should be added only when a +concrete portable-export workflow owns artifact bundling and validation. + +`ComponentConfig` has no version field in v1. It is a nested jsonargparse shape, +not an owned persistent document format. IPC envelopes version their own wire +formats as described below. Before advertising generated runtime YAML as a +stable persisted artifact, define a versioned root document that contains the +runtime component config; do not add a version field to every nested component. + +## Full runtime example + +A `RobotRuntime` config recursively contains all participating components: + +```yaml +class_path: physicalai.runtime.RobotRuntime +init_args: + robot: + class_path: physicalai.robot.SO101 + init_args: + port: /dev/ttyACM0 + calibration: ./calibration.json + action_source: + class_path: physicalai.runtime.PolicySource + init_args: + model: + class_path: physicalai.inference.InferenceModel + init_args: + export_dir: ./exports/act_policy + fps: 30 + cameras: + wrist: + class_path: physicalai.capture.UVCCamera + init_args: + device: /dev/video0 + width: 640 + height: 480 + fps: 30 +``` + +This nested fragment is accepted by jsonargparse without translation. For the +existing CLI parser it is nested under `runtime:`; trusted local code may also +pass the bare fragment to `instantiate()`. + +## Component-specific decisions + +### Robots + +SO101 and WidowXAI opt in directly. SO101 calibration is stored as a path when +constructed from a path and as a normalized dictionary when constructed from +an object. + +Private arguments are not excluded by naming convention. If replay needs an +argument, capture it. `SO101.uncalibrated()` currently calls the constructor +with `_allow_uncalibrated=True`, so the decorated outer constructor must retain +that supplied private argument. Uncalibrated round-trip tests are required +before claiming SO101 support; a future public constructor parameter can +replace the private replay detail. + +Bimanual robots no longer need a special serialization design once each arm +uses `@export_config`: `left` and `right` become nested configs under +`BimanualWidowXAI`. v1 `SharedRobot` spawn treats the composite as **one +owner**. Per-arm `SharedRobot` wrapping of nested arms is out of scope. + +### Cameras + +Camera implementations opt in using public class paths. This supports +third-party cameras without extending the `create_camera()` registry. + +Today's `CameraSpec(camera_type, camera_kwargs)` cannot preserve a third-party +class path because `build()` routes through the built-in `create_camera()` +registry. Supporting third-party replay therefore requires a semantic +transport migration, not a wrapper translation. + +Change the private publisher envelope to contain `camera: ComponentConfig` +plus transport fields including `service_name`; `build()` delegates to +`instantiate()` and verifies the result satisfies `Camera`. This is a private +startup-wire **hard cutover** in the same PR as the SharedCamera spawn path: +rewrite the envelope, update fixtures/tests, and drop the legacy +`camera_type` + `camera_kwargs` reader. No `config_format` field and no +dual-read. Normalize built-in names to public class paths in the new shape. +Third-party camera sharing is not supported until this step lands. + +#### SharedCamera service naming + +Transport owns naming; `ComponentConfig` never embeds `service_name`. + +Today spawn derives +`physicalai/camera/{camera_type}/{device_id}/frame` from the registry enum. +After the envelope migration there is no `camera_type` on the construction +config. Rules: + +- **Built-in spawn:** a private map from public `class_path` to the legacy + `CameraType` token (`uvc`, `ip`, `realsense`, `basler`, `genicam`). That map + lives in **capture transport**, not in `physicalai.config`. Derive + `service_name` with the existing scheme using that token plus device id from + `init_args` (`serial_number` else `device`, including `/dev/` symlink + resolve). This preserves existing attacher discovery. +- **Third-party spawn:** require an explicit `service_name` on `SharedCamera` / + the publisher envelope; fail before publisher start if missing. Do not hash + `class_path` or `init_args` into a name (unstable across arg ordering and + omitted defaults). +- **Attach-only:** unchanged — `service_name` required; no construction config + needed. +- The publisher payload carries `service_name` **alongside** + `camera: ComponentConfig`, never inside `init_args`. + +### Action sources + +v1 first-party graph that must round-trip under `PolicySource` (anything else +fails at `to_config`): + +| Class | Notes | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `PolicySource` | Captures `model`, optional `execution`, `action_queue`, `task` | +| `InferenceModel` | Path-rooted (see below) | +| `SyncExecution` | | +| `AsyncExecution` | | +| `RTCExecution` | JSON-safe / scalar constructor args only; `latency_tracker` and live `postprocessors` must be omitted or `to_config` fails | +| `ChunkedActionQueue` | Includes nested `smoother` when supplied | +| `RTCActionQueue` | | +| `LerpSmoother` | Required when an explicit `ChunkedActionQueue(smoother=...)` is captured | +| `ReplaceSmoother` | Same | + +Omitted `execution` / `action_queue` stay omitted so reconstruction uses +current constructor defaults. `PolicySource` itself defaults to +`SyncExecution()` and `ChunkedActionQueue(smoother=LerpSmoother(...))`. +Bare `ChunkedActionQueue()` (smoother omitted) restores `ReplaceSmoother()` — +that is the queue's own default, not the PolicySource default. Explicit nested +values must themselves be exportable. + +`TeleopSource.leader` can be a nested robot config. Its optional `to_action` +callable is replayable only when omitted in v1. Module-level callable support +can be added later with a distinct callable-reference type; do not treat +arbitrary dotted paths as both classes and functions in `ComponentConfig`. + +### Inference model + +`InferenceModel.__init__` eagerly reads the manifest, creates an adapter, and +loads model artifacts. In v1 it is a path-rooted component: capture +`export_dir`, `policy_name`, `backend`, `device`, and JSON-safe scalar adapter +kwargs. Omitted runner, processor, and callback overrides remain omitted and +are reconstructed from the exported package. + +Non-scalar or live override arguments among captured kwargs (including +non-scalar `adapter_kwargs`, and live `runner` / `preprocessors` / +`postprocessors` / `callbacks`) make `to_config` fail. Do not silently drop +them. Live overrides are in scope only when every nested value independently +exports component config or `InferenceModel` supplies the private manual +config hook. Instantiating an inference config can allocate substantial +resources; it is not analogous to constructing a disconnected robot driver. + +### Runtime + +`RobotRuntime` captures its robot, action source, FPS, camera mapping, and +callbacks. A runtime config is therefore the root of a complete jsonargparse +workflow config. It never captures connection state, session IDs, callback +bus state, observations, or action queues created during a run. + +v1 first-party callbacks that must round-trip when supplied (anything else +fails at `to_config`): + +| Class | Notes | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `ConsoleCallback` | scalar args | +| `LowPassFilterCallback` | scalar args | +| `JsonlCallback` | path arg; `Path` → `str(path)` as given; ctor opens the file immediately (eager I/O like `InferenceModel`); tests use temp paths | +| `RerunCallback` | JSON-safe / scalar args only (`save_path`, `connect_addr`, mode, …) | +| `AsyncCallback` | nested `inner` must itself be exportable; reject inners with action hooks per the existing `AsyncCallback` guard | + +Omitted or empty `callbacks` stay omitted or empty. Observer helpers under +`runtime/observer/` that take `session_id` after construction are out of scope +for `RobotRuntime` capture in v1. + +## Transport integration + +Keep construction and transport ownership separate: + +```python +SharedRobot.from_robot(robot, name="follower") +SharedRobot.from_config(to_config(robot), name="follower") +``` + +`SharedRobot.from_robot()` is sugar only. It requires `is_config_exportable(robot)`, +rejects a connected driver via `robot.is_connected()`, then calls +`from_config(to_config(robot), ...)`. It must not scrape constructor kwargs +ad hoc. It never disconnects a caller-owned live driver implicitly. Studio +builders must return disconnected drivers for wrapping, or Studio must +explicitly release a driver it owns before calling `from_robot()`. Prefer +`from_config()` when no live instance is otherwise needed. + +### Private startup envelopes (hard cutover) + +Changing `RobotOwnerConfig` from `robot_class` + `robot_kwargs` to +`robot: ComponentConfig` also changes private startup JSON. Parent and child +are always the same installed package at `Popen`, and stdin is ephemeral — not +a persisted document or peer protocol. Hard-cutover both envelopes in the same +PR as the Shared\* spawn path: rewrite writers, readers, and fixtures; do **not** +add a `config_format` field, dual-read, or shape-detection fallback. + +```text +# Robot owner stdin — before +{name, robot_class, robot_kwargs, allow_remote, rate_hz, idle_timeout, …} + +# Robot owner stdin — after +{name, robot: {class_path, init_args}, allow_remote, rate_hz, idle_timeout, …} + +# Camera publisher stdin — before +{camera_type, camera_kwargs, service_name, idle_timeout, …} + +# Camera publisher stdin — after +{camera: {class_path, init_args}, service_name, idle_timeout, …} +``` + +Writers and readers speak only the new shape. Reject payloads that still carry +legacy flat keys (`robot_class` / `robot_kwargs`, or `camera_type` / +`camera_kwargs`) before import or hardware access — no silent translation. + +Do not reuse `capture.transport.PROTOCOL_VERSION` or +`ROBOT_TRANSPORT_PROTOCOL_VERSION` for these changes. Those version frame and +robot network payloads respectively, not the one-shot stdin construction +envelopes. Existing detached owners/publishers are discovered through their +current transport protocols and do not receive a new startup config. + +Decision record: [shared construction wire decision](shared-construction-wire-decision.md). + +### Paths and cwd for local replay / IPC + +Relative paths are preferred for project- and folder-local configs (for +example an exported directory that contains `runtime.yaml`, calibration, and +model artifacts together). Do **not** rewrite paths to absolute in `to_config` +or in IPC writers — that fights portable folder export and is easy to get +wrong for non-path strings (URLs, roles, `host:port`). + +v1 rules: + +1. `to_config`: `Path` → `str(path)` as given; `str` paths unchanged. Relative + stays relative; absolute stays absolute. +2. Resolution is against the **process cwd** at `instantiate` / constructor + open (for example `Path(calibration).read_text()`). +3. Owner/publisher children are started with `Popen` and **no `cwd=` + override**, so they inherit the parent cwd at spawn. That **is** the v1 + IPC contract for relative paths — the same behavior today’s transport + already uses. +4. **Unsupported:** calling `os.chdir` (or otherwise changing the process cwd) + between capturing/exporting a relative-path config and owner/publisher + spawn when those configs still contain relatives. +5. Future: an explicit bundle/export-folder root may pin resolution without + absolutizing paths; that is out of scope for v1. + +`port` remains absolute (`/dev/…`); relative serial ports are unsupported. +Camera URL/stream fields are not filesystem paths. The built-in camera +`class_path` → `CameraType` map lives in **capture transport**, not in +`physicalai.config`. + +### Public SharedRobot, CLI, and metadata + +Private stdin hard cutover does not force a public API break. Keep legacy flat +kwargs as **adapters** that pack `ComponentConfig` and write only the new +stdin. Removing those adapters is a later cleanup PR, not a calendar dual-read +window on the private wire. + +- **`SharedRobot` constructor:** accept `robot: ComponentConfig` **XOR** + legacy `robot_class` + `robot_kwargs`. Passing both is an error — no merge. + Legacy flat kwargs are an adapter only; they immediately become + `robot: {class_path, init_args}` before spawn. New code and docs prefer + `SharedRobot.from_config(...)` and `from_robot(...)`. +- **Public path normalization:** when accepting a class object or a legacy + dotted path, normalize through the same public-path resolution / + `__config_class_path__` used by `to_config` **before** store, metadata + advertise, and conflict compare. This prevents false mismatches between + defining-module paths (for example + `physicalai.robot.so101.so101.SO101`) and public re-exports + (`physicalai.robot.SO101`). +- **CLI `physicalai robot`:** accept `--robot` (ComponentConfig JSON/YAML) + **XOR** legacy `--robot_class` / `--robot_kwargs`; both paths write only the + new stdin shape. +- **Network metadata:** do not rename the advertised key. Keep `robot_class` as + the metadata field so `ROBOT_TRANSPORT_PROTOCOL_VERSION` stays unchanged. + Populate it from the normalized public `robot["class_path"]`. Conflict + checks compare that string unchanged. Attach-only / discover paths are + unchanged. + +### Public SharedCamera + +Mirror the SharedRobot public story so step-5 implementers do not invent +three APIs: + +- **`SharedCamera.from_config(camera, *, service_name=None, …)`** — primary + API. Transport kwargs (zero-copy, validation, idle timeout, …) stay on the + SharedCamera / publisher side, never inside `ComponentConfig`. +- **`SharedCamera.from_camera(camera, *, service_name=None, …)`** — sugar: + require `is_config_exportable(camera)`, reject if `camera.is_connected()` + (fail before publisher spawn), never disconnect a caller-owned live camera + implicitly, then `from_config(to_config(camera), …)`. No ad-hoc kwargs + scrape. +- **Constructor adapter:** accept `camera: ComponentConfig` **XOR** legacy + `camera_type` + kwargs. Passing both is an error. Legacy flat form packs + `camera: ComponentConfig` and writes only the new stdin. +- **Who derives `service_name`:** `from_config` and the adapter constructor. + If `service_name` is omitted and `class_path` is a known built-in, derive + via the transport map + device id from `init_args`. If third-party and + `service_name` is omitted, fail before spawn. The publisher envelope always + carries a concrete `service_name`; the child never re-derives it. +- **Attach-only / `from_publisher(service_name=…)`:** unchanged. + +`ComponentConfig` does not import transport code. Instead, transport envelopes +consume the plain config: + +```python +@dataclass(frozen=True) +class RobotOwnerConfig: + name: str + robot: ComponentConfig + allow_remote: bool = False + rate_hz: float = 100.0 + idle_timeout: float | None = 10.0 +``` + +The same separation applies to camera service names, validation settings, +zero-copy mode, publisher rates, and idle timeouts. These settings describe +how a component is shared, not how the underlying component was constructed. + +Config export capability does not imply process sharing. Studio owns an +explicit product/deployment policy such as `robot.share`; +`is_config_exportable` only determines whether the selected sharing path can +obtain a component config. Studio consumption becomes: + +```python +driver = await builder(robot, self) +driver = share_if_requested(driver, name=robot.name, enabled=robot.share) +``` + +`share_if_requested()` leaves the direct driver unchanged when disabled. When +enabled, it requires `is_config_exportable(driver)`, rejects a connected +driver, then calls `SharedRobot.from_config(to_config(driver), name=name)`. +The helper implements Studio policy; it is not part of the generic +component-config API. + +Catalog and plugin builders return ordinary runtime objects. Plugins never +import Zenoh, iceoryx2, `SharedRobot`, or `SharedCamera`. + +## jsonargparse relationship + +jsonargparse remains responsible for: + +- generating CLI schemas from typed constructor signatures; +- merging command-line, environment, and YAML values; +- validating CLI input; +- instantiating runtime configs through the existing CLI. + +The component-config layer is responsible for: + +- recovering a replay recipe from an opted-in live object; +- validating the JSON-safe recipe; +- reconstructing it outside the CLI; +- feeding emitted **nested** `class_path` + `init_args` fragments back into + jsonargparse without translation. A document root may still use a CLI + wrapper key such as `runtime:`. + +Do not depend on jsonargparse internals to remember live-object construction. +The explicit construction contract is also needed in subprocesses and Studio, +where no parser namespace exists. + +## Security boundary + +`class_path` is executable local configuration. `instantiate()` only accepts +trusted application or user-authored config. Never instantiate a config +received from robot metadata, camera metadata, Zenoh, shared memory, or +another untrusted peer. + +Validation makes malformed input predictable; it does not make arbitrary +imports safe. Transport wire protocols may carry a config only from a trusted +parent process to the child it spawned. They must not accept component +configs from network subscribers. + +An allowlist resolver can be added for contexts that need a narrower trust +policy, but it does not replace the trusted-input rule for v1. + +## Plugin contract + +1. Implement the relevant runtime protocol (`Robot`, `Camera`, + `ActionSource`, callback, or another typed component). +2. Opt into config export with `@export_config`. A factory-oriented class can + instead provide `__component_config__()`; both paths satisfy + `is_config_exportable`. +3. Ensure every captured value is JSON-normalizable and constructor-compatible; + domain dictionaries containing reserved `class_path` require an explicit + codec or wrapper. +4. Path-shaped constructor args may be relative. They resolve against the + process cwd at open/`instantiate`. Owner/publisher children inherit the + parent cwd at spawn — keep that cwd stable between export and spawn when + using relatives. Absolute paths and `Path` are fine when you need them. +5. Export the class from a stable public import path. +6. Add construction round-trip tests. + +Opt-in is transitive: a container component can export config only when all +captured nested component values can also export config. + +## Failure semantics + +- `to_config(value)` raises `ComponentConfigError` when a captured value cannot + be normalized. +- `instantiate()` raises `ComponentConfigError` for malformed data before + importing anything. +- `instantiate()` raises `ComponentImportError` when `class_path` cannot + resolve to an importable class. +- Constructor validation and hardware configuration errors propagate from the + constructor with the component path added as context. + +Callers generally recover from these failures by correcting configuration, so +the first implementation can use one public `ComponentConfigError` base with +specific subclasses only where tests or callers distinguish phases. + +## Suggested rollout + +1. Add `ComponentConfig`, bounded normalization/instantiation, cycle checks, + and one shared dotted-path resolver to `physicalai.config`. Consolidate the + duplicated inference and robot importers behind that resolver + (behavior-preserving). Do not change inference factory behavior. +2. Add `@export_config`, `to_config()`, and `is_config_exportable()` with + signature, inheritance-depth, hook-export, and mutable-container tests. +3. Wire SO101, WidowXAI, and `BimanualWidowXAI` (nested `left`/`right` + composite round-trips); add JSON and construction round-trip tests. +4. Add `SharedRobot.from_config()` / `from_robot()`; hard-cutover + `RobotOwnerConfig` stdin to `robot: ComponentConfig` (rewrite writers, + readers, fixtures; no `config_format`, no dual-read). Keep public ctor and + CLI flat kwargs as adapters that always write the new stdin (XOR mutual + exclusion with `robot=` / `--robot`). Normalize legacy class paths to + public re-exports before store/advertise/compare; advertise metadata + `robot_class` from that public `class_path`. Relative path args rely on + parent cwd inheritance at `Popen` (no path-absolutizing helper). +5. Wire camera implementations, then hard-cutover the camera publisher stdin + to `camera: ComponentConfig` with `service_name` beside it (same PR: + rewrite fixtures; no dual-read). Add `SharedCamera.from_config()` / + `from_camera()`, XOR adapter ctor, and transport-owned built-in + class-path → type-token map for derived names; third-party requires + explicit `service_name`. Do not claim third-party shared camera support + before this lands. +6. Wire the exact PolicySource graph listed above, `TeleopSource`, path-rooted + `InferenceModel` configuration, and the v1 callback set. +7. Wire `RobotRuntime` and verify emitted nested configs load through both + `instantiate()` and jsonargparse (under `runtime:` where the CLI requires + it). +8. Studio drops interim serializers and applies explicit sharing policy to + exportable plugin results. +9. Document component config next to `class_path` / `init_args` and state that + persisted workflow versioning remains preview work. +10. In a separate inference design/change, audit manifest fixtures before + considering `ComponentSpec` delegation, default semantics, depth counting, + or class-path extra-field tightening. + +The public jsonargparse `class_path` + `init_args` shape remains unchanged. +Robot-owner and camera-publisher startup payloads are private wire hard +cutovers; do not describe them as schema-preserving. + +## Required tests + +- Primitive, path, enum, mapping, list, and nested-component normalization. +- Non-finite floats are rejected during normalization. +- `Path` values emit `str(path)` as given (relative stays relative; absolute + stays absolute); plain relative `str` paths are unchanged. +- Relative calibration (and similar path args) survive owner/publisher spawn + when the parent cwd is unchanged between export and `Popen`; children + inherit that cwd. Changing cwd in between with relative paths in the config + is unsupported. +- The shared depth limit applies through configs, mappings, and lists; cyclic + Python containers fail during normalization. +- JSON dump/load followed by reconstruction. +- Re-export produces the same normalized config. +- Supplied defaults remain represented; omitted defaults remain omitted. +- A config emitted with omitted optional nested components parses through + jsonargparse with the same constructor defaults; examples do not emit nulls + for arguments the caller omitted. +- Omitted `PolicySource.action_queue` reconstructs with `LerpSmoother`; bare + `ChunkedActionQueue()` without smoother reconstructs with `ReplaceSmoother`. +- Positional calls bind to names and `**kwargs` flatten into `init_args`. +- Decorated `super()` calls do not overwrite the outer constructor capture. +- An undecorated overriding constructor fails instead of emitting base-only + arguments, and the selected public path resolves to the most-derived type. +- `is_config_exportable` is true for decorator-marked and `__component_config__` + hook-only classes; `to_config` works for both; Studio share keys off that + predicate. +- jsonargparse still sees the original decorated constructor signature. +- Injected instance `to_config()` does not break structural Protocol checks; + docs prefer module `to_config`. +- Malformed and ambiguous nested configs fail before import. +- Local classes and non-class import targets fail. +- Unsupported objects and lambdas report the complete argument path. +- `instantiate()` does not invoke lifecycle methods; eager constructor side + effects such as `InferenceModel` artifact loading and `JsonlCallback` file + open remain visible in tests. +- `InferenceModel` non-scalar / live override args fail at `to_config` (no + silent drop). +- Robot owner and camera publisher subprocess handshakes remain JSON-only, + accept only the new `robot:` / `camera: ComponentConfig` shape, and reject + legacy flat stdin (`robot_class` / `camera_type` forms) before import. +- Built-in SharedCamera spawn derives the legacy `service_name` via the + transport class-path → type-token map inside `from_config` / the adapter + ctor; third-party spawn without explicit `service_name` fails before + publisher start; the publisher envelope carries a concrete `service_name` + not inside `init_args`. +- `SharedCamera.from_camera()` is sugar over `from_config(to_config(...))`, + requires exportability, rejects connected cameras before publisher spawn, + and never disconnects implicitly; public ctor XOR-rejects simultaneous + `camera` and legacy `camera_type` + kwargs. +- Third-party camera class paths survive the publisher envelope and bypass the + built-in camera registry. +- Public `SharedRobot` ctor and `physicalai robot` CLI accept legacy flat + kwargs as adapters **XOR** ComponentConfig; both paths write only the new + stdin; legacy defining-module paths normalize to public re-exports before + store/advertise/compare; metadata `robot_class` equals that public + `robot["class_path"]`. +- `SharedRobot.from_robot()` is sugar over `from_config(to_config(...))`, + requires exportability, and rejects connected drivers before owner spawn. +- Composite bimanual robots spawn as one owner; nested arm configs round-trip + under the composite. +- The exact PolicySource nested graph (including smoothers) round-trips; + unsupported nested values fail at `to_config`. +- The exact v1 callback set round-trips; other callbacks fail at `to_config`; + omitted/empty callbacks stay omitted/empty. +- Referenced local configs and inline self-contained values have distinct tests. +- One complete `RobotRuntime` tree round-trips through both the generic loader + and the jsonargparse CLI parser (nested fragments; root may use `runtime:`). +- Existing inference manifest fixtures and `ComponentSpec.from_class()` retain + their current behavior in v1. +- Untrusted transport metadata is never passed to `instantiate()`. + +## Alternatives considered + +| Option | Why not | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `to_dict()` on runtime protocols | Mixes runtime behavior with construction | +| Studio-only component specs | Creates a parallel config model and does not scale to plugins | +| Transport-specific robot/camera serializers | Duplicates the same replay problem by component type | +| jsonargparse namespace as source of truth | Live objects and subprocesses often have no parser namespace | +| Automatic reflection of object attributes | Cannot distinguish constructor input, derived state, mutation, or resources | +| Arbitrary codec registry in v1 | Adds global extension and security complexity before a second use case exists | +| Unify with inference `ComponentSpec` / `instantiate_component` in v1 | Different defaults, depth counting, extras, and registry mode; changes the inference critical path without helping robot/camera spawn | +| Embed `service_name` inside `ComponentConfig` | Mixes transport naming with construction | +| Hash `class_path` into third-party camera service names | Unstable across arg ordering and omitted defaults; collisions | +| Rename metadata field to `class_path` in v1 | Would bump `ROBOT_TRANSPORT_PROTOCOL_VERSION`; keep `robot_class` populated from `class_path` | +| Changing cwd between relative-path export and owner/publisher spawn | Unsupported in v1; relatives resolve against process cwd at open | +| Public `absolutize_component_paths` | Easy to forget; fights folder-local relative export; redundant with Popen cwd inheritance | +| Universal string heuristic for IPC path absolutization | Corrupts URLs / non-path tokens; wrong tool once relatives are first-class | +| `__component_path_keys__` in v1 | Not needed without an absolutizer; defer until a second concrete need | +| `config_format` / dual-read on owner/publisher stdin | Same-package ephemeral `Popen` handshake; hard cutover is enough — see [wire decision](shared-construction-wire-decision.md) | +| Shape dual-read (`robot` vs `robot_class`) without a version field | Soft landing only; fixture churn is in-repo, so rewrite once | +| Attach-only Studio | Clean separation, but changes operations by requiring serve-first | + +## References + +- Runtime config shape: [config schema](../reference/config-schema.md) +- Runtime YAML guide: [write runtime config](../how-to/config/write-runtime-config.md) +- Security rules: [runtime security](security.md) +- Robot owner envelope: `physicalai.robot.transport._owner_config.RobotOwnerConfig` +- Camera publisher envelope: `physicalai.capture.transport._spec.CameraSpec` +- Review context: Mark's comments on Studio PR #818 (factory wrap; plugins + should not care about `SharedRobot`) From 4d10490cb1e555035cdde7bdc236a73d7d2083ab Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Wed, 22 Jul 2026 13:47:47 +0200 Subject: [PATCH 04/16] add brief --- docs/development/README.md | 3 + docs/development/component-config-brief.md | 67 +++++++++++++++++++++ docs/development/component-config-report.md | 13 ++-- docs/development/component-config.md | 27 +++++---- 4 files changed, 92 insertions(+), 18 deletions(-) create mode 100644 docs/development/component-config-brief.md diff --git a/docs/development/README.md b/docs/development/README.md index f72a0b4..67a3364 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -4,3 +4,6 @@ Repository-wide contributor guidance. - [Coding Standards](coding-standards.md) — coding, writing, and testing standards for all contributors and agents. - [Security Rules](security.md) — security requirements for `src/physicalai/`. +- [Component config (brief)](component-config-brief.md) — 3–5 min reviewer overview for opinions. +- [Component config (report)](component-config-report.md) — longer design map with diagrams. +- [Component config (spec)](component-config.md) — canonical accepted design note. diff --git a/docs/development/component-config-brief.md b/docs/development/component-config-brief.md new file mode 100644 index 0000000..4d44eea --- /dev/null +++ b/docs/development/component-config-brief.md @@ -0,0 +1,67 @@ +# Component config — reviewer brief + +**Read time:** ~3–5 minutes +**Status:** Accepted design — gathering implementation opinions +**Full map:** [component-config-report.md](component-config-report.md) +**Canonical spec:** [component-config.md](component-config.md) +**Context:** Follow-up to [Studio SharedRobot #818](https://github.com/open-edge-platform/physical-ai-studio/pull/818) + +--- + +## Problem + +jsonargparse can build a component tree from YAML, but nothing recovers constructor +args from a **live** plugin/Studio object. SharedRobot/SharedCamera need that recipe +to spawn an owner/publisher child. Today Studio either special-cases serializers or +cannot share third-party drivers cleanly. + +## Proposal + +```text +@export_config → remember supplied __init__ args +to_config(live) → {class_path, init_args} # ComponentConfig +instantiate(cfg)→ fresh disconnected instance +``` + +Same shape as existing jsonargparse configs. Protocols (`Robot`, `Camera`, …) stay +behavior-only. Transport settings (`name`, `service_name`, rates) stay in envelopes. + +```mermaid +flowchart LR + Live["Live @export_config component"] --> TC[to_config] + TC --> CC[ComponentConfig] + CC --> Inst[instantiate] + CC --> Stdin[Owner / publisher stdin] +``` + +## Decisions we want opinions on + +| Decision | Locked choice | Why | +| ------------------------------------------ | --------------------------------------------------------------- | -------------------------------------------------------------- | +| Opt-in decorator vs `to_dict` on protocols | `@export_config` in `physicalai.config` | Keeps runtime protocols clean; plugins stay transport-agnostic | +| Export ≠ share | Studio decides whether to wrap; Runtime only exports the recipe | Decorating for CLI must not force SharedRobot | +| Paths | Relative as given; child inherits parent cwd | Fits “export a folder”; no absolutizer API | +| Inference factory | **Out of v1, keep inference as is** | `ComponentSpec` differs (defaults, extras, registry) | + +## What v1 covers (sketch) + +- Robots (SO101, WidowX, bimanual as one owner), cameras, PolicySource graph, + path-rooted `InferenceModel`, RobotRuntime + listed callbacks +- SharedCamera: built-in `service_name` derived in transport; third-party must + pass an explicit name +- Trust: `instantiate` only on local / parent→child config — never network metadata + +## Out of scope (v1) + +Inference `ComponentSpec` unification · portable bundles · callable +`to_action` · per-arm SharedRobot · persisted workflow document versioning + +## Where to dig deeper + +| Need | Doc | +| ------------------------------------------- | ------------------------------------ | +| Diagrams, rollout, plugin checklist | [Report](component-config-report.md) | +| Inheritance, serialization, tests, security | [Spec](component-config.md) | +| Security rules for `src/physicalai/` | [security.md](security.md) | + +--- diff --git a/docs/development/component-config-report.md b/docs/development/component-config-report.md index 2007765..60c5201 100644 --- a/docs/development/component-config-report.md +++ b/docs/development/component-config-report.md @@ -2,10 +2,11 @@ **Audience:** Runtime developers **Status:** Accepted — implement per rollout -**Canonical spec:** [robot-construction-remembered-init.md](robot-construction-remembered-init.md) +**Short brief (reviewers):** [component-config-brief.md](component-config-brief.md) +**Canonical spec:** [component-config.md](component-config.md) **Context:** Follow-up to [Studio SharedRobot #818](https://github.com/open-edge-platform/physical-ai-studio/pull/818) -This report is the human-readable map of the design. When behavior is ambiguous, the canonical design note wins. +This report is the human-readable map of the design. Prefer the [brief](component-config-brief.md) for a quick overview. When behavior is ambiguous, the canonical design note wins. ![Architecture: behavior, construction, and transport layers](captured-component-config-architecture.png) @@ -78,7 +79,7 @@ flowchart TB - **Construction** = how to build a fresh instance - **Transport** = name, rate, `service_name`, timeouts, sharing -- **Exportable ≠ shared** — Studio owns `robot.share`; `@export_config` only enables config export +- **Exportable ≠ shared** — Studio decides whether to wrap in `SharedRobot`; `@export_config` only enables config export --- @@ -178,7 +179,7 @@ flowchart TB ## Transport migration (private wire) -Do **not** bump `ROBOT_TRANSPORT_PROTOCOL_VERSION` or camera frame `PROTOCOL_VERSION` for this. Those are network/frame protocols. Startup stdin is a same-package `Popen` handshake — **hard cutover** to `ComponentConfig` with no `config_format` and no dual-read (see [wire decision](shared-construction-wire-decision.md)). +Do **not** bump `ROBOT_TRANSPORT_PROTOCOL_VERSION` or camera frame `PROTOCOL_VERSION` for this. Those are network/frame protocols. Startup stdin is a same-package `Popen` handshake — **hard cutover** to `ComponentConfig` with no `config_format` and no dual-read (recorded in the [spec](component-config.md#private-startup-envelopes-hard-cutover)). ```text # Robot owner — after @@ -256,7 +257,7 @@ flowchart LR ```mermaid flowchart TD Builder[Plugin builder] --> Driver[Ordinary Robot/Camera] - Driver --> Share{robot.share?} + Driver --> Share{Studio share policy?} Share -->|no| Direct[In-process driver] Share -->|yes| Exp{is_config_exportable?} Exp -->|no| Fail[Fail loud] @@ -358,4 +359,4 @@ Implement **one step at a time**. Keep the design note open as the contract. | Owner/publisher stdin | Hard cutover to `robot:` / `camera: ComponentConfig` | | `service_name` | Camera transport identity (not construction) | -**Full rules and required tests:** [robot-construction-remembered-init.md](robot-construction-remembered-init.md) +**Full rules and required tests:** [component-config.md](component-config.md) diff --git a/docs/development/component-config.md b/docs/development/component-config.md index f308c47..f01b931 100644 --- a/docs/development/component-config.md +++ b/docs/development/component-config.md @@ -129,8 +129,8 @@ Studio code that needs to test exportability uses `is_config_exportable(value)`. A value is exportable if and only if it carries the private `@export_config` decorator marker **or** provides a callable `__component_config__` hook. `to_config(value)` uses the same predicate. This keeps the plugin-facing -contract to one decorator plus one private escape hatch, and gives -`share_if_requested` a single key for both paths. +contract to one decorator plus one private escape hatch, and gives Studio's +share path a single exportability check for both decorator and hook paths. ### Naming @@ -707,21 +707,24 @@ The same separation applies to camera service names, validation settings, zero-copy mode, publisher rates, and idle timeouts. These settings describe how a component is shared, not how the underlying component was constructed. -Config export capability does not imply process sharing. Studio owns an -explicit product/deployment policy such as `robot.share`; -`is_config_exportable` only determines whether the selected sharing path can -obtain a component config. Studio consumption becomes: +Config export capability does not imply process sharing. Studio owns the +product decision of whether a built driver should run in-process or be wrapped +in `SharedRobot`. `is_config_exportable` only answers whether that sharing path +can obtain a component config. Sketch: ```python driver = await builder(robot, self) -driver = share_if_requested(driver, name=robot.name, enabled=robot.share) +if should_share(robot): # Studio policy — not a Runtime field + if not is_config_exportable(driver): + raise ... + if driver.is_connected(): + raise ... + driver = SharedRobot.from_config(to_config(driver), name=robot.name) ``` -`share_if_requested()` leaves the direct driver unchanged when disabled. When -enabled, it requires `is_config_exportable(driver)`, rejects a connected -driver, then calls `SharedRobot.from_config(to_config(driver), name=name)`. -The helper implements Studio policy; it is not part of the generic -component-config API. +`should_share` is Studio's concern (UI toggle, deployment setting, etc.). It is +not part of the generic component-config API and is not a Runtime attribute on +robots. Catalog and plugin builders return ordinary runtime objects. Plugins never import Zenoh, iceoryx2, `SharedRobot`, or `SharedCamera`. From 37e6555c5484167a0596560baba3c05a57b27bb0 Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Wed, 22 Jul 2026 16:29:47 +0200 Subject: [PATCH 05/16] plan step 1 & 2 --- docs/development/component-config-brief.md | 2 +- docs/development/security.md | 17 +- pyproject.toml | 2 + src/physicalai/config/__init__.py | 77 ++++ src/physicalai/config/_errors.py | 18 + src/physicalai/config/_export.py | 281 ++++++++++++ src/physicalai/config/_importing.py | 10 + src/physicalai/config/_instantiate.py | 143 ++++++ src/physicalai/config/_normalize.py | 355 +++++++++++++++ src/physicalai/config/_path.py | 11 + src/physicalai/config/_types.py | 32 ++ src/physicalai/inference/_importing.py | 39 +- src/physicalai/robot/transport/_importing.py | 39 +- tests/unit/config/__init__.py | 3 + tests/unit/config/test_component_config.py | 446 +++++++++++++++++++ 15 files changed, 1409 insertions(+), 66 deletions(-) create mode 100644 src/physicalai/config/__init__.py create mode 100644 src/physicalai/config/_errors.py create mode 100644 src/physicalai/config/_export.py create mode 100644 src/physicalai/config/_importing.py create mode 100644 src/physicalai/config/_instantiate.py create mode 100644 src/physicalai/config/_normalize.py create mode 100644 src/physicalai/config/_path.py create mode 100644 src/physicalai/config/_types.py create mode 100644 tests/unit/config/__init__.py create mode 100644 tests/unit/config/test_component_config.py diff --git a/docs/development/component-config-brief.md b/docs/development/component-config-brief.md index 4d44eea..6050af3 100644 --- a/docs/development/component-config-brief.md +++ b/docs/development/component-config-brief.md @@ -1,7 +1,7 @@ # Component config — reviewer brief **Read time:** ~3–5 minutes -**Status:** Accepted design — gathering implementation opinions +**Status:** Accepted — implement per rollout **Full map:** [component-config-report.md](component-config-report.md) **Canonical spec:** [component-config.md](component-config.md) **Context:** Follow-up to [Studio SharedRobot #818](https://github.com/open-edge-platform/physical-ai-studio/pull/818) diff --git a/docs/development/security.md b/docs/development/security.md index 89f980b..b4bf1c4 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -14,9 +14,20 @@ These rules apply when writing, editing, or reviewing code under `src/physicalai raise ValueError(f"Path escapes base directory: {user_path!r}") ``` -4. No arbitrary `class_path` import from untrusted manifests or YAML. `instantiate_component` in `inference/component_factory.py` resolves `class_path` via `ComponentRegistry` and `importlib`. Only register trusted short names; treat manifest `class_path` values as untrusted unless the export directory is trusted. Prefer registered `type` names for built-ins. - -5. Enforce component nesting limits. `_MAX_COMPONENT_DEPTH` in `component_factory.py` caps recursive manifest/YAML instantiation — do not raise or bypass without a security review. +4. No arbitrary `class_path` import from untrusted manifests, YAML, or peer + payloads. `instantiate_component` in `inference/component_factory.py` + resolves `class_path` via `ComponentRegistry` and `importlib`. Only register + trusted short names; treat manifest `class_path` values as untrusted unless + the export directory is trusted. Prefer registered `type` names for + built-ins. `physicalai.config.instantiate` is a separate trusted-local / + parent→child-only construction boundary: never pass robot/camera network + metadata, Zenoh payloads, or other untrusted peer data into it. + +5. Enforce component nesting limits. `_MAX_COMPONENT_DEPTH` in + `component_factory.py` caps recursive manifest/YAML instantiation; + `_MAX_CONFIG_DEPTH` in `physicalai.config` caps recursive + `to_config` / `instantiate` trees — do not raise or bypass either without a + security review. 6. Never use `pickle`, `eval()`, `exec()`, `joblib`, `dill`, or `cloudpickle` on untrusted data. Prefer `json` for structured metadata, `safetensors` for weights, and `numpy.load(..., allow_pickle=False)` for arrays. diff --git a/pyproject.toml b/pyproject.toml index d2f5dde..62af33b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,6 +153,8 @@ unfixable = [] "src/physicalai/capture/transport/_header.py" = ["RUF012"] "src/physicalai/capture/transport/_publisher.py" = ["S603"] "src/physicalai/robot/transport/_owner.py" = ["S603"] +# Lazy __getattr__ public API so physicalai.config.importing stays lightweight. +"src/physicalai/config/__init__.py" = ["RUF067"] [tool.ruff.lint.mccabe] max-complexity = 15 diff --git a/src/physicalai/config/__init__.py b/src/physicalai/config/__init__.py new file mode 100644 index 0000000..bb371a9 --- /dev/null +++ b/src/physicalai/config/__init__.py @@ -0,0 +1,77 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Captured component configuration: export live components and instantiate configs. + +Trusted local application and parent→child startup configs only. Never pass +network metadata or untrusted peer payloads to :func:`instantiate`. + +Public names are resolved lazily so ``physicalai.config.importing`` can be +imported without loading export/normalize/instantiate. +""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ._errors import ComponentConfigError as ComponentConfigError + from ._errors import ComponentImportError as ComponentImportError + from ._export import export_config as export_config + from ._export import is_config_exportable as is_config_exportable + from ._export import to_config as to_config + from ._instantiate import instantiate as instantiate + from ._types import ComponentConfig as ComponentConfig + from ._types import JsonScalar as JsonScalar + from ._types import JsonValue as JsonValue + from .importing import import_dotted_path as import_dotted_path + +__all__ = [ + "ComponentConfig", + "ComponentConfigError", + "ComponentImportError", + "JsonScalar", + "JsonValue", + "export_config", + "import_dotted_path", + "instantiate", + "is_config_exportable", + "to_config", +] + +_LAZY_ATTRS: dict[str, tuple[str, str]] = { + "ComponentConfig": ("._types", "ComponentConfig"), + "JsonScalar": ("._types", "JsonScalar"), + "JsonValue": ("._types", "JsonValue"), + "ComponentConfigError": ("._errors", "ComponentConfigError"), + "ComponentImportError": ("._errors", "ComponentImportError"), + "import_dotted_path": (".importing", "import_dotted_path"), + "export_config": ("._export", "export_config"), + "is_config_exportable": ("._export", "is_config_exportable"), + "to_config": ("._export", "to_config"), + "instantiate": ("._instantiate", "instantiate"), +} + + +def __getattr__(name: str) -> object: + """Lazily resolve public API attributes. + + Returns: + The requested public attribute. + + Raises: + AttributeError: If *name* is not part of the public API. + """ + try: + module_name, attr = _LAZY_ATTRS[name] + except KeyError as exc: + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) from exc + value = getattr(import_module(module_name, __name__), attr) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted({*globals(), *__all__}) diff --git a/src/physicalai/config/_errors.py b/src/physicalai/config/_errors.py new file mode 100644 index 0000000..5b8515f --- /dev/null +++ b/src/physicalai/config/_errors.py @@ -0,0 +1,18 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Errors for component configuration export and instantiation.""" + +from __future__ import annotations + + +class ComponentConfigError(Exception): + """Raised when a component config cannot be exported, validated, or built. + + Callers typically recover by correcting configuration. Subclasses mark + distinct failure phases when callers need to distinguish them. + """ + + +class ComponentImportError(ComponentConfigError): + """Raised when a ``class_path`` cannot be resolved to an importable class.""" diff --git a/src/physicalai/config/_export.py b/src/physicalai/config/_export.py new file mode 100644 index 0000000..569e71e --- /dev/null +++ b/src/physicalai/config/_export.py @@ -0,0 +1,281 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Opt-in constructor-config export for live components.""" + +from __future__ import annotations + +import functools +import inspect +from typing import TypeVar + +from ._errors import ComponentConfigError +from ._normalize import ( + normalize_value, + snapshot_captured_value, + validate_component_config, +) +from ._path import format_path +from ._types import ( + _CAPTURED_INIT_ARGS_ATTR, + _CONFIG_CLASS_PATH_ATTR, + _CONFIG_HOOK_NAME, + _EXPORT_DEPTH_ATTR, + _EXPORT_MARKER_ATTR, + _MAX_CONFIG_DEPTH, + ComponentConfig, + JsonValue, +) +from .importing import import_dotted_path + +_T = TypeVar("_T", bound=type) + + +def _has_export_marker(obj: object) -> bool: + return bool(getattr(obj, _EXPORT_MARKER_ATTR, False)) + + +def _has_config_hook(value: object) -> bool: + hook = getattr(value, _CONFIG_HOOK_NAME, None) + return callable(hook) + + +def is_config_exportable(value: object) -> bool: + """Return whether *value* can export a :class:`ComponentConfig`. + + A value is exportable if and only if its most-derived class's effective + ``__init__`` carries the ``@export_config`` marker, or the instance + provides a callable ``__component_config__`` hook. + """ + if _has_config_hook(value): + return True + init = type(value).__init__ + return _has_export_marker(init) + + +def _resolve_public_class_path(cls: type) -> str: + explicit = getattr(cls, _CONFIG_CLASS_PATH_ATTR, None) + path = explicit if isinstance(explicit, str) and explicit else f"{cls.__module__}.{cls.__qualname__}" + + if "" in cls.__qualname__: + msg = f"local class {path!r} cannot export a stable class_path" + raise ComponentConfigError(msg) + + try: + resolved = import_dotted_path(path) + except (ValueError, ImportError, AttributeError) as exc: + msg = f"class_path {path!r} for {cls.__qualname__} is not importable: {exc}" + raise ComponentConfigError(msg) from exc + if resolved is not cls: + msg = f"class_path {path!r} resolves to {resolved!r}, expected exactly {cls!r}" + raise ComponentConfigError(msg) + return path + + +def _component_path_prefix(value: object) -> str: + cls = type(value) + explicit = getattr(cls, _CONFIG_CLASS_PATH_ATTR, None) + if isinstance(explicit, str) and explicit: + return explicit + return f"{cls.__module__}.{cls.__qualname__}" + + +def _arg_path(prefix: str, class_path: str, key: str) -> str: + if prefix: + return f"{prefix}.init_args.{key}" + return f"{class_path}.init_args.{key}" + + +def to_config( + value: object, + *, + _path: str = "", + _depth: int = 0, + _seen: set[int] | None = None, +) -> ComponentConfig: + """Export an opted-in live component as JSON-safe ``class_path`` + ``init_args``. + + Args: + value: An instance whose class uses ``@export_config``, or which + implements ``__component_config__``. + + Returns: + A :class:`ComponentConfig` describing the object as constructed. + + Raises: + ComponentConfigError: If *value* is not exportable or captured values + cannot be normalized. + """ + if _depth > _MAX_CONFIG_DEPTH: + msg = f"{format_path(_path)}: nesting depth exceeds {_MAX_CONFIG_DEPTH}" + raise ComponentConfigError(msg) + + seen = _seen if _seen is not None else set() + + if _has_config_hook(value): + hook = getattr(value, _CONFIG_HOOK_NAME) + raw = hook() + path_prefix = _path or _component_path_prefix(value) + validated = validate_component_config(raw, path=path_prefix) + normalized_args: dict[str, JsonValue] = { + key: normalize_value( + item, + path=_arg_path(_path, validated["class_path"], key), + depth=_depth + 1, + seen=seen, + to_config=to_config, + is_exportable=is_config_exportable, + ) + for key, item in validated["init_args"].items() + } + return {"class_path": validated["class_path"], "init_args": normalized_args} + + if not _has_export_marker(type(value).__init__): + msg = ( + f"{_path or _component_path_prefix(value)}: not config-exportable; " + "decorate the concrete class with @export_config or implement " + "__component_config__" + ) + raise ComponentConfigError(msg) + + captured = getattr(value, _CAPTURED_INIT_ARGS_ATTR, None) + if captured is None: + msg = ( + f"{_path or _component_path_prefix(value)}: no captured constructor " + "arguments; the object was not constructed through the decorated __init__" + ) + raise ComponentConfigError(msg) + + class_path = _resolve_public_class_path(type(value)) + init_args: dict[str, JsonValue] = {} + for key, item in captured.items(): + init_args[key] = normalize_value( + item, + path=_arg_path(_path, class_path, key), + depth=_depth + 1, + seen=seen, + to_config=to_config, + is_exportable=is_config_exportable, + ) + return {"class_path": class_path, "init_args": init_args} + + +def _instance_to_config(self: object) -> ComponentConfig: + """Instance-method sugar for :func:`to_config`; prefer the module function. + + Returns: + The component config for *self*. + """ + return to_config(self) + + +def _validate_replayable_signature(cls: type, signature: inspect.Signature) -> None: + for param in signature.parameters.values(): + if param.name == "self": + continue + if param.kind is inspect.Parameter.POSITIONAL_ONLY: + msg = ( + f"@export_config on {cls.__qualname__}: positional-only parameter " + f"{param.name!r} is not replayable through keyword init_args" + ) + raise TypeError(msg) + if param.kind is inspect.Parameter.VAR_POSITIONAL: + msg = f"@export_config on {cls.__qualname__}: *args is not replayable through keyword init_args" + raise TypeError(msg) + + +def export_config(cls: _T) -> _T: + """Opt a concrete class into constructor-config export via :func:`to_config`. + + Remembers caller-supplied ``__init__`` arguments (not defaults). Rejects + constructors that declare positional-only parameters or ``*args``. Injects + an instance ``to_config()`` convenience method; library code should still + call the module-level :func:`to_config`. + + Inheritance: + + - Decorate every concrete class that **overrides** ``__init__``. + - An undecorated overriding ``__init__`` fails at :func:`to_config` (partial + base recipes are not emitted). + - A subclass that inherits a decorated constructor unchanged remains valid + without re-decorating. + - Do not apply ``@export_config`` to a subclass that does not define its + own ``__init__`` (that would double-wrap the inherited wrapper). + + Args: + cls: Class to decorate. Must define ``__init__`` in its own class body. + + Returns: + The same class with a wrapped ``__init__``. + + Raises: + TypeError: If *cls* is not a type, has no own ``__init__``, or its + ``__init__`` is not replayable. + """ + if not isinstance(cls, type): + msg = f"@export_config expects a class, got {type(cls).__name__}" + raise TypeError(msg) + + if "__init__" not in cls.__dict__: + msg = ( + f"@export_config on {cls.__qualname__}: decorate only classes that " + "define their own __init__; inherited decorated constructors remain " + "exportable without re-decorating" + ) + raise TypeError(msg) + + original_init = cls.__dict__["__init__"] + if original_init is object.__init__: + msg = f"@export_config on {cls.__qualname__}: class has no custom __init__" + raise TypeError(msg) + + # Re-decorating the same class body is a no-op. + if _has_export_marker(original_init): + return cls + + signature = inspect.signature(original_init) + _validate_replayable_signature(cls, signature) + + @functools.wraps(original_init) + def wrapped_init(self: object, *args: object, **kwargs: object) -> None: + bound = signature.bind(self, *args, **kwargs) + # Do not apply_defaults — omit unsupplied arguments so reconstruction + # uses current constructor defaults. + supplied = { + name: snapshot_captured_value(value, keep_by_reference=is_config_exportable) + for name, value in bound.arguments.items() + if name != "self" + } + # Flatten **kwargs mapping into init_args. + var_kw_name = next( + (name for name, param in signature.parameters.items() if param.kind is inspect.Parameter.VAR_KEYWORD), + None, + ) + if var_kw_name is not None and var_kw_name in supplied: + extra = supplied.pop(var_kw_name) + if not isinstance(extra, dict): + msg = f"{cls.__qualname__}: **{var_kw_name} must be a mapping" + raise TypeError(msg) + for key, value in extra.items(): + if not isinstance(key, str): + msg = f"{cls.__qualname__}: **{var_kw_name} keys must be strings" + raise TypeError(msg) + supplied[key] = snapshot_captured_value(value, keep_by_reference=is_config_exportable) + + depth = getattr(self, _EXPORT_DEPTH_ATTR, 0) + setattr(self, _EXPORT_DEPTH_ATTR, depth + 1) + try: + original_init(self, *args, **kwargs) + # Only the outermost successful decorated constructor commits. + if getattr(self, _EXPORT_DEPTH_ATTR, 0) == 1: + setattr(self, _CAPTURED_INIT_ARGS_ATTR, supplied) + finally: + setattr(self, _EXPORT_DEPTH_ATTR, depth) + if depth == 0 and hasattr(self, _EXPORT_DEPTH_ATTR): + delattr(self, _EXPORT_DEPTH_ATTR) + + setattr(wrapped_init, _EXPORT_MARKER_ATTR, True) + cls.__init__ = wrapped_init # type: ignore[method-assign] + # Convenience method; type checkers do not see the injection — prefer module to_config. + cls.to_config = _instance_to_config # type: ignore[attr-defined] + return cls diff --git a/src/physicalai/config/_importing.py b/src/physicalai/config/_importing.py new file mode 100644 index 0000000..4123efb --- /dev/null +++ b/src/physicalai/config/_importing.py @@ -0,0 +1,10 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Backward-compatible alias for :mod:`physicalai.config.importing`.""" + +from __future__ import annotations + +from physicalai.config.importing import import_dotted_path + +__all__ = ["import_dotted_path"] diff --git a/src/physicalai/config/_instantiate.py b/src/physicalai/config/_instantiate.py new file mode 100644 index 0000000..8d8e683 --- /dev/null +++ b/src/physicalai/config/_instantiate.py @@ -0,0 +1,143 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Instantiate trusted ComponentConfig trees.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import cast + +from ._errors import ComponentConfigError, ComponentImportError +from ._normalize import validate_component_config +from ._path import format_path +from ._types import _MAX_CONFIG_DEPTH, ComponentConfig, JsonValue +from .importing import import_dotted_path + + +def _is_nested_config(value: object) -> bool: + return isinstance(value, Mapping) and "class_path" in value + + +def _decode_value( + value: object, + *, + path: str, + depth: int, + seen: set[int], +) -> object: + if depth > _MAX_CONFIG_DEPTH: + msg = f"{format_path(path)}: nesting depth exceeds {_MAX_CONFIG_DEPTH}" + raise ComponentConfigError(msg) + + if _is_nested_config(value): + return _instantiate_impl( + cast("ComponentConfig | Mapping[str, JsonValue]", value), + path=path, + depth=depth, + seen=seen, + ) + + if isinstance(value, Mapping): + obj_id = id(value) + if obj_id in seen: + msg = f"{format_path(path)}: cyclic mapping is not instantiable" + raise ComponentConfigError(msg) + seen.add(obj_id) + try: + result: dict[str, object] = {} + for key, item in value.items(): + if not isinstance(key, str): + msg = f"{format_path(path)}: mapping keys must be strings, got {type(key).__name__}" + raise ComponentConfigError(msg) + child_path = f"{path}.{key}" if path else key + result[key] = _decode_value(item, path=child_path, depth=depth + 1, seen=seen) + return result + finally: + seen.discard(obj_id) + + if isinstance(value, list): + obj_id = id(value) + if obj_id in seen: + msg = f"{format_path(path)}: cyclic sequence is not instantiable" + raise ComponentConfigError(msg) + seen.add(obj_id) + try: + items: list[object] = [] + for index, item in enumerate(value): + child_path = f"{path}[{index}]" if path else f"[{index}]" + items.append(_decode_value(item, path=child_path, depth=depth + 1, seen=seen)) + return items + finally: + seen.discard(obj_id) + + return value + + +def _resolve_class(class_path: str, *, path: str) -> type: + if "" in class_path: + msg = f"{format_path(path)}: local class {class_path!r} cannot be imported" + raise ComponentConfigError(msg) + try: + obj = import_dotted_path(class_path) + except (ValueError, ImportError, AttributeError) as exc: + msg = f"{format_path(path)}: cannot import class_path {class_path!r}: {exc}" + raise ComponentImportError(msg) from exc + if not isinstance(obj, type): + msg = f"{format_path(path)}: {class_path!r} does not resolve to a class (got {type(obj).__name__})" + raise ComponentImportError(msg) + if "" in obj.__qualname__: + msg = f"{format_path(path)}: local class {class_path!r} cannot be instantiated" + raise ComponentConfigError(msg) + return obj + + +def _instantiate_impl( + config: ComponentConfig | Mapping[str, JsonValue], + *, + path: str, + depth: int, + seen: set[int], +) -> object: + if depth > _MAX_CONFIG_DEPTH: + msg = f"{format_path(path)}: nesting depth exceeds {_MAX_CONFIG_DEPTH}" + raise ComponentConfigError(msg) + + validated = validate_component_config(config, path=path) + cls = _resolve_class(validated["class_path"], path=path) + + decoded_args: dict[str, object] = {} + for key, item in validated["init_args"].items(): + child_path = f"{path}.init_args.{key}" if path else f"{validated['class_path']}.init_args.{key}" + decoded_args[key] = _decode_value(item, path=child_path, depth=depth + 1, seen=seen) + + try: + return cls(**decoded_args) + except Exception as exc: + loc = path or validated["class_path"] + exc.add_note(f"{format_path(loc)}: constructor failed while instantiating component config") + raise + + +def instantiate(config: ComponentConfig | Mapping[str, JsonValue]) -> object: + """Build a fresh component from a trusted :class:`ComponentConfig`. + + Validates *config* before importing. Recursively instantiates nested + component configs in ``init_args``, then calls the class with keyword + arguments. Does not invoke lifecycle methods beyond the constructor. + + Trusted local application and parent→child startup configs only. Never + pass network metadata or untrusted peer payloads. + + Malformed configs raise :class:`ComponentConfigError` (including + :class:`ComponentImportError` for unresolved ``class_path``). Constructor + failures propagate as their original exception type with path context + attached via :meth:`BaseException.add_note`. + + Args: + config: Trusted ``class_path`` + ``init_args`` mapping. + + Returns: + A new instance of the configured class. + """ + return _instantiate_impl(config, path="", depth=0, seen=set()) diff --git a/src/physicalai/config/_normalize.py b/src/physicalai/config/_normalize.py new file mode 100644 index 0000000..3dfa088 --- /dev/null +++ b/src/physicalai/config/_normalize.py @@ -0,0 +1,355 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Normalize live values into JSON-safe component configuration fragments.""" + +from __future__ import annotations + +import math +from collections.abc import Callable, Mapping +from enum import Enum +from pathlib import Path +from typing import cast + +from ._errors import ComponentConfigError +from ._path import format_path +from ._types import ( + _MAX_CONFIG_DEPTH, + _REPR_LIMIT, + ComponentConfig, + JsonValue, +) + +ToConfigFn = Callable[..., ComponentConfig] +IsExportableFn = Callable[[object], bool] +DomainCodecFn = Callable[[object], JsonValue | None] + + +def _is_component_config_mapping(value: Mapping[object, object]) -> bool: + return "class_path" in value + + +def validate_component_config(config: object, *, path: str = "") -> ComponentConfig: + """Validate a mapping as a :class:`ComponentConfig` without importing. + + Args: + config: Candidate config mapping. + path: Argument path prefix for error messages. + + Returns: + The validated config (``init_args`` defaulted to ``{}`` when omitted). + + Raises: + ComponentConfigError: If the mapping is malformed. + """ + loc = format_path(path) + if not isinstance(config, dict): + msg = f"{loc}: component config must be a mapping, got {type(config).__name__}" + raise ComponentConfigError(msg) + + keys = set(config) + allowed = {"class_path", "init_args"} + extra = keys - allowed + if extra: + extras = ", ".join(sorted(repr(k) for k in extra)) + msg = f"{loc}: nested component config may only contain 'class_path' and 'init_args'; unexpected keys: {extras}" + raise ComponentConfigError(msg) + if "class_path" not in config: + msg = f"{loc}: component config missing required 'class_path'" + raise ComponentConfigError(msg) + + class_path = config["class_path"] + if not isinstance(class_path, str) or not class_path: + msg = f"{loc}: 'class_path' must be a non-empty string" + raise ComponentConfigError(msg) + + if "init_args" not in config: + init_args: object = {} + else: + init_args = config["init_args"] + if init_args is None: + msg = f"{loc}: 'init_args' must be a mapping, got None" + raise ComponentConfigError(msg) + if not isinstance(init_args, dict): + msg = f"{loc}: 'init_args' must be a mapping, got {type(init_args).__name__}" + raise ComponentConfigError(msg) + for key in init_args: + if not isinstance(key, str): + msg = f"{loc}.init_args: keys must be strings, got {type(key).__name__}" + raise ComponentConfigError(msg) + + return {"class_path": class_path, "init_args": dict(init_args)} + + +def _check_depth(path: str, depth: int) -> None: + if depth > _MAX_CONFIG_DEPTH: + msg = f"{format_path(path)}: nesting depth exceeds {_MAX_CONFIG_DEPTH}" + raise ComponentConfigError(msg) + + +def _try_normalize_scalar(value: object, *, path: str) -> tuple[bool, JsonValue]: + """Return ``(True, json_value)`` for scalars/Path, else ``(False, None)``. + + Raises: + ComponentConfigError: If *value* is a non-finite float. + """ + if value is None or isinstance(value, (bool, str)): + return True, value + if isinstance(value, int) and not isinstance(value, bool): + return True, value + if isinstance(value, float): + if not math.isfinite(value): + msg = f"{format_path(path)}: non-finite float {value!r} is not JSON-portable" + raise ComponentConfigError(msg) + return True, value + if isinstance(value, Path): + return True, str(value) + return False, None + + +def _normalize_enum(value: Enum, *, path: str) -> JsonValue: + enum_value = value.value + if isinstance(enum_value, (bool, str)) or ( + isinstance(enum_value, (int, float)) and not isinstance(enum_value, bool) + ): + if isinstance(enum_value, float) and not math.isfinite(enum_value): + msg = f"{format_path(path)}: non-finite enum value {enum_value!r}" + raise ComponentConfigError(msg) + return enum_value # type: ignore[return-value] + msg = f"{format_path(path)}: enum {type(value).__name__} value {type(enum_value).__name__} is not JSON-safe" + raise ComponentConfigError(msg) + + +def _normalize_exportable( + value: object, + *, + path: str, + depth: int, + seen: set[int], + to_config: ToConfigFn, +) -> JsonValue: + # Match instantiate: a nested component config found at *depth* is itself at + # *depth* (its init_args then advance to depth + 1 inside to_config). + nested = to_config(value, _path=path, _depth=depth, _seen=seen) + return cast("JsonValue", dict(nested)) + + +def _normalize_mapping( + value: Mapping[object, object], + *, + path: str, + depth: int, + seen: set[int], + to_config: ToConfigFn | None, + is_exportable: IsExportableFn | None, + domain_codec: DomainCodecFn | None, +) -> JsonValue: + obj_id = id(value) + if obj_id in seen: + msg = f"{format_path(path)}: cyclic mapping is not serializable" + raise ComponentConfigError(msg) + + if _is_component_config_mapping(value): + validated = validate_component_config(value, path=path) + nested_args: dict[str, JsonValue] = {} + seen.add(obj_id) + try: + for key, item in validated["init_args"].items(): + child_path = f"{path}.init_args.{key}" if path else f"init_args.{key}" + nested_args[key] = normalize_value( + item, + path=child_path, + depth=depth + 1, + seen=seen, + to_config=to_config, + is_exportable=is_exportable, + domain_codec=domain_codec, + ) + finally: + seen.discard(obj_id) + return {"class_path": validated["class_path"], "init_args": nested_args} + + seen.add(obj_id) + try: + result: dict[str, JsonValue] = {} + for key, item in value.items(): + if not isinstance(key, str): + msg = f"{format_path(path)}: mapping keys must be strings, got {type(key).__name__}" + raise ComponentConfigError(msg) + child_path = f"{path}.{key}" if path else key + result[key] = normalize_value( + item, + path=child_path, + depth=depth + 1, + seen=seen, + to_config=to_config, + is_exportable=is_exportable, + domain_codec=domain_codec, + ) + return result + finally: + seen.discard(obj_id) + + +def _normalize_sequence( + value: list[object] | tuple[object, ...], + *, + path: str, + depth: int, + seen: set[int], + to_config: ToConfigFn | None, + is_exportable: IsExportableFn | None, + domain_codec: DomainCodecFn | None, +) -> JsonValue: + obj_id = id(value) + if obj_id in seen: + msg = f"{format_path(path)}: cyclic sequence is not serializable" + raise ComponentConfigError(msg) + seen.add(obj_id) + try: + items: list[JsonValue] = [] + for index, item in enumerate(value): + child_path = f"{path}[{index}]" if path else f"[{index}]" + items.append( + normalize_value( + item, + path=child_path, + depth=depth + 1, + seen=seen, + to_config=to_config, + is_exportable=is_exportable, + domain_codec=domain_codec, + ) + ) + return items + finally: + seen.discard(obj_id) + + +def _unsupported_message(value: object, *, path: str) -> str: + type_name = type(value).__name__ + repr_value = repr(value) + if len(repr_value) > _REPR_LIMIT: + repr_value = repr_value[: _REPR_LIMIT - 3] + "..." + return f"{format_path(path)}: cannot encode {type_name} {repr_value}; omit it or use a supported component value" + + +def normalize_value( + value: object, + *, + path: str = "", + depth: int = 0, + seen: set[int] | None = None, + to_config: ToConfigFn | None = None, + is_exportable: IsExportableFn | None = None, + domain_codec: DomainCodecFn | None = None, +) -> JsonValue: + """Recursively normalize *value* to a JSON-safe representation. + + Args: + value: Captured constructor argument or nested structure. + path: Argument path for error messages. + depth: Current nesting depth (configs, lists, and mappings). + seen: Container identities already visited (cycle detection). + to_config: Callback for nested exportable components. + is_exportable: Predicate for nested exportable components. + domain_codec: Optional codec returning a JSON value or ``None``. + + Returns: + A JSON-safe value. + + Raises: + ComponentConfigError: On unsupported values, cycles, or depth overflow. + """ + _check_depth(path, depth) + if seen is None: + seen = set() + + handled, scalar = _try_normalize_scalar(value, path=path) + if handled: + return scalar + + if isinstance(value, Enum): + return _normalize_enum(value, path=path) + + if is_exportable is not None and to_config is not None and is_exportable(value): + return _normalize_exportable(value, path=path, depth=depth, seen=seen, to_config=to_config) + + if domain_codec is not None: + encoded = domain_codec(value) + if encoded is not None: + return encoded + + if isinstance(value, Mapping): + return _normalize_mapping( + value, + path=path, + depth=depth, + seen=seen, + to_config=to_config, + is_exportable=is_exportable, + domain_codec=domain_codec, + ) + + if isinstance(value, (list, tuple)): + return _normalize_sequence( + value, + path=path, + depth=depth, + seen=seen, + to_config=to_config, + is_exportable=is_exportable, + domain_codec=domain_codec, + ) + + msg = _unsupported_message(value, path=path) + raise ComponentConfigError(msg) + + +def snapshot_captured_value( + value: object, + *, + keep_by_reference: Callable[[object], bool] | None = None, + memo: dict[int, object] | None = None, +) -> object: + """Deep-snapshot built-in mutable containers; keep selected values by reference. + + Nested exportable components (and other values matching + *keep_by_reference*) are retained by identity so their own conversion + remains authoritative. Cyclic containers are preserved via *memo* so + :func:`~physicalai.config.to_config` can reject them during normalization. + + Returns: + A snapshot of *value* suitable for later normalization. + """ + if memo is None: + memo = {} + + if isinstance(value, (dict, list, tuple)): + obj_id = id(value) + if obj_id in memo: + return memo[obj_id] + if isinstance(value, dict): + result: dict[object, object] = {} + memo[obj_id] = result + for key, item in value.items(): + result[key] = snapshot_captured_value(item, keep_by_reference=keep_by_reference, memo=memo) + snap: object = result + elif isinstance(value, list): + result_list: list[object] = [] + memo[obj_id] = result_list + result_list.extend( + snapshot_captured_value(item, keep_by_reference=keep_by_reference, memo=memo) for item in value + ) + snap = result_list + else: + memo[obj_id] = value + snap = tuple( + snapshot_captured_value(item, keep_by_reference=keep_by_reference, memo=memo) for item in value + ) + memo[obj_id] = snap + return snap + + if keep_by_reference is not None and keep_by_reference(value): + return value + return value diff --git a/src/physicalai/config/_path.py b/src/physicalai/config/_path.py new file mode 100644 index 0000000..921336d --- /dev/null +++ b/src/physicalai/config/_path.py @@ -0,0 +1,11 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Shared argument-path formatting for component-config errors.""" + +from __future__ import annotations + + +def format_path(path: str) -> str: + """Return *path* for error messages, or ```` when empty.""" + return path or "" diff --git a/src/physicalai/config/_types.py b/src/physicalai/config/_types.py new file mode 100644 index 0000000..d179a6f --- /dev/null +++ b/src/physicalai/config/_types.py @@ -0,0 +1,32 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Types for JSON-safe component configuration.""" + +from __future__ import annotations + +from typing import TypedDict + +JsonScalar = None | bool | int | float | str +JsonValue = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] + + +class ComponentConfig(TypedDict): + """Wire shape shared with jsonargparse: ``class_path`` + ``init_args``.""" + + class_path: str + init_args: dict[str, JsonValue] + + +# Maximum nesting depth for recursive normalization and instantiation. +# Counts traversal through lists and mappings as well as nested component configs. +_MAX_CONFIG_DEPTH = 10 + +# Private attribute names used by @export_config. +_CAPTURED_INIT_ARGS_ATTR = "_physicalai_captured_init_args" +_EXPORT_DEPTH_ATTR = "_physicalai_export_config_depth" +_EXPORT_MARKER_ATTR = "_physicalai_export_config" +_CONFIG_HOOK_NAME = "__component_config__" +_CONFIG_CLASS_PATH_ATTR = "__config_class_path__" + +_REPR_LIMIT = 80 diff --git a/src/physicalai/inference/_importing.py b/src/physicalai/inference/_importing.py index 8965d97..8d277b2 100644 --- a/src/physicalai/inference/_importing.py +++ b/src/physicalai/inference/_importing.py @@ -1,38 +1,15 @@ # Copyright (C) 2026 Intel Corporation # SPDX-License-Identifier: Apache-2.0 -"""Dotted-path imports for inference components.""" +"""Dotted-path imports for inference components. -from __future__ import annotations - -import importlib - - -def import_dotted_path(path: str) -> object: - """Resolve a fully-qualified path, including nested attributes. +Canonical implementation lives in :mod:`physicalai.config.importing`. This +module re-exports for backward-compatible imports without loading export or +instantiate. +""" - Args: - path: Dotted path such as ``"pkg.mod.Outer.Inner"``. - - Returns: - The resolved object. - - Raises: - ValueError: If no module prefix can be imported. - """ - if "." not in path: - msg = f"dotted path must contain at least one '.': {path!r}" - raise ValueError(msg) +from __future__ import annotations - segments = path.split(".") - for split_index in range(len(segments), 0, -1): - try: - obj: object = importlib.import_module(".".join(segments[:split_index])) - except ImportError: - continue - for attr in segments[split_index:]: - obj = getattr(obj, attr) - return obj +from physicalai.config.importing import import_dotted_path - msg = f"could not import any module prefix of {path!r}" - raise ValueError(msg) +__all__ = ["import_dotted_path"] diff --git a/src/physicalai/robot/transport/_importing.py b/src/physicalai/robot/transport/_importing.py index 0f3dc58..021561f 100644 --- a/src/physicalai/robot/transport/_importing.py +++ b/src/physicalai/robot/transport/_importing.py @@ -1,38 +1,15 @@ # Copyright (C) 2026 Intel Corporation # SPDX-License-Identifier: Apache-2.0 -"""Dotted-path imports for trusted robot owner configuration.""" +"""Dotted-path imports for trusted robot owner configuration. -from __future__ import annotations - -import importlib - - -def import_dotted_path(path: str) -> object: - """Resolve a fully-qualified path, including nested attributes. +Canonical implementation lives in :mod:`physicalai.config.importing`. This +module re-exports for backward-compatible imports without loading export or +instantiate. +""" - Args: - path: Dotted path such as ``"pkg.mod.Outer.Inner"``. - - Returns: - The resolved object. - - Raises: - ValueError: If no module prefix can be imported. - """ - if "." not in path: - msg = f"dotted path must contain at least one '.': {path!r}" - raise ValueError(msg) +from __future__ import annotations - segments = path.split(".") - for split_index in range(len(segments), 0, -1): - try: - obj: object = importlib.import_module(".".join(segments[:split_index])) - except ImportError: - continue - for attr in segments[split_index:]: - obj = getattr(obj, attr) - return obj +from physicalai.config.importing import import_dotted_path - msg = f"could not import any module prefix of {path!r}" - raise ValueError(msg) +__all__ = ["import_dotted_path"] diff --git a/tests/unit/config/__init__.py b/tests/unit/config/__init__.py new file mode 100644 index 0000000..888f2e0 --- /dev/null +++ b/tests/unit/config/__init__.py @@ -0,0 +1,3 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +# ruff:file-ignore[undocumented-public-package] diff --git a/tests/unit/config/test_component_config.py b/tests/unit/config/test_component_config.py new file mode 100644 index 0000000..9913103 --- /dev/null +++ b/tests/unit/config/test_component_config.py @@ -0,0 +1,446 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +# ruff:file-ignore[undocumented-public-module, undocumented-public-class, undocumented-public-method, undocumented-public-init, undocumented-magic-method, bad-dunder-method-name, magic-value-comparison, no-self-use, assert, unused-method-argument, too-many-public-methods] + +from __future__ import annotations + +import inspect +import json +import math +from collections.abc import Mapping +from enum import Enum +from pathlib import Path +from typing import Protocol, cast, runtime_checkable + +import pytest + +from physicalai.config import ( + ComponentConfig, + ComponentConfigError, + ComponentImportError, + JsonValue, + export_config, + import_dotted_path, + instantiate, + is_config_exportable, + to_config, +) + +# Matches physicalai.config._types._MAX_CONFIG_DEPTH +_MAX_CONFIG_DEPTH = 10 + + +def _as_mapping(value: object) -> dict[str, object]: + assert isinstance(value, dict) + return value + + +def _as_list(value: object) -> list[object]: + assert isinstance(value, list) + return value + + +class Color(Enum): + RED = "red" + BLUE = "blue" + + +@runtime_checkable +class Named(Protocol): + @property + def name(self) -> str: ... + + +@export_config +class Point: + def __init__(self, x: int, y: int = 0) -> None: + self.x = x + self.y = y + + +@export_config +class Box: + def __init__(self, origin: Point, label: str = "box") -> None: + self.origin = origin + self.label = label + + +@export_config +class Nest: + def __init__(self, child: Nest | None = None) -> None: + self.child = child + + +@export_config +class BadInner: + def __init__(self, fn: object) -> None: + self.fn = fn + + +@export_config +class Outer: + def __init__(self, child: BadInner) -> None: + self.child = child + + +@export_config +class BaseWidget: + def __init__(self, name: str) -> None: + self.name = name + + +@export_config +class DerivedWidget(BaseWidget): + def __init__(self, name: str, size: int) -> None: + super().__init__(name) + self.size = size + + +class UndecoratedOverride(BaseWidget): + def __init__(self, name: str, extra: int) -> None: + super().__init__(name) + self.extra = extra + + +class InheritsDecorated(BaseWidget): + """Subclass that inherits the decorated constructor unchanged.""" + + +class HookOnly: + def __init__(self, value: int) -> None: + self.value = value + + def __component_config__(self) -> dict[str, object]: + return { + "class_path": "tests.unit.config.test_component_config.HookOnly", + "init_args": {"value": self.value}, + } + + +@export_config +class WithExtras: + def __init__(self, base: int, **kwargs: object) -> None: + self.base = base + self.kwargs = kwargs + + +@export_config +class PathHolder: + def __init__(self, path: str | Path) -> None: + self.path = path + + +@export_config +class EnumHolder: + def __init__(self, color: Color | str) -> None: + self.color = Color(color) if not isinstance(color, Color) else color + + +@export_config +class MappingHolder: + def __init__(self, data: Mapping[str, object]) -> None: + self.data = dict(data) + + +@export_config +class ListHolder: + def __init__(self, items: list[object]) -> None: + self.items = list(items) + + +@export_config +class OptionalName: + def __init__(self, name: str | None = "default") -> None: + self.name = name + + +@export_config +class Boom: + def __init__(self, x: int) -> None: + msg = "nope" + raise RuntimeError(msg) + + +@export_config +class CtorBoom: + def __init__(self, x: int) -> None: + msg = "boom" + raise ValueError(msg) + + +class TestImportDottedPath: + def test_resolves_nested_class(self) -> None: + assert import_dotted_path("tests.unit.config.test_component_config.Point") is Point + + +class TestNormalizeAndInstantiate: + def test_primitives_round_trip(self) -> None: + point = Point(1, y=2) + config = to_config(point) + wire = json.loads(json.dumps(config)) + restored = instantiate(wire) + assert isinstance(restored, Point) + assert restored.x == 1 + assert restored.y == 2 + assert to_config(restored) == wire + + def test_omitted_defaults_stay_omitted(self) -> None: + point = Point(3) + config = to_config(point) + assert config["init_args"] == {"x": 3} + restored = cast(Point, instantiate(config)) + assert restored.y == 0 + + def test_explicit_none_is_preserved(self) -> None: + obj = OptionalName(None) + config = to_config(obj) + assert config["init_args"] == {"name": None} + restored = cast(OptionalName, instantiate(config)) + assert restored.name is None + + def test_path_as_given(self) -> None: + relative = PathHolder(Path("calib.json")) + absolute = PathHolder(Path("/var/calib.json")) + assert to_config(relative)["init_args"]["path"] == "calib.json" + assert to_config(absolute)["init_args"]["path"] == "/var/calib.json" + str_relative = PathHolder("./relative.json") + assert to_config(str_relative)["init_args"]["path"] == "./relative.json" + + def test_enum_value(self) -> None: + holder = EnumHolder(Color.RED) + config = to_config(holder) + assert config["init_args"]["color"] == "red" + restored = cast(EnumHolder, instantiate(config)) + assert restored.color is Color.RED + + def test_non_finite_float_rejected(self) -> None: + holder = MappingHolder({"x": math.nan}) + with pytest.raises(ComponentConfigError, match="non-finite"): + to_config(holder) + + def test_nested_component(self) -> None: + box = Box(Point(1, 2), label="b") + config = to_config(box) + origin = _as_mapping(config["init_args"]["origin"]) + assert cast(str, origin["class_path"]).endswith(".Point") + assert origin["init_args"] == {"x": 1, "y": 2} + restored = cast(Box, instantiate(json.loads(json.dumps(config)))) + assert restored.origin.x == 1 + assert to_config(restored) == json.loads(json.dumps(config)) + + def test_list_and_mapping(self) -> None: + holder = ListHolder([Point(1), {"a": 1}]) + config = to_config(holder) + items = _as_list(config["init_args"]["items"]) + first = _as_mapping(items[0]) + assert _as_mapping(first["init_args"])["x"] == 1 + assert items[1] == {"a": 1} + restored = cast(ListHolder, instantiate(config)) + assert isinstance(restored.items[0], Point) + + def test_mutable_container_snapshot(self) -> None: + data: dict[str, object] = {"a": 1} + holder = MappingHolder(data) + data["a"] = 99 + assert _as_mapping(to_config(holder)["init_args"]["data"])["a"] == 1 + + def test_cyclic_mapping_rejected(self) -> None: + data: dict[str, object] = {} + data["self"] = data + holder = MappingHolder(data) + with pytest.raises(ComponentConfigError, match="cyclic"): + to_config(holder) + + def test_depth_limit_on_mappings(self) -> None: + nested: dict[str, object] = {"leaf": 1} + for _ in range(_MAX_CONFIG_DEPTH + 2): + nested = {"child": nested} + holder = MappingHolder(nested) + with pytest.raises(ComponentConfigError, match="nesting depth"): + to_config(holder) + + def test_nested_component_depth_symmetric(self) -> None: + """Export and instantiate share the same nested-component depth limit. + + A Nest chain of length ``_MAX_CONFIG_DEPTH`` (depths 0..MAX-1 for + components, leaf ``None`` at depth MAX) must round-trip. Length + ``_MAX_CONFIG_DEPTH + 1`` must fail on both sides. + """ + + def nest_chain(length: int) -> Nest: + node: Nest | None = None + for _ in range(length): + node = Nest(node) + assert node is not None + return node + + allowed = nest_chain(_MAX_CONFIG_DEPTH) + config = to_config(allowed) + restored = instantiate(config) + assert isinstance(restored, Nest) + assert to_config(restored) == config + + too_deep = nest_chain(_MAX_CONFIG_DEPTH + 1) + with pytest.raises(ComponentConfigError, match="nesting depth"): + to_config(too_deep) + + deeper: ComponentConfig = { + "class_path": "tests.unit.config.test_component_config.Nest", + "init_args": cast("dict[str, JsonValue]", {"child": config}), + } + with pytest.raises(ComponentConfigError, match="nesting depth"): + instantiate(deeper) + + def test_nested_error_path_includes_parent(self) -> None: + outer = Outer(BadInner(lambda: None)) + with pytest.raises(ComponentConfigError, match=r"Outer\.init_args\.child\.init_args\.fn"): + to_config(outer) + + def test_malformed_nested_config_before_import(self) -> None: + with pytest.raises(ComponentConfigError, match="unexpected keys"): + instantiate({ + "class_path": "tests.unit.config.test_component_config.Point", + "init_args": {}, + "extra": 1, + }) + + def test_null_init_args_rejected(self) -> None: + with pytest.raises(ComponentConfigError, match="init_args"): + instantiate({ + "class_path": "tests.unit.config.test_component_config.Point", + "init_args": None, + }) + + def test_missing_class_path_rejected(self) -> None: + with pytest.raises(ComponentConfigError, match="missing required"): + instantiate({"init_args": {}}) # type: ignore[arg-type] + + def test_non_class_import_target(self) -> None: + with pytest.raises(ComponentImportError, match="does not resolve to a class"): + instantiate({"class_path": "os.path.join", "init_args": {}}) + + def test_unimportable_class_path(self) -> None: + with pytest.raises(ComponentImportError, match="cannot import"): + instantiate({"class_path": "totally.unknown.module.Cls", "init_args": {}}) + + def test_dict_with_class_path_is_reserved(self) -> None: + holder = MappingHolder({"class_path": "not.a.component", "other": 1}) + with pytest.raises(ComponentConfigError, match="unexpected keys"): + to_config(holder) + + def test_unsupported_object_reports_path(self) -> None: + holder = MappingHolder({"fn": lambda: None}) + with pytest.raises(ComponentConfigError, match=r"init_args\.data\.fn"): + to_config(holder) + + def test_local_class_export_fails(self) -> None: + @export_config + class LocalPoint: + def __init__(self, x: int) -> None: + self.x = x + + obj = LocalPoint(1) + with pytest.raises(ComponentConfigError, match=""): + to_config(obj) + + def test_local_class_instantiate_fails(self) -> None: + with pytest.raises(ComponentConfigError, match=""): + instantiate({ + "class_path": "tests.unit.config.test_component_config.Local..X", + "init_args": {}, + }) + + def test_constructor_failure_propagates_with_note(self) -> None: + with pytest.raises(ValueError, match="boom") as info: + instantiate({ + "class_path": "tests.unit.config.test_component_config.CtorBoom", + "init_args": {"x": 1}, + }) + notes = getattr(info.value, "__notes__", []) + assert any("constructor failed" in note for note in notes) + + +class TestExportConfig: + def test_positional_binds_to_names(self) -> None: + point = Point(4, 5) + assert to_config(point)["init_args"] == {"x": 4, "y": 5} + + def test_kwargs_flatten(self) -> None: + obj = WithExtras(1, color="red", count=2) + assert to_config(obj)["init_args"] == {"base": 1, "color": "red", "count": 2} + + def test_rejects_var_positional(self) -> None: + with pytest.raises(TypeError, match=r"\*args"): + + @export_config + class Bad: + def __init__(self, *args: object) -> None: + self.args = args + + def test_rejects_positional_only(self) -> None: + with pytest.raises(TypeError, match="positional-only"): + + @export_config + class BadPosOnly: + def __init__(self, x: int, /) -> None: + self.x = x + + def test_rejects_decorating_subclass_without_own_init(self) -> None: + with pytest.raises(TypeError, match="define their own __init__"): + + @export_config + class Redundant(BaseWidget): + pass + + def test_outermost_super_wins(self) -> None: + widget = DerivedWidget("w", 10) + config = to_config(widget) + assert config["class_path"].endswith(".DerivedWidget") + assert config["init_args"] == {"name": "w", "size": 10} + + def test_undecorated_override_fails(self) -> None: + obj = UndecoratedOverride("n", 1) + assert not is_config_exportable(obj) + with pytest.raises(ComponentConfigError, match="not config-exportable"): + to_config(obj) + + def test_inherited_decorated_constructor(self) -> None: + obj = InheritsDecorated("ok") + assert is_config_exportable(obj) + config = to_config(obj) + assert config["class_path"].endswith(".InheritsDecorated") + assert config["init_args"] == {"name": "ok"} + + def test_failed_constructor_does_not_capture(self) -> None: + with pytest.raises(RuntimeError, match="nope"): + Boom(1) + + def test_hook_exportable(self) -> None: + obj = HookOnly(7) + assert is_config_exportable(obj) + config = to_config(obj) + assert config == { + "class_path": "tests.unit.config.test_component_config.HookOnly", + "init_args": {"value": 7}, + } + restored = instantiate(config) + assert isinstance(restored, HookOnly) + assert restored.value == 7 + + def test_instance_to_config_sugar(self) -> None: + point = Point(1, 2) + assert point.to_config() == to_config(point) # type: ignore[attr-defined] + + def test_injected_to_config_does_not_break_protocol(self) -> None: + widget = BaseWidget("ok") + assert isinstance(widget, Named) + assert widget.to_config()["init_args"] == {"name": "ok"} # type: ignore[attr-defined] + + def test_signature_preserved(self) -> None: + sig = inspect.signature(Point.__init__) + assert list(sig.parameters) == ["self", "x", "y"] + + def test_depth_attr_cleaned_after_construction(self) -> None: + point = Point(1) + assert is_config_exportable(point) + assert "_physicalai_export_config_depth" not in vars(point) From 86e168d2c9aedf3aae75957247a71fd4c3d8b295 Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Wed, 22 Jul 2026 16:40:59 +0200 Subject: [PATCH 06/16] importing as public module --- src/physicalai/config/_importing.py | 10 -------- src/physicalai/config/importing.py | 38 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 10 deletions(-) delete mode 100644 src/physicalai/config/_importing.py create mode 100644 src/physicalai/config/importing.py diff --git a/src/physicalai/config/_importing.py b/src/physicalai/config/_importing.py deleted file mode 100644 index 4123efb..0000000 --- a/src/physicalai/config/_importing.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (C) 2026 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -"""Backward-compatible alias for :mod:`physicalai.config.importing`.""" - -from __future__ import annotations - -from physicalai.config.importing import import_dotted_path - -__all__ = ["import_dotted_path"] diff --git a/src/physicalai/config/importing.py b/src/physicalai/config/importing.py new file mode 100644 index 0000000..bb49b72 --- /dev/null +++ b/src/physicalai/config/importing.py @@ -0,0 +1,38 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Dotted-path imports for trusted component configuration.""" + +from __future__ import annotations + +import importlib + + +def import_dotted_path(path: str) -> object: + """Resolve a fully-qualified path, including nested attributes. + + Args: + path: Dotted path such as ``"pkg.mod.Outer.Inner"``. + + Returns: + The resolved object. + + Raises: + ValueError: If no module prefix can be imported, or *path* has no ``.``. + """ + if "." not in path: + msg = f"dotted path must contain at least one '.': {path!r}" + raise ValueError(msg) + + segments = path.split(".") + for split_index in range(len(segments), 0, -1): + try: + obj: object = importlib.import_module(".".join(segments[:split_index])) + except ImportError: + continue + for attr in segments[split_index:]: + obj = getattr(obj, attr) + return obj + + msg = f"could not import any module prefix of {path!r}" + raise ValueError(msg) From bd5467ee09fa63aa852f7fb2b106b127e91f4af3 Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Wed, 22 Jul 2026 21:55:46 +0200 Subject: [PATCH 07/16] step 3 --- docs/development/component-config-brief.md | 4 +- docs/development/component-config-report.md | 37 ++-- docs/development/component-config.md | 181 +++++++++------ src/physicalai/config/__init__.py | 13 ++ src/physicalai/config/_export.py | 189 ++++++++++------ src/physicalai/config/_normalize.py | 64 +++++- src/physicalai/config/_types.py | 15 +- src/physicalai/robot/so101/calibration.py | 8 + src/physicalai/robot/so101/so101.py | 2 + .../robot/trossen/bimanual_widowxai.py | 2 + src/physicalai/robot/trossen/widowxai.py | 2 + tests/unit/config/test_component_config.py | 130 +++++++++-- .../unit/robot/test_robot_component_config.py | 209 ++++++++++++++++++ 13 files changed, 669 insertions(+), 187 deletions(-) create mode 100644 tests/unit/robot/test_robot_component_config.py diff --git a/docs/development/component-config-brief.md b/docs/development/component-config-brief.md index 6050af3..42f5499 100644 --- a/docs/development/component-config-brief.md +++ b/docs/development/component-config-brief.md @@ -18,13 +18,15 @@ cannot share third-party drivers cleanly. ## Proposal ```text -@export_config → remember supplied __init__ args +@export_config / @export_config(class_path=...) → remember supplied __init__ args to_config(live) → {class_path, init_args} # ComponentConfig instantiate(cfg)→ fresh disconnected instance +to_config_value() → domain arg → plain JSON inside init_args ``` Same shape as existing jsonargparse configs. Protocols (`Robot`, `Camera`, …) stay behavior-only. Transport settings (`name`, `service_name`, rates) stay in envelopes. +Studio needs only `is_config_exportable` + `to_config`. ```mermaid flowchart LR diff --git a/docs/development/component-config-report.md b/docs/development/component-config-report.md index 60c5201..93c838b 100644 --- a/docs/development/component-config-report.md +++ b/docs/development/component-config-report.md @@ -88,10 +88,12 @@ flowchart TB ```python ComponentConfig = {"class_path": str, "init_args": dict[str, JsonValue]} -@export_config # opt-in on concrete classes -to_config(value) # live → ComponentConfig -instantiate(config) # trusted ComponentConfig → fresh object -is_config_exportable # decorator marker OR __component_config__ +@export_config # opt-in on concrete classes +@export_config(class_path="...") # when public import ≠ defining module +to_config(value) # live → ComponentConfig +instantiate(config) # trusted ComponentConfig → fresh object +is_config_exportable # @export_config marker only +# Domain args: to_config_value() → plain JSON (ConfigValue Protocol) ``` ```mermaid @@ -340,23 +342,26 @@ Implement **one step at a time**. Keep the design note open as the contract. ## Plugin checklist 1. Implement `Robot` / `Camera` / `ActionSource` / callback protocol -2. `@export_config` (or `__component_config__`) -3. JSON-normalizable args; constructors accept normalized forms +2. `@export_config` — use `@export_config(class_path="…")` when re-exported +3. JSON-normalizable args (JSON / nested `@export_config` / `to_config_value()`); + constructors accept normalized forms 4. Relative path args OK; keep cwd stable through Shared\* spawn (or use absolute/`Path` as given) -5. Stable public import path + `__config_class_path__` when re-exported +5. Stable public import path (decorator `class_path=` when it differs from defining module) 6. Round-trip test: `to_config` → `json` → `instantiate` → `to_config` equal ---- +## Studio: `is_config_exportable` + `to_config` only ## Quick reference -| Symbol | Role | -| --------------------------- | ------------------------------------------------------------------- | -| `@export_config` | Opt into `ComponentConfig` export (stores supplied `__init__` args) | -| `ComponentConfig` | Wire shape shared with jsonargparse | -| `to_config` / `instantiate` | Export / trusted rebuild | -| `is_config_exportable` | Can we get a recipe? | -| Owner/publisher stdin | Hard cutover to `robot:` / `camera: ComponentConfig` | -| `service_name` | Camera transport identity (not construction) | +| Symbol | Role | +| ------------------------------ | ------------------------------------------------------------------- | +| `@export_config` | Opt into `ComponentConfig` export (stores supplied `__init__` args) | +| `@export_config(class_path=…)` | Public re-export path when ≠ defining module | +| `to_config_value()` | Domain arg → plain JSON inside `init_args` (`ConfigValue`) | +| `ComponentConfig` | Wire shape shared with jsonargparse | +| `to_config` / `instantiate` | Export / trusted rebuild | +| `is_config_exportable` | Can we get a recipe? (decorator marker) | +| Owner/publisher stdin | Hard cutover to `robot:` / `camera: ComponentConfig` | +| `service_name` | Camera transport identity (not construction) | **Full rules and required tests:** [component-config.md](component-config.md) diff --git a/docs/development/component-config.md b/docs/development/component-config.md index f01b931..14be1c1 100644 --- a/docs/development/component-config.md +++ b/docs/development/component-config.md @@ -12,7 +12,7 @@ configuration must be exportable so a fresh instance can be created later. ```text Robot / Camera / ActionSource = runtime behavior (unchanged) -@export_config = opt into ComponentConfig export from ctor args +@export_config / @export_config(class_path=...) = opt into ComponentConfig export ComponentConfig = plain class_path + init_args data ``` @@ -20,6 +20,18 @@ Use the same contract for robots, cameras, action sources, runtimes, callbacks, and nested components. Keep process names, rates, timeouts, and other transport settings in their existing transport envelopes. +Mental model: + +```text +@export_config / @export_config(class_path="...") → whole component → {class_path, init_args} +to_config_value() → domain arg → plain JSON inside init_args +(nested @export_config) → nested component → nested {class_path, init_args} +``` + +Studio needs only `is_config_exportable(obj)` and `to_config(obj)`, then +domain helpers such as `SharedRobot.from_config(...)`. Studio does not +implement or require domain/component escape hatches. + The contract closes both directions: ```text @@ -71,7 +83,7 @@ Place this API in a neutral module such as `physicalai.config`, not under robot, capture, runtime, or inference. ```python -from typing import TypedDict +from typing import Protocol, TypedDict JsonScalar = None | bool | int | float | str JsonValue = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] @@ -80,7 +92,11 @@ JsonValue = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] ComponentConfig = TypedDict("ComponentConfig", {"class_path": str, "init_args": dict[str, JsonValue]}) -def export_config(cls: type) -> type: ... +class ConfigValue(Protocol): + def to_config_value(self) -> JsonValue: ... + + +def export_config(cls: type | None = None, *, class_path: str | None = None): ... def to_config(value: object) -> ComponentConfig: ... @@ -96,6 +112,11 @@ Normal use stays small: ```python @export_config +class MyCamera: + def __init__(self, device: str): ... + + +@export_config(class_path="physicalai.robot.SO101") class SO101: def __init__(self, port: str, calibration: dict | str): ... @@ -127,10 +148,9 @@ the supported JSON model. Do not add a public `Constructible` or `Replayable` protocol. Runtime and Studio code that needs to test exportability uses `is_config_exportable(value)`. A value is exportable if and only if it carries the private `@export_config` -decorator marker **or** provides a callable `__component_config__` hook. -`to_config(value)` uses the same predicate. This keeps the plugin-facing -contract to one decorator plus one private escape hatch, and gives Studio's -share path a single exportability check for both decorator and hook paths. +decorator marker. `to_config(value)` uses the same predicate. This keeps the +plugin-facing contract to one decorator; Studio's share path keys off that +single exportability check. ### Naming @@ -142,18 +162,18 @@ detail documented here, not part of the public name. Rejected names: -| Name | Problem | -| --------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `capture_init` | Collides with the `physicalai.capture` camera package | -| `record_init` | Will collide with planned runtime recording callbacks | -| `serializable` | Implies complete object-state serialization | -| `configurable` | Usually means an object accepts configuration, not that it exports it | -| `replayable` | Does not say what is replayed and can suggest runtime/action replay | -| `reconstructible` | Accurate but cumbersome and still hides the config-export contract | -| `remember_init` / `snapshot_init` | Mechanism-only; weaker fit next to `to_config` | -| `config_exportable` | Accurate but clumsy as a class decorator | -| `export_init` | Precise about ctor args, but understates hook-only export and rhymes less with `is_config_exportable` | -| `has_captured_init` | Misleading for hook-only classes; use `is_config_exportable` | +| Name | Problem | +| --------------------------------- | --------------------------------------------------------------------- | +| `capture_init` | Collides with the `physicalai.capture` camera package | +| `record_init` | Will collide with planned runtime recording callbacks | +| `serializable` | Implies complete object-state serialization | +| `configurable` | Usually means an object accepts configuration, not that it exports it | +| `replayable` | Does not say what is replayed and can suggest runtime/action replay | +| `reconstructible` | Accurate but cumbersome and still hides the config-export contract | +| `remember_init` / `snapshot_init` | Mechanism-only; weaker fit next to `to_config` | +| `config_exportable` | Accurate but clumsy as a class decorator | +| `export_init` | Precise about ctor args, but rhymes less with `is_config_exportable` | +| `has_captured_init` | Mechanism-only; use `is_config_exportable` | Use `to_config(value)` for export because the result is directly consumable jsonargparse component configuration. Use `instantiate()` for the trusted @@ -250,31 +270,29 @@ again. Tests and contributor documentation must state this explicitly. There is no merge or last-write behavior. The outermost decorated constructor owns the complete `init_args`. `to_config(value)` verifies that the -most-derived class's effective `__init__` carries the decorator marker **or** -that the instance provides `__component_config__`; an overriding, undecorated -`__init__` without the hook fails loudly instead of emitting a partial recipe. -A subclass that inherits a decorated constructor unchanged remains valid +most-derived class's effective `__init__` carries the decorator marker; an +overriding, undecorated `__init__` fails loudly instead of emitting a partial +recipe. A subclass that inherits a decorated constructor unchanged remains valid because its effective constructor has the marker and the inherited signature is complete. Select `class_path` from the most-derived `type(self)`, never from the class -that defined an inner decorated constructor. Classes can declare a stable -`__config_class_path__` public re-export; otherwise use -`type(self).__module__ + "." + type(self).__qualname__`. Before emitting the -spec, resolve the selected path and verify that it identifies exactly -`type(self)`. - -First-party public robot and camera classes must declare their stable public +that defined an inner decorated constructor. Pass +`@export_config(class_path="physicalai.robot.SO101")` when the public import +path differs from the defining module; otherwise export uses +`type(self).__module__ + "." + type(self).__qualname__`. The `class_path=` +override applies only to the concrete class that was decorated (owns the +wrapped `__init__` in its class dict). A subclass that inherits a decorated +constructor unchanged does **not** inherit that override — it exports its own +`__module__.__qualname__` unless it re-decorates with its own `class_path=`. +Before emitting the spec, resolve the selected path and verify that it +identifies exactly `type(self)`. + +First-party public robot and camera classes must pass their stable public re-export path (for example, `physicalai.robot.SO101`) instead of emitting an internal defining-module path. Plugins must do the same when they promise a stable public import surface. -Factory or legacy classes whose configuration does not correspond directly to -one constructor call can implement a private `__component_config__()` hook. -`to_config(value)` and `is_config_exportable(value)` recognize that hook. This -is an internal escape hatch, not a second public protocol; such classes are -the exception, not the default. - `to_config(value)` describes the object **as constructed**, not its current mutable state. @@ -288,10 +306,10 @@ Normalization is recursive and produces JSON-safe values. | non-finite `float` (`NaN`, `±Inf`) | error | not applicable | | `Path` | string as given (`str(path)`) | string | | `Enum` | JSON-safe `.value` | enum value representation | -| decorated or hook-exportable component | `{class_path, init_args}` | instantiated component | +| decorated (`@export_config`) component | `{class_path, init_args}` | instantiated component | | mapping with string keys | normalized mapping | decoded mapping | | list or tuple | normalized list | list | -| domain value with an explicit codec | codec output | constructor-compatible value | +| domain value with `to_config_value()` | re-normalized codec output | constructor-compatible value | | any other object | error | not applicable | `Path` values serialize as `str(path)` **without** `resolve()`. Relative paths @@ -304,10 +322,16 @@ Reject non-finite floats during normalization. Python's default `json` encoder permits `NaN` / `Infinity`, which are not portable across strict JSON consumers. -For v1, domain codecs remain minimal. `SO101Calibration` can normalize with -`to_dict()` because the SO101 constructor already accepts a dictionary. Do not -add a global arbitrary-object codec registry until a second concrete domain -type requires one. +For v1, domain codecs remain minimal. Non-component domain values implement +`to_config_value()` (`ConfigValue` Protocol). The method must return a **new** +JSON-compatible value that is then re-normalized (non-finite floats, reserved +`class_path` maps, depth and cycle checks, nested codecs). Absence of the +method means _no codec_ for that value. Returning `None` from the method is a +real JSON `null` payload. Mutually recursive domain codecs fail with +`ComponentConfigError` (not `RecursionError`). `SO101Calibration.to_config_value()` +returns `to_dict()` because the SO101 constructor already accepts a dictionary. +Do not auto-call arbitrary `to_dict()`. Do not add a global arbitrary-object +codec registry until a second concrete domain type requires one. Constructors participating in this contract must accept the normalized JSON representation of their arguments. For example, constructors typed with an @@ -327,10 +351,11 @@ Any dictionary containing `class_path` is reserved as a nested component config. Its only allowed keys are `class_path` and `init_args`; omitted `init_args` means an empty mapping. Extra keys or invalid values are malformed configs, not ordinary dictionaries. A caller that needs `class_path` as a normal -data key must use an explicit domain codec or wrapper. Error messages and the -plugin contract must name this escape hatch. This rule removes ambiguity and -fails closed during generic recursive deserialization. Do not add a sentinel -marker unless a second concrete domain collision demonstrates the need. +data key must encode that mapping through `to_config_value()` (or another +domain wrapper). Error messages and the plugin contract name this escape. +This rule removes ambiguity and fails closed during generic recursive +deserialization. Do not add a sentinel marker unless a second concrete domain +collision demonstrates the need. Errors identify the full argument path: @@ -432,9 +457,11 @@ pass the bare fragment to `instantiate()`. ### Robots -SO101 and WidowXAI opt in directly. SO101 calibration is stored as a path when -constructed from a path and as a normalized dictionary when constructed from -an object. +SO101 and WidowXAI opt in with `@export_config(class_path="physicalai.robot.…")` +so export emits the public re-export path. SO101 calibration is stored as a +path when constructed from a path and as a normalized dictionary when +constructed from an object (`SO101Calibration.to_config_value()` → +`to_dict()` — a domain value, not a nested component). Private arguments are not excluded by naming convention. If replay needs an argument, capture it. `SO101.uncalibrated()` currently calls the constructor @@ -443,10 +470,11 @@ that supplied private argument. Uncalibrated round-trip tests are required before claiming SO101 support; a future public constructor parameter can replace the private replay detail. -Bimanual robots no longer need a special serialization design once each arm -uses `@export_config`: `left` and `right` become nested configs under -`BimanualWidowXAI`. v1 `SharedRobot` spawn treats the composite as **one -owner**. Per-arm `SharedRobot` wrapping of nested arms is out of scope. +Bimanual robots need no special serialization once each arm uses +`@export_config`: `left` and `right` are nested `@export_config` components +under `BimanualWidowXAI` (contrast with domain `to_config_value` for +calibration). v1 `SharedRobot` spawn treats the composite as **one owner**. +Per-arm `SharedRobot` wrapping of nested arms is out of scope. ### Cameras @@ -532,9 +560,11 @@ Non-scalar or live override arguments among captured kwargs (including non-scalar `adapter_kwargs`, and live `runner` / `preprocessors` / `postprocessors` / `callbacks`) make `to_config` fail. Do not silently drop them. Live overrides are in scope only when every nested value independently -exports component config or `InferenceModel` supplies the private manual -config hook. Instantiating an inference config can allocate substantial -resources; it is not analogous to constructing a disconnected robot driver. +exports via `@export_config` (or encodes via `to_config_value()` for domain +args). A full-component escape hatch without the decorator is deferred and not +part of the v1 contract. Instantiating an inference config can allocate +substantial resources; it is not analogous to constructing a disconnected +robot driver. ### Runtime @@ -652,11 +682,11 @@ window on the private wire. `robot: {class_path, init_args}` before spawn. New code and docs prefer `SharedRobot.from_config(...)` and `from_robot(...)`. - **Public path normalization:** when accepting a class object or a legacy - dotted path, normalize through the same public-path resolution / - `__config_class_path__` used by `to_config` **before** store, metadata - advertise, and conflict compare. This prevents false mismatches between - defining-module paths (for example - `physicalai.robot.so101.so101.SO101`) and public re-exports + dotted path, normalize through the same public-path resolution used by + `to_config` (decorator `class_path=` override, else + `__module__.__qualname__`) **before** store, metadata advertise, and conflict + compare. This prevents false mismatches between defining-module paths (for + example `physicalai.robot.so101.so101.SO101`) and public re-exports (`physicalai.robot.SO101`). - **CLI `physicalai robot`:** accept `--robot` (ComponentConfig JSON/YAML) **XOR** legacy `--robot_class` / `--robot_kwargs`; both paths write only the @@ -770,19 +800,26 @@ policy, but it does not replace the trusted-input rule for v1. 1. Implement the relevant runtime protocol (`Robot`, `Camera`, `ActionSource`, callback, or another typed component). -2. Opt into config export with `@export_config`. A factory-oriented class can - instead provide `__component_config__()`; both paths satisfy - `is_config_exportable`. -3. Ensure every captured value is JSON-normalizable and constructor-compatible; - domain dictionaries containing reserved `class_path` require an explicit - codec or wrapper. +2. Opt into config export with `@export_config`. When the public import path + differs from the defining module, pass + `@export_config(class_path="physicalai.robot.SO101")`. +3. Ensure every captured value is JSON-normalizable and constructor-compatible + (JSON-safe scalars/collections, nested `@export_config` components, or + domain values with `to_config_value()`). Do not auto-call arbitrary + `to_dict()`. Dictionaries containing reserved `class_path` as ordinary data + must go through `to_config_value()` or another wrapper. 4. Path-shaped constructor args may be relative. They resolve against the process cwd at open/`instantiate`. Owner/publisher children inherit the parent cwd at spawn — keep that cwd stable between export and spawn when using relatives. Absolute paths and `Path` are fine when you need them. -5. Export the class from a stable public import path. +5. Export the class from a stable public import path (and set `class_path=` + on the decorator when that path differs from the defining module). 6. Add construction round-trip tests. +Studio consumes only `is_config_exportable` and `to_config`, then +`SharedRobot.from_config` / camera equivalents. A full-component escape hatch +(without `@export_config`) is not part of the v1 contract. + Opt-in is transitive: a container component can export config only when all captured nested component values can also export config. @@ -808,7 +845,8 @@ specific subclasses only where tests or callers distinguish phases. duplicated inference and robot importers behind that resolver (behavior-preserving). Do not change inference factory behavior. 2. Add `@export_config`, `to_config()`, and `is_config_exportable()` with - signature, inheritance-depth, hook-export, and mutable-container tests. + signature, inheritance-depth, `class_path=`, domain `to_config_value`, and + mutable-container tests. 3. Wire SO101, WidowXAI, and `BimanualWidowXAI` (nested `left`/`right` composite round-trips); add JSON and construction round-trip tests. 4. Add `SharedRobot.from_config()` / `from_robot()`; hard-cutover @@ -867,9 +905,8 @@ cutovers; do not describe them as schema-preserving. - Decorated `super()` calls do not overwrite the outer constructor capture. - An undecorated overriding constructor fails instead of emitting base-only arguments, and the selected public path resolves to the most-derived type. -- `is_config_exportable` is true for decorator-marked and `__component_config__` - hook-only classes; `to_config` works for both; Studio share keys off that - predicate. +- `is_config_exportable` is true for decorator-marked classes; `to_config` + works for them; Studio share keys off that predicate. - jsonargparse still sees the original decorated constructor signature. - Injected instance `to_config()` does not break structural Protocol checks; docs prefer module `to_config`. diff --git a/src/physicalai/config/__init__.py b/src/physicalai/config/__init__.py index bb371a9..15b6af4 100644 --- a/src/physicalai/config/__init__.py +++ b/src/physicalai/config/__init__.py @@ -6,6 +6,16 @@ Trusted local application and parent→child startup configs only. Never pass network metadata or untrusted peer payloads to :func:`instantiate`. +Opt-in path: + +- ``@export_config`` / ``@export_config(class_path=...)`` — remember + caller-supplied constructor args for :func:`to_config`. +- Domain ctor args may implement :meth:`~ConfigValue.to_config_value` to + return a JSON-compatible fragment (re-normalized). + +Studio needs only :func:`is_config_exportable` and :func:`to_config`, then +domain ``from_config`` helpers (for example ``SharedRobot.from_config``). + Public names are resolved lazily so ``physicalai.config.importing`` can be imported without loading export/normalize/instantiate. """ @@ -23,6 +33,7 @@ from ._export import to_config as to_config from ._instantiate import instantiate as instantiate from ._types import ComponentConfig as ComponentConfig + from ._types import ConfigValue as ConfigValue from ._types import JsonScalar as JsonScalar from ._types import JsonValue as JsonValue from .importing import import_dotted_path as import_dotted_path @@ -31,6 +42,7 @@ "ComponentConfig", "ComponentConfigError", "ComponentImportError", + "ConfigValue", "JsonScalar", "JsonValue", "export_config", @@ -42,6 +54,7 @@ _LAZY_ATTRS: dict[str, tuple[str, str]] = { "ComponentConfig": ("._types", "ComponentConfig"), + "ConfigValue": ("._types", "ConfigValue"), "JsonScalar": ("._types", "JsonScalar"), "JsonValue": ("._types", "JsonValue"), "ComponentConfigError": ("._errors", "ComponentConfigError"), diff --git a/src/physicalai/config/_export.py b/src/physicalai/config/_export.py index 569e71e..9cc8715 100644 --- a/src/physicalai/config/_export.py +++ b/src/physicalai/config/_export.py @@ -7,19 +7,17 @@ import functools import inspect -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar, overload from ._errors import ComponentConfigError from ._normalize import ( normalize_value, snapshot_captured_value, - validate_component_config, ) from ._path import format_path from ._types import ( _CAPTURED_INIT_ARGS_ATTR, _CONFIG_CLASS_PATH_ATTR, - _CONFIG_HOOK_NAME, _EXPORT_DEPTH_ATTR, _EXPORT_MARKER_ATTR, _MAX_CONFIG_DEPTH, @@ -28,6 +26,9 @@ ) from .importing import import_dotted_path +if TYPE_CHECKING: + from collections.abc import Callable + _T = TypeVar("_T", bound=type) @@ -35,27 +36,53 @@ def _has_export_marker(obj: object) -> bool: return bool(getattr(obj, _EXPORT_MARKER_ATTR, False)) -def _has_config_hook(value: object) -> bool: - hook = getattr(value, _CONFIG_HOOK_NAME, None) - return callable(hook) +def _encode_domain_value(value: object) -> tuple[JsonValue] | None: + """Encode a domain value via ``to_config_value()`` if present. + + The returned payload is further normalized by :func:`normalize_value` + (non-finite floats, reserved ``class_path`` maps, depth/cycles, nested + codecs). Absence of a callable ``to_config_value`` means *no codec*. + Returning JSON ``null`` (``None``) from the method is a real payload — + wrap it in a 1-tuple here so it is distinct from “no codec”. + + Returns: + ``(payload,)`` when a codec applies (``payload`` may be ``None``), or + ``None`` when the value has no domain encoder. + """ + encode = getattr(value, "to_config_value", None) + if not callable(encode): + return None + return (encode(),) # type: ignore[return-value] def is_config_exportable(value: object) -> bool: """Return whether *value* can export a :class:`ComponentConfig`. A value is exportable if and only if its most-derived class's effective - ``__init__`` carries the ``@export_config`` marker, or the instance - provides a callable ``__component_config__`` hook. + ``__init__`` carries the ``@export_config`` marker. """ - if _has_config_hook(value): - return True init = type(value).__init__ return _has_export_marker(init) +def _class_path_override(cls: type) -> str | None: + """Return ``class_path=`` only when *cls* owns the decorated ``__init__``. + + Subclasses that inherit a decorated constructor unchanged must not inherit + the base's public-path override; they export ``__module__.__qualname__`` + unless they re-decorate with their own ``class_path=``. + """ + owned_init = cls.__dict__.get("__init__") + if owned_init is None: + return None + explicit = getattr(owned_init, _CONFIG_CLASS_PATH_ATTR, None) + if isinstance(explicit, str) and explicit: + return explicit + return None + + def _resolve_public_class_path(cls: type) -> str: - explicit = getattr(cls, _CONFIG_CLASS_PATH_ATTR, None) - path = explicit if isinstance(explicit, str) and explicit else f"{cls.__module__}.{cls.__qualname__}" + path = _class_path_override(cls) or f"{cls.__module__}.{cls.__qualname__}" if "" in cls.__qualname__: msg = f"local class {path!r} cannot export a stable class_path" @@ -74,10 +101,7 @@ def _resolve_public_class_path(cls: type) -> str: def _component_path_prefix(value: object) -> str: cls = type(value) - explicit = getattr(cls, _CONFIG_CLASS_PATH_ATTR, None) - if isinstance(explicit, str) and explicit: - return explicit - return f"{cls.__module__}.{cls.__qualname__}" + return _class_path_override(cls) or f"{cls.__module__}.{cls.__qualname__}" def _arg_path(prefix: str, class_path: str, key: str) -> str: @@ -95,9 +119,12 @@ def to_config( ) -> ComponentConfig: """Export an opted-in live component as JSON-safe ``class_path`` + ``init_args``. + Nested constructor values may be components (``@export_config``) or domain + values with :meth:`~ConfigValue.to_config_value` (re-normalized; absence of + the method means no codec; a returned ``None`` is JSON null). + Args: - value: An instance whose class uses ``@export_config``, or which - implements ``__component_config__``. + value: An instance whose class uses ``@export_config``. Returns: A :class:`ComponentConfig` describing the object as constructed. @@ -112,29 +139,10 @@ def to_config( seen = _seen if _seen is not None else set() - if _has_config_hook(value): - hook = getattr(value, _CONFIG_HOOK_NAME) - raw = hook() - path_prefix = _path or _component_path_prefix(value) - validated = validate_component_config(raw, path=path_prefix) - normalized_args: dict[str, JsonValue] = { - key: normalize_value( - item, - path=_arg_path(_path, validated["class_path"], key), - depth=_depth + 1, - seen=seen, - to_config=to_config, - is_exportable=is_config_exportable, - ) - for key, item in validated["init_args"].items() - } - return {"class_path": validated["class_path"], "init_args": normalized_args} - if not _has_export_marker(type(value).__init__): msg = ( f"{_path or _component_path_prefix(value)}: not config-exportable; " - "decorate the concrete class with @export_config or implement " - "__component_config__" + "decorate the concrete class with @export_config" ) raise ComponentConfigError(msg) @@ -156,6 +164,7 @@ def to_config( seen=seen, to_config=to_config, is_exportable=is_config_exportable, + domain_codec=_encode_domain_value, ) return {"class_path": class_path, "init_args": init_args} @@ -184,34 +193,7 @@ def _validate_replayable_signature(cls: type, signature: inspect.Signature) -> N raise TypeError(msg) -def export_config(cls: _T) -> _T: - """Opt a concrete class into constructor-config export via :func:`to_config`. - - Remembers caller-supplied ``__init__`` arguments (not defaults). Rejects - constructors that declare positional-only parameters or ``*args``. Injects - an instance ``to_config()`` convenience method; library code should still - call the module-level :func:`to_config`. - - Inheritance: - - - Decorate every concrete class that **overrides** ``__init__``. - - An undecorated overriding ``__init__`` fails at :func:`to_config` (partial - base recipes are not emitted). - - A subclass that inherits a decorated constructor unchanged remains valid - without re-decorating. - - Do not apply ``@export_config`` to a subclass that does not define its - own ``__init__`` (that would double-wrap the inherited wrapper). - - Args: - cls: Class to decorate. Must define ``__init__`` in its own class body. - - Returns: - The same class with a wrapped ``__init__``. - - Raises: - TypeError: If *cls* is not a type, has no own ``__init__``, or its - ``__init__`` is not replayable. - """ +def _decorate_export_config(cls: _T, *, class_path: str | None) -> _T: if not isinstance(cls, type): msg = f"@export_config expects a class, got {type(cls).__name__}" raise TypeError(msg) @@ -229,10 +211,14 @@ def export_config(cls: _T) -> _T: msg = f"@export_config on {cls.__qualname__}: class has no custom __init__" raise TypeError(msg) - # Re-decorating the same class body is a no-op. + # Re-decorating the same class body is a no-op (keeps prior class_path if any). if _has_export_marker(original_init): return cls + if class_path is not None and (not isinstance(class_path, str) or not class_path): + msg = f"@export_config on {cls.__qualname__}: class_path must be a non-empty string" + raise TypeError(msg) + signature = inspect.signature(original_init) _validate_replayable_signature(cls, signature) @@ -275,7 +261,76 @@ def wrapped_init(self: object, *args: object, **kwargs: object) -> None: delattr(self, _EXPORT_DEPTH_ATTR) setattr(wrapped_init, _EXPORT_MARKER_ATTR, True) + if class_path is not None: + setattr(wrapped_init, _CONFIG_CLASS_PATH_ATTR, class_path) cls.__init__ = wrapped_init # type: ignore[method-assign] # Convenience method; type checkers do not see the injection — prefer module to_config. cls.to_config = _instance_to_config # type: ignore[attr-defined] return cls + + +@overload +def export_config(cls: _T, /) -> _T: ... + + +@overload +def export_config(*, class_path: str | None = None) -> Callable[[_T], _T]: ... + + +def export_config( + cls: _T | None = None, + /, + *, + class_path: str | None = None, +) -> _T | Callable[[_T], _T]: + """Opt a concrete class into constructor-config export via :func:`to_config`. + + Remembers caller-supplied ``__init__`` arguments (not defaults). Rejects + constructors that declare positional-only parameters or ``*args``. Injects + an instance ``to_config()`` convenience method; library code should still + call the module-level :func:`to_config`. + + Usage:: + + @export_config + class MyRobot: ... + + @export_config(class_path="physicalai.robot.SO101") + class SO101: ... + + When ``class_path`` is omitted, export uses + ``type(self).__module__ + "." + type(self).__qualname__``. Pass + ``class_path=`` when the public import path differs from the defining + module (for example a package re-export). + + Nested non-component domain values (for example calibration objects) may + implement :meth:`~ConfigValue.to_config_value` so they normalize to + constructor-compatible JSON; that method's output is re-normalized. + Absence of the method means no codec; a returned ``None`` is JSON null. + + Inheritance: + + - Decorate every concrete class that **overrides** ``__init__``. + - An undecorated overriding ``__init__`` fails at :func:`to_config` (partial + base recipes are not emitted). + - A subclass that inherits a decorated constructor unchanged remains valid + without re-decorating. + - Do not apply ``@export_config`` to a subclass that does not define its + own ``__init__`` (that would double-wrap the inherited wrapper). + + Args: + cls: Class to decorate when used as ``@export_config``. Must define + ``__init__`` in its own class body. + class_path: Optional stable public import path for export. Verified on + export to resolve exactly to the decorated class. + + Returns: + The decorated class, or a decorator when ``class_path`` is passed. + """ + if cls is not None: + return _decorate_export_config(cls, class_path=class_path) + + def decorator(target: _T) -> _T: + return _decorate_export_config(target, class_path=class_path) + + return decorator diff --git a/src/physicalai/config/_normalize.py b/src/physicalai/config/_normalize.py index 3dfa088..95da902 100644 --- a/src/physicalai/config/_normalize.py +++ b/src/physicalai/config/_normalize.py @@ -22,7 +22,9 @@ ToConfigFn = Callable[..., ComponentConfig] IsExportableFn = Callable[[object], bool] -DomainCodecFn = Callable[[object], JsonValue | None] +# Present codec result is a 1-tuple so JSON ``null`` (``None``) is distinct from +# “no codec” (plain ``None`` return from the codec function). +DomainCodecFn = Callable[[object], tuple[JsonValue] | None] def _is_component_config_mapping(value: Mapping[object, object]) -> bool: @@ -52,7 +54,12 @@ def validate_component_config(config: object, *, path: str = "") -> ComponentCon extra = keys - allowed if extra: extras = ", ".join(sorted(repr(k) for k in extra)) - msg = f"{loc}: nested component config may only contain 'class_path' and 'init_args'; unexpected keys: {extras}" + msg = ( + f"{loc}: nested component config may only contain 'class_path' and " + f"'init_args'; unexpected keys: {extras}. For an ordinary mapping that " + "needs a 'class_path' data key, encode it via to_config_value() " + "(or another domain wrapper) instead of nesting it as a component config" + ) raise ComponentConfigError(msg) if "class_path" not in config: msg = f"{loc}: component config missing required 'class_path'" @@ -140,6 +147,7 @@ def _normalize_mapping( path: str, depth: int, seen: set[int], + codec_seen: set[int], to_config: ToConfigFn | None, is_exportable: IsExportableFn | None, domain_codec: DomainCodecFn | None, @@ -161,6 +169,7 @@ def _normalize_mapping( path=child_path, depth=depth + 1, seen=seen, + codec_seen=codec_seen, to_config=to_config, is_exportable=is_exportable, domain_codec=domain_codec, @@ -182,6 +191,7 @@ def _normalize_mapping( path=child_path, depth=depth + 1, seen=seen, + codec_seen=codec_seen, to_config=to_config, is_exportable=is_exportable, domain_codec=domain_codec, @@ -197,6 +207,7 @@ def _normalize_sequence( path: str, depth: int, seen: set[int], + codec_seen: set[int], to_config: ToConfigFn | None, is_exportable: IsExportableFn | None, domain_codec: DomainCodecFn | None, @@ -216,6 +227,7 @@ def _normalize_sequence( path=child_path, depth=depth + 1, seen=seen, + codec_seen=codec_seen, to_config=to_config, is_exportable=is_exportable, domain_codec=domain_codec, @@ -240,6 +252,7 @@ def normalize_value( path: str = "", depth: int = 0, seen: set[int] | None = None, + codec_seen: set[int] | None = None, to_config: ToConfigFn | None = None, is_exportable: IsExportableFn | None = None, domain_codec: DomainCodecFn | None = None, @@ -249,21 +262,28 @@ def normalize_value( Args: value: Captured constructor argument or nested structure. path: Argument path for error messages. - depth: Current nesting depth (configs, lists, and mappings). + depth: Current nesting depth (configs, lists, mappings, and codec hops). seen: Container identities already visited (cycle detection). + codec_seen: Domain values currently mid-encode via ``to_config_value``. to_config: Callback for nested exportable components. is_exportable: Predicate for nested exportable components. - domain_codec: Optional codec returning a JSON value or ``None``. + domain_codec: Optional codec. Returns ``None`` when there is no codec for + *value*; returns a 1-tuple ``(payload,)`` when a codec applies + (``payload`` may be JSON ``null`` / ``None``). Tuple payloads are + re-normalized (fail closed). Returns: A JSON-safe value. Raises: - ComponentConfigError: On unsupported values, cycles, or depth overflow. + ComponentConfigError: On unsupported values, cycles, depth overflow, + or a domain codec that returns the same object identity. """ _check_depth(path, depth) if seen is None: seen = set() + if codec_seen is None: + codec_seen = set() handled, scalar = _try_normalize_scalar(value, path=path) if handled: @@ -276,9 +296,35 @@ def normalize_value( return _normalize_exportable(value, path=path, depth=depth, seen=seen, to_config=to_config) if domain_codec is not None: - encoded = domain_codec(value) - if encoded is not None: - return encoded + encoded_result = domain_codec(value) + if encoded_result is not None: + obj_id = id(value) + if obj_id in codec_seen: + msg = f"{format_path(path)}: cyclic to_config_value() encoding is not serializable" + raise ComponentConfigError(msg) + (encoded,) = encoded_result + # Identity guard: a method that returns self would recurse forever. + if encoded is value: + msg = ( + f"{format_path(path)}: to_config_value() must return a new " + "JSON-compatible value, not the same object " + "(absence of the method means no codec)" + ) + raise ComponentConfigError(msg) + codec_seen.add(obj_id) + try: + return normalize_value( + encoded, + path=path, + depth=depth + 1, + seen=seen, + codec_seen=codec_seen, + to_config=to_config, + is_exportable=is_exportable, + domain_codec=domain_codec, + ) + finally: + codec_seen.discard(obj_id) if isinstance(value, Mapping): return _normalize_mapping( @@ -286,6 +332,7 @@ def normalize_value( path=path, depth=depth, seen=seen, + codec_seen=codec_seen, to_config=to_config, is_exportable=is_exportable, domain_codec=domain_codec, @@ -297,6 +344,7 @@ def normalize_value( path=path, depth=depth, seen=seen, + codec_seen=codec_seen, to_config=to_config, is_exportable=is_exportable, domain_codec=domain_codec, diff --git a/src/physicalai/config/_types.py b/src/physicalai/config/_types.py index d179a6f..4563a45 100644 --- a/src/physicalai/config/_types.py +++ b/src/physicalai/config/_types.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import TypedDict +from typing import Protocol, TypedDict, runtime_checkable JsonScalar = None | bool | int | float | str JsonValue = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] @@ -18,6 +18,15 @@ class ComponentConfig(TypedDict): init_args: dict[str, JsonValue] +@runtime_checkable +class ConfigValue(Protocol): + """Domain value that encodes to constructor-compatible JSON for export.""" + + def to_config_value(self) -> JsonValue: + """Return ctor-compatible JSON for component-config export.""" + ... + + # Maximum nesting depth for recursive normalization and instantiation. # Counts traversal through lists and mappings as well as nested component configs. _MAX_CONFIG_DEPTH = 10 @@ -26,7 +35,7 @@ class ComponentConfig(TypedDict): _CAPTURED_INIT_ARGS_ATTR = "_physicalai_captured_init_args" _EXPORT_DEPTH_ATTR = "_physicalai_export_config_depth" _EXPORT_MARKER_ATTR = "_physicalai_export_config" -_CONFIG_HOOK_NAME = "__component_config__" -_CONFIG_CLASS_PATH_ATTR = "__config_class_path__" +# Optional public class_path override stored on the decorated __init__ wrapper. +_CONFIG_CLASS_PATH_ATTR = "_physicalai_config_class_path" _REPR_LIMIT = 80 diff --git a/src/physicalai/robot/so101/calibration.py b/src/physicalai/robot/so101/calibration.py index 4765df6..1f0cbad 100644 --- a/src/physicalai/robot/so101/calibration.py +++ b/src/physicalai/robot/so101/calibration.py @@ -71,6 +71,14 @@ def to_dict(self) -> dict[str, dict[str, int]]: """ return {name: joint.to_dict() for name, joint in self.joints.items()} + def to_config_value(self) -> dict[str, dict[str, int]]: + """Encode as a constructor-compatible dict for :func:`~physicalai.config.to_config`. + + Returns: + The LeRobot calibration mapping produced by :meth:`to_dict`. + """ + return self.to_dict() + @classmethod def from_dict(cls, data: object) -> SO101Calibration: """Build a calibration object from parsed JSON data. diff --git a/src/physicalai/robot/so101/so101.py b/src/physicalai/robot/so101/so101.py index 1f916f7..ad0d63b 100644 --- a/src/physicalai/robot/so101/so101.py +++ b/src/physicalai/robot/so101/so101.py @@ -41,6 +41,7 @@ PortHandler, ) +from physicalai.config import export_config from physicalai.robot import Robot from physicalai.robot.device_ids import device_id_from_serial_port from physicalai.robot.so101.calibration import SO101Calibration @@ -93,6 +94,7 @@ def state(self) -> np.ndarray: return self.joint_positions +@export_config(class_path="physicalai.robot.SO101") class SO101(Robot): """Driver for the SO-101 robot arm (6-DOF, Feetech STS3215 servos). diff --git a/src/physicalai/robot/trossen/bimanual_widowxai.py b/src/physicalai/robot/trossen/bimanual_widowxai.py index 1db88d0..d1e286e 100644 --- a/src/physicalai/robot/trossen/bimanual_widowxai.py +++ b/src/physicalai/robot/trossen/bimanual_widowxai.py @@ -23,6 +23,7 @@ import numpy as np +from physicalai.config import export_config from physicalai.robot import Robot if TYPE_CHECKING: @@ -58,6 +59,7 @@ def state(self) -> np.ndarray: return self.joint_positions +@export_config(class_path="physicalai.robot.BimanualWidowXAI") class BimanualWidowXAI(Robot): """Two-arm WidowX AI driver composing a left and right :class:`WidowXAI`. diff --git a/src/physicalai/robot/trossen/widowxai.py b/src/physicalai/robot/trossen/widowxai.py index ccb5e5c..75e14eb 100644 --- a/src/physicalai/robot/trossen/widowxai.py +++ b/src/physicalai/robot/trossen/widowxai.py @@ -42,6 +42,7 @@ ) raise ImportError(msg) from e +from physicalai.config import export_config from physicalai.robot import Robot from physicalai.robot.trossen.constants import HOME_POSITION, VALID_ROLES, WIDOWXAI_JOINT_ORDER @@ -77,6 +78,7 @@ def state(self) -> np.ndarray: return self.joint_positions +@export_config(class_path="physicalai.robot.WidowXAI") class WidowXAI(Robot): """Driver for the Trossen WidowX AI robot arm (7-DOF). diff --git a/tests/unit/config/test_component_config.py b/tests/unit/config/test_component_config.py index 9913103..e6a4d25 100644 --- a/tests/unit/config/test_component_config.py +++ b/tests/unit/config/test_component_config.py @@ -106,17 +106,6 @@ class InheritsDecorated(BaseWidget): """Subclass that inherits the decorated constructor unchanged.""" -class HookOnly: - def __init__(self, value: int) -> None: - self.value = value - - def __component_config__(self) -> dict[str, object]: - return { - "class_path": "tests.unit.config.test_component_config.HookOnly", - "init_args": {"value": self.value}, - } - - @export_config class WithExtras: def __init__(self, base: int, **kwargs: object) -> None: @@ -168,6 +157,62 @@ def __init__(self, x: int) -> None: raise ValueError(msg) +class DomainPayload: + """Domain value that encodes via ``to_config_value()`` (not a component).""" + + def __init__(self, amount: int) -> None: + self.amount = amount + + def to_config_value(self) -> dict[str, int]: + return {"amount": self.amount} + + +class BadNanDomain: + def to_config_value(self) -> dict[str, float]: + return {"x": math.nan} + + +class BadReservedDomain: + def to_config_value(self) -> dict[str, object]: + return {"class_path": "not.a.component", "other": 1} + + +class NullDomain: + def to_config_value(self) -> None: + return None + + +class CodecPeer: + """Domain value that can point at another domain value for cycle tests.""" + + def __init__(self) -> None: + self.other: object | None = None + + def to_config_value(self) -> object: + return self.other + + +@export_config +class DomainHolder: + def __init__(self, payload: object) -> None: + self.payload = payload + + +@export_config(class_path="tests.unit.config.test_component_config.ExportAlias") +class _HiddenExport: + """Defining-module class with a public re-export alias (see ExportAlias).""" + + def __init__(self, x: int) -> None: + self.x = x + + +ExportAlias = _HiddenExport + + +class InheritsAliasedExport(_HiddenExport): + """Inherits decorated constructor; must not inherit ``class_path=`` override.""" + + class TestImportDottedPath: def test_resolves_nested_class(self) -> None: assert import_dotted_path("tests.unit.config.test_component_config.Point") is Point @@ -325,7 +370,7 @@ def test_unimportable_class_path(self) -> None: def test_dict_with_class_path_is_reserved(self) -> None: holder = MappingHolder({"class_path": "not.a.component", "other": 1}) - with pytest.raises(ComponentConfigError, match="unexpected keys"): + with pytest.raises(ComponentConfigError, match="to_config_value"): to_config(holder) def test_unsupported_object_reports_path(self) -> None: @@ -415,17 +460,62 @@ def test_failed_constructor_does_not_capture(self) -> None: with pytest.raises(RuntimeError, match="nope"): Boom(1) - def test_hook_exportable(self) -> None: - obj = HookOnly(7) + def test_explicit_class_path_override(self) -> None: + obj = _HiddenExport(3) assert is_config_exportable(obj) config = to_config(obj) - assert config == { - "class_path": "tests.unit.config.test_component_config.HookOnly", - "init_args": {"value": 7}, - } + assert config["class_path"] == "tests.unit.config.test_component_config.ExportAlias" + assert config["init_args"] == {"x": 3} + restored = instantiate(config) + assert type(restored) is _HiddenExport + assert restored.x == 3 # type: ignore[union-attr] + + def test_inherited_class_path_override_does_not_leak(self) -> None: + obj = InheritsAliasedExport(9) + assert is_config_exportable(obj) + config = to_config(obj) + assert config["class_path"] == ( + "tests.unit.config.test_component_config.InheritsAliasedExport" + ) + assert config["init_args"] == {"x": 9} restored = instantiate(config) - assert isinstance(restored, HookOnly) - assert restored.value == 7 + assert type(restored) is InheritsAliasedExport + + def test_domain_value_hook_encodes_to_json(self) -> None: + holder = DomainHolder(DomainPayload(42)) + config = to_config(holder) + assert config["init_args"]["payload"] == {"amount": 42} + wire = json.loads(json.dumps(config)) + restored = cast(DomainHolder, instantiate(wire)) + assert restored.payload == {"amount": 42} + assert to_config(restored) == wire + + def test_domain_value_none_is_json_null(self) -> None: + holder = DomainHolder(NullDomain()) + config = to_config(holder) + assert config["init_args"]["payload"] is None + wire = json.loads(json.dumps(config)) + restored = cast(DomainHolder, instantiate(wire)) + assert restored.payload is None + + def test_domain_value_codec_cycle_raises(self) -> None: + left = CodecPeer() + right = CodecPeer() + left.other = right + right.other = left + holder = DomainHolder(left) + with pytest.raises(ComponentConfigError, match="cyclic to_config_value"): + to_config(holder) + + def test_domain_value_hook_output_is_renormalized(self) -> None: + holder = DomainHolder(BadNanDomain()) + with pytest.raises(ComponentConfigError, match="non-finite"): + to_config(holder) + + def test_domain_value_hook_reserved_class_path_validated(self) -> None: + holder = DomainHolder(BadReservedDomain()) + with pytest.raises(ComponentConfigError, match="to_config_value"): + to_config(holder) def test_instance_to_config_sugar(self) -> None: point = Point(1, 2) diff --git a/tests/unit/robot/test_robot_component_config.py b/tests/unit/robot/test_robot_component_config.py new file mode 100644 index 0000000..e20615b --- /dev/null +++ b/tests/unit/robot/test_robot_component_config.py @@ -0,0 +1,209 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +# ruff:file-ignore[undocumented-public-module, undocumented-public-class, undocumented-public-method, undocumented-public-init, assert, magic-value-comparison] + +"""Construction round-trips for robot ``@export_config`` wiring.""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Generator +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from physicalai.config import instantiate, is_config_exportable, to_config +from physicalai.robot import Robot + +# SO101 imports scservo_sdk at module load; keep unit tests hardware-free. +sys.modules.setdefault("scservo_sdk", MagicMock()) + +SAMPLE_CALIBRATION: dict[str, Any] = { + "shoulder_pan": {"id": 1, "drive_mode": 0, "homing_offset": 2048, "range_min": 707, "range_max": 3439}, + "shoulder_lift": {"id": 2, "drive_mode": 1, "homing_offset": 1024, "range_min": 669, "range_max": 3292}, + "elbow_flex": {"id": 3, "drive_mode": 0, "homing_offset": 2048, "range_min": 846, "range_max": 3069}, + "wrist_flex": {"id": 4, "drive_mode": 0, "homing_offset": 2048, "range_min": 956, "range_max": 3311}, + "wrist_roll": {"id": 5, "drive_mode": 0, "homing_offset": 2048, "range_min": 59, "range_max": 3946}, + "gripper": {"id": 6, "drive_mode": 0, "homing_offset": 2048, "range_min": 2026, "range_max": 3074}, +} + + +def _assert_construction_round_trip(robot: object) -> dict[str, Any]: + assert is_config_exportable(robot) + assert isinstance(robot, Robot) + config = to_config(robot) + wire: dict[str, Any] = json.loads(json.dumps(config)) + restored = instantiate(wire) + assert type(restored) is type(robot) + assert to_config(restored) == wire + assert not restored.is_connected() # type: ignore[union-attr] + return wire + + +# --------------------------------------------------------------------------- +# SO101 +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_scservo_sdk() -> Generator[MagicMock, None, None]: + sdk = MagicMock() + with patch.dict(sys.modules, {"scservo_sdk": sdk}): + # Ensure SO101 can be imported under the mock (idempotent if already loaded). + yield sdk + + +class TestSO101ComponentConfig: + def test_dict_calibration_round_trip(self, mock_scservo_sdk: MagicMock) -> None: + from physicalai.robot import SO101 + + robot = SO101(port="/dev/ttyUSB0", calibration=SAMPLE_CALIBRATION) + wire = _assert_construction_round_trip(robot) + assert wire["class_path"] == "physicalai.robot.SO101" + assert wire["init_args"] == { + "port": "/dev/ttyUSB0", + "calibration": SAMPLE_CALIBRATION, + } + + def test_calibration_object_encodes_to_dict(self, mock_scservo_sdk: MagicMock) -> None: + from physicalai.robot import SO101 + from physicalai.robot.so101 import SO101Calibration + + calibration = SO101Calibration.from_dict(SAMPLE_CALIBRATION) + robot = SO101(port="/dev/ttyACM0", calibration=calibration) + wire = _assert_construction_round_trip(robot) + assert wire["init_args"]["calibration"] == SAMPLE_CALIBRATION + + def test_path_calibration_keeps_relative_string( + self, mock_scservo_sdk: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + from physicalai.robot import SO101 + + calib_path = tmp_path / "calibration.json" + calib_path.write_text(json.dumps(SAMPLE_CALIBRATION), encoding="utf-8") + monkeypatch.chdir(tmp_path) + + robot = SO101(port="/dev/ttyUSB0", calibration="calibration.json") + wire = _assert_construction_round_trip(robot) + assert wire["init_args"]["calibration"] == "calibration.json" + + def test_path_object_keeps_relative_as_given( + self, mock_scservo_sdk: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + from physicalai.robot import SO101 + + calib_path = tmp_path / "calib.json" + calib_path.write_text(json.dumps(SAMPLE_CALIBRATION), encoding="utf-8") + monkeypatch.chdir(tmp_path) + + robot = SO101(port="/dev/ttyUSB0", calibration=Path("calib.json")) + wire = _assert_construction_round_trip(robot) + assert wire["init_args"]["calibration"] == "calib.json" + + def test_absolute_path_calibration_stays_absolute( + self, mock_scservo_sdk: MagicMock, tmp_path: Path + ) -> None: + from physicalai.robot import SO101 + + calib_path = (tmp_path / "absolute-calib.json").resolve() + calib_path.write_text(json.dumps(SAMPLE_CALIBRATION), encoding="utf-8") + + robot = SO101(port="/dev/ttyUSB0", calibration=calib_path) + wire = _assert_construction_round_trip(robot) + assert wire["init_args"]["calibration"] == str(calib_path) + assert Path(str(wire["init_args"]["calibration"])).is_absolute() + + def test_uncalibrated_retains_private_flag(self, mock_scservo_sdk: MagicMock) -> None: + from physicalai.robot import SO101 + + robot = SO101.uncalibrated(port="/dev/ttyUSB0") + wire = _assert_construction_round_trip(robot) + assert wire["init_args"]["calibration"] is None + assert wire["init_args"]["_allow_uncalibrated"] is True + assert wire["init_args"]["unit"] == "ticks" + + def test_protocol_still_holds(self, mock_scservo_sdk: MagicMock) -> None: + from physicalai.robot import SO101 + + robot = SO101(port="/dev/ttyUSB0", calibration=SAMPLE_CALIBRATION) + assert isinstance(robot, Robot) + assert robot.to_config() == to_config(robot) # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# WidowXAI / BimanualWidowXAI +# --------------------------------------------------------------------------- + + +def _make_mock_trossen_arm() -> MagicMock: + module = MagicMock() + driver = MagicMock() + driver.get_is_configured.return_value = True + module.TrossenArmDriver.return_value = driver + module.Model.wxai_v0 = MagicMock(name="Model.wxai_v0") + module.StandardEndEffector.wxai_v0_follower = MagicMock(name="wxai_v0_follower") + module.StandardEndEffector.wxai_v0_leader = MagicMock(name="wxai_v0_leader") + module.Mode.position = MagicMock(name="Mode.position") + module.Mode.external_effort = MagicMock(name="Mode.external_effort") + return module + + +@pytest.fixture +def mock_trossen_arm() -> Generator[MagicMock, None, None]: + """Inject a mock ``trossen_arm`` so WidowX drivers import without the SDK.""" + mock_module = _make_mock_trossen_arm() + # Drop cached modules so import picks up the mock even if a prior import failed. + for name in ( + "physicalai.robot.trossen.widowxai", + "physicalai.robot.trossen.bimanual_widowxai", + "physicalai.robot.trossen", + ): + sys.modules.pop(name, None) + with patch.dict(sys.modules, {"trossen_arm": mock_module}): + yield mock_module + + +class TestWidowXAIComponentConfig: + def test_default_role_omitted(self, mock_trossen_arm: MagicMock) -> None: + from physicalai.robot import WidowXAI + + robot = WidowXAI(ip="192.168.1.2") + wire = _assert_construction_round_trip(robot) + assert wire["class_path"] == "physicalai.robot.WidowXAI" + assert wire["init_args"] == {"ip": "192.168.1.2"} + + def test_explicit_role_round_trip(self, mock_trossen_arm: MagicMock) -> None: + from physicalai.robot import WidowXAI + + robot = WidowXAI(ip="10.0.0.1", role="leader") + wire = _assert_construction_round_trip(robot) + assert wire["init_args"] == {"ip": "10.0.0.1", "role": "leader"} + + def test_protocol_still_holds(self, mock_trossen_arm: MagicMock) -> None: + from physicalai.robot import WidowXAI + + robot = WidowXAI(ip="192.168.1.2") + assert isinstance(robot, Robot) + assert robot.to_config() == to_config(robot) # type: ignore[attr-defined] + + +class TestBimanualWidowXAIComponentConfig: + def test_nested_arms_round_trip(self, mock_trossen_arm: MagicMock) -> None: + from physicalai.robot import BimanualWidowXAI, WidowXAI + + left = WidowXAI(ip="192.168.1.10", role="follower") + right = WidowXAI(ip="192.168.1.11", role="follower") + robot = BimanualWidowXAI(left=left, right=right) + wire = _assert_construction_round_trip(robot) + assert wire["class_path"] == "physicalai.robot.BimanualWidowXAI" + left_cfg = wire["init_args"]["left"] + right_cfg = wire["init_args"]["right"] + assert isinstance(left_cfg, dict) + assert isinstance(right_cfg, dict) + assert left_cfg["class_path"] == "physicalai.robot.WidowXAI" + assert right_cfg["class_path"] == "physicalai.robot.WidowXAI" + assert left_cfg["init_args"] == {"ip": "192.168.1.10", "role": "follower"} + assert right_cfg["init_args"] == {"ip": "192.168.1.11", "role": "follower"} From b3bae6d4299dc7ea5e507d8b491a7fd902691f65 Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Thu, 23 Jul 2026 08:42:13 +0200 Subject: [PATCH 08/16] step 4 --- docs/development/component-config-report.md | 20 +- docs/development/component-config.md | 77 +++---- examples/so101/serve.yaml | 11 +- src/physicalai/cli/robot.py | 52 ++++- src/physicalai/config/__init__.py | 13 ++ src/physicalai/config/_export.py | 22 +- .../robot/transport/_owner_config.py | 177 ++++++++++----- .../robot/transport/_owner_worker.py | 16 +- .../robot/transport/_shared_robot.py | 153 ++++++++++--- tests/unit/cli/test_robot_cli.py | 54 ++++- tests/unit/robot/transport/fake.py | 3 + tests/unit/robot/transport/test_latency.py | 6 +- .../unit/robot/transport/test_owner_config.py | 208 ++++++++++++++---- .../robot/transport/test_owner_handshake.py | 16 +- .../unit/robot/transport/test_owner_worker.py | 38 +++- .../unit/robot/transport/test_shared_robot.py | 72 +++++- 16 files changed, 723 insertions(+), 215 deletions(-) diff --git a/docs/development/component-config-report.md b/docs/development/component-config-report.md index 93c838b..983e6e2 100644 --- a/docs/development/component-config-report.md +++ b/docs/development/component-config-report.md @@ -216,16 +216,18 @@ flowchart TB `service_name` lives **beside** `camera: ComponentConfig`, never inside `init_args`. -### Public API (adapters) +### Public API -| Surface | Accept (XOR) | Preferred | -| ---------------------- | ------------------------------------------------------- | ----------------------------- | -| `SharedRobot` | `robot=` **xor** `robot_class`+`robot_kwargs` (adapter) | `from_config` / `from_robot` | -| `SharedCamera` | `camera=` **xor** `camera_type`+kwargs (adapter) | `from_config` / `from_camera` | -| CLI `physicalai robot` | `--robot` **xor** legacy flags (adapter) | `--robot` | -| Metadata | Keep key `robot_class` | Value = public `class_path` | +| Surface | Accept | Preferred | +| ---------------------- | ------------------------------------------------ | ----------------------------- | +| `SharedRobot` | `robot=` ComponentConfig (or `None` / `attach`) | `from_config` / `from_robot` | +| `SharedCamera` | `camera=` **xor** `camera_type`+kwargs (adapter) | `from_config` / `from_camera` | +| CLI `physicalai robot` | `--robot` ComponentConfig (required) | `--robot` | +| Metadata | Keep key `robot_class` | Value = public `class_path` | -Legacy flat forms always pack `ComponentConfig` and write only the new stdin. Removing adapters is a later cleanup PR. +Flat `robot_class` / `robot_kwargs` on SharedRobot, serve CLI, and owner stdin +are unsupported (rejected on stdin; removed from the public API). Camera +still keeps a temporary XOR adapter until its cutover step. `from_robot` / `from_camera`: require exportable, **reject if connected**, never disconnect implicitly. @@ -327,7 +329,7 @@ gantt | 1 | `ComponentConfig`, instantiate, depth/cycles, shared importer (no inference behavior change) | | 2 | `@export_config`, `to_config`, `is_config_exportable` | | 3 | SO101, WidowXAI, BimanualWidowXAI round-trips | -| 4 | SharedRobot + owner stdin hard cutover + public/CLI adapters (cwd inherit for relatives) | +| 4 | SharedRobot + owner stdin + public/CLI ComponentConfig-only (cwd inherit for relatives) | | 5 | SharedCamera + publisher stdin hard cutover + `service_name` rules | | 6 | PolicySource graph, TeleopSource, path-rooted InferenceModel, v1 callbacks | | 7 | RobotRuntime -> `instantiate` + jsonargparse | diff --git a/docs/development/component-config.md b/docs/development/component-config.md index 14be1c1..3f9cd76 100644 --- a/docs/development/component-config.md +++ b/docs/development/component-config.md @@ -629,7 +629,7 @@ add a `config_format` field, dual-read, or shape-detection fallback. ``` Writers and readers speak only the new shape. Reject payloads that still carry -legacy flat keys (`robot_class` / `robot_kwargs`, or `camera_type` / +unsupported flat keys (`robot_class` / `robot_kwargs`, or `camera_type` / `camera_kwargs`) before import or hardware access — no silent translation. Do not reuse `capture.transport.PROTOCOL_VERSION` or @@ -671,31 +671,30 @@ Camera URL/stream fields are not filesystem paths. The built-in camera ### Public SharedRobot, CLI, and metadata -Private stdin hard cutover does not force a public API break. Keep legacy flat -kwargs as **adapters** that pack `ComponentConfig` and write only the new -stdin. Removing those adapters is a later cleanup PR, not a calendar dual-read -window on the private wire. - -- **`SharedRobot` constructor:** accept `robot: ComponentConfig` **XOR** - legacy `robot_class` + `robot_kwargs`. Passing both is an error — no merge. - Legacy flat kwargs are an adapter only; they immediately become - `robot: {class_path, init_args}` before spawn. New code and docs prefer - `SharedRobot.from_config(...)` and `from_robot(...)`. -- **Public path normalization:** when accepting a class object or a legacy - dotted path, normalize through the same public-path resolution used by - `to_config` (decorator `class_path=` override, else - `__module__.__qualname__`) **before** store, metadata advertise, and conflict - compare. This prevents false mismatches between defining-module paths (for - example `physicalai.robot.so101.so101.SO101`) and public re-exports - (`physicalai.robot.SO101`). -- **CLI `physicalai robot`:** accept `--robot` (ComponentConfig JSON/YAML) - **XOR** legacy `--robot_class` / `--robot_kwargs`; both paths write only the - new stdin shape. -- **Network metadata:** do not rename the advertised key. Keep `robot_class` as - the metadata field so `ROBOT_TRANSPORT_PROTOCOL_VERSION` stays unchanged. - Populate it from the normalized public `robot["class_path"]`. Conflict - checks compare that string unchanged. Attach-only / discover paths are - unchanged. +Public construction matches private stdin: `robot` / `--robot` only. +Flat `robot_class` / `robot_kwargs` on the public API and CLI are +removed; those keys on owner stdin remain unsupported and are rejected +before import. + +- **`SharedRobot` constructor:** accept `robot: ComponentConfig` to spawn, + or `robot=None` for attach-only (prefer :meth:`SharedRobot.attach`). + Prefer `SharedRobot.from_config(...)` and `from_robot(...)` when + building from a trusted config or an exportable live driver. +- **Public path normalization:** when accepting a class object or dotted + path inside `robot.class_path`, normalize through the same public-path + resolution used by `to_config` (decorator `class_path=` override, else + `__module__.__qualname__`) **before** store, metadata advertise, and + conflict compare. This prevents false mismatches between defining-module + paths (for example `physicalai.robot.so101.so101.SO101`) and public + re-exports (`physicalai.robot.SO101`). +- **CLI `physicalai robot serve`:** require `--robot` (ComponentConfig + JSON/YAML with `class_path` + `init_args`). Write only the new stdin + shape. +- **Network metadata:** do not rename the advertised key. Keep `robot_class` + as the metadata field so `ROBOT_TRANSPORT_PROTOCOL_VERSION` stays + unchanged. Populate it from the normalized public `robot["class_path"]`. + Conflict checks compare that string unchanged. Attach-only / discover + paths are unchanged. ### Public SharedCamera @@ -851,12 +850,13 @@ specific subclasses only where tests or callers distinguish phases. composite round-trips); add JSON and construction round-trip tests. 4. Add `SharedRobot.from_config()` / `from_robot()`; hard-cutover `RobotOwnerConfig` stdin to `robot: ComponentConfig` (rewrite writers, - readers, fixtures; no `config_format`, no dual-read). Keep public ctor and - CLI flat kwargs as adapters that always write the new stdin (XOR mutual - exclusion with `robot=` / `--robot`). Normalize legacy class paths to - public re-exports before store/advertise/compare; advertise metadata - `robot_class` from that public `class_path`. Relative path args rely on - parent cwd inheritance at `Popen` (no path-absolutizing helper). + readers, fixtures; no `config_format`, no dual-read). Public ctor and + CLI accept only `robot=` / `--robot` (plus `from_config` / `from_robot`); + flat `robot_class` / `robot_kwargs` are unsupported on public API, CLI, + and stdin. Normalize class paths to public re-exports before + store/advertise/compare; advertise metadata `robot_class` from that public + `class_path`. Relative path args rely on parent cwd inheritance at `Popen` + (no path-absolutizing helper). 5. Wire camera implementations, then hard-cutover the camera publisher stdin to `camera: ComponentConfig` with `service_name` beside it (same PR: rewrite fixtures; no dual-read). Add `SharedCamera.from_config()` / @@ -920,7 +920,7 @@ cutovers; do not describe them as schema-preserving. silent drop). - Robot owner and camera publisher subprocess handshakes remain JSON-only, accept only the new `robot:` / `camera: ComponentConfig` shape, and reject - legacy flat stdin (`robot_class` / `camera_type` forms) before import. + unsupported flat stdin (`robot_class` / `camera_type` forms) before import. - Built-in SharedCamera spawn derives the legacy `service_name` via the transport class-path → type-token map inside `from_config` / the adapter ctor; third-party spawn without explicit `service_name` fails before @@ -932,11 +932,12 @@ cutovers; do not describe them as schema-preserving. `camera` and legacy `camera_type` + kwargs. - Third-party camera class paths survive the publisher envelope and bypass the built-in camera registry. -- Public `SharedRobot` ctor and `physicalai robot` CLI accept legacy flat - kwargs as adapters **XOR** ComponentConfig; both paths write only the new - stdin; legacy defining-module paths normalize to public re-exports before - store/advertise/compare; metadata `robot_class` equals that public - `robot["class_path"]`. +- Public `SharedRobot` ctor and `physicalai robot serve` accept only + `robot=` / `--robot` ComponentConfig (plus `from_config` / `from_robot`); + flat `robot_class` / `robot_kwargs` are rejected as unsupported on stdin + and are not a public dual API; defining-module paths normalize to public + re-exports before store/advertise/compare; metadata `robot_class` equals + that public `robot["class_path"]`. - `SharedRobot.from_robot()` is sugar over `from_config(to_config(...))`, requires exportability, and rejects connected drivers before owner spawn. - Composite bimanual robots spawn as one owner; nested arm configs round-trip diff --git a/examples/so101/serve.yaml b/examples/so101/serve.yaml index 31100d9..2b93aad 100644 --- a/examples/so101/serve.yaml +++ b/examples/so101/serve.yaml @@ -5,10 +5,11 @@ # host B walkthrough (serve on the host with the physical robot attached, # attach from anywhere else with a `SharedRobot` runtime config). name: follower-arm -robot_class: physicalai.robot.SO101 -robot_kwargs: - port: /dev/ttyACM0 - calibration: /path/to/so101-calibration.json - role: follower +robot: + class_path: physicalai.robot.SO101 + init_args: + port: /dev/ttyACM0 + calibration: /path/to/so101-calibration.json + role: follower allow_remote: false rate_hz: 100.0 diff --git a/src/physicalai/cli/robot.py b/src/physicalai/cli/robot.py index b07ba97..58220c4 100644 --- a/src/physicalai/cli/robot.py +++ b/src/physicalai/cli/robot.py @@ -17,6 +17,7 @@ from loguru import logger from physicalai.cli._spec import SubcommandSpec # noqa: PLC2701 +from physicalai.config import ComponentConfigError from physicalai.robot.errors import RobotTransportError from physicalai.robot.transport import ( DEFAULT_RATE_HZ, @@ -55,12 +56,11 @@ def _build_serve_parser() -> ArgumentParser: parser = ArgumentParser(description=_SERVE_HELP) parser.add_argument("--config", action=ActionConfigFile, help="YAML/JSON config file.") parser.add_argument("--name", type=str, required=True, help="Robot logical name.") - parser.add_argument("--robot_class", type=str, required=True, help="Trusted dotted path to the driver class.") parser.add_argument( - "--robot_kwargs", + "--robot", type=dict, - default=None, - help="JSON-serializable driver constructor arguments.", + required=True, + help="Trusted robot ComponentConfig (class_path + init_args).", ) parser.add_argument( "--allow_remote", @@ -133,6 +133,45 @@ def _log_serve_start(config: RobotOwnerConfig) -> None: logger.info(f"Starting robot {config.name!r} using {config.robot_class} [{mode_tag}]") +def _mapping_from_cfg(value: object) -> dict[str, object]: + """Convert a jsonargparse dict/Namespace value to a plain dict. + + Returns: + A shallow ``dict`` copy of *value*. + + Raises: + TypeError: If *value* is not ``None``, a ``dict``, or a Namespace-like + object with ``as_dict()``. + """ + if value is None: + return {} + if isinstance(value, dict): + return dict(value) + as_dict = getattr(value, "as_dict", None) + if callable(as_dict): + converted = as_dict() + if isinstance(converted, dict): + return dict(converted) + msg = f"expected a mapping, got {type(value).__name__}" + raise TypeError(msg) + + +def _robot_config_from_serve_cfg(cfg: Namespace) -> dict[str, object]: + """Require ``--robot`` ComponentConfig for foreground serve. + + Returns: + A ComponentConfig mapping for :class:`RobotOwnerConfig`. + + Raises: + ValueError: If ``--robot`` is missing. + """ + robot = getattr(cfg, "robot", None) + if robot is None: + msg = "robot serve requires --robot ComponentConfig (class_path + init_args)" + raise ValueError(msg) + return _mapping_from_cfg(robot) + + def _format_duration(seconds: float) -> str: """Format elapsed seconds as ``HH:MM:SS``. @@ -174,13 +213,12 @@ def serve(cfg: Namespace) -> int: try: config = RobotOwnerConfig( name=cfg.name, - robot_class=cfg.robot_class, - robot_kwargs=dict(cfg.robot_kwargs or {}), + robot=_robot_config_from_serve_cfg(cfg), allow_remote=cfg.allow_remote, rate_hz=cfg.rate_hz, idle_timeout=None, ) - except (TypeError, ValueError) as exc: + except (TypeError, ValueError, ComponentConfigError) as exc: logger.error(f"Invalid robot configuration: {exc}") return 1 diff --git a/src/physicalai/config/__init__.py b/src/physicalai/config/__init__.py index 15b6af4..57b247b 100644 --- a/src/physicalai/config/__init__.py +++ b/src/physicalai/config/__init__.py @@ -13,6 +13,13 @@ - Domain ctor args may implement :meth:`~ConfigValue.to_config_value` to return a JSON-compatible fragment (re-normalized). +Also exported for transport and other callers (no private ``physicalai.config._*`` +imports): + +- :func:`resolve_public_class_path` — decorator ``class_path=`` override or + ``__module__.__qualname__``, verified to import back to the class. +- :func:`validate_component_config` — shape-check ``class_path`` + ``init_args``. + Studio needs only :func:`is_config_exportable` and :func:`to_config`, then domain ``from_config`` helpers (for example ``SharedRobot.from_config``). @@ -30,8 +37,10 @@ from ._errors import ComponentImportError as ComponentImportError from ._export import export_config as export_config from ._export import is_config_exportable as is_config_exportable + from ._export import resolve_public_class_path as resolve_public_class_path from ._export import to_config as to_config from ._instantiate import instantiate as instantiate + from ._normalize import validate_component_config as validate_component_config from ._types import ComponentConfig as ComponentConfig from ._types import ConfigValue as ConfigValue from ._types import JsonScalar as JsonScalar @@ -49,7 +58,9 @@ "import_dotted_path", "instantiate", "is_config_exportable", + "resolve_public_class_path", "to_config", + "validate_component_config", ] _LAZY_ATTRS: dict[str, tuple[str, str]] = { @@ -62,8 +73,10 @@ "import_dotted_path": (".importing", "import_dotted_path"), "export_config": ("._export", "export_config"), "is_config_exportable": ("._export", "is_config_exportable"), + "resolve_public_class_path": ("._export", "resolve_public_class_path"), "to_config": ("._export", "to_config"), "instantiate": ("._instantiate", "instantiate"), + "validate_component_config": ("._normalize", "validate_component_config"), } diff --git a/src/physicalai/config/_export.py b/src/physicalai/config/_export.py index 9cc8715..632a8a5 100644 --- a/src/physicalai/config/_export.py +++ b/src/physicalai/config/_export.py @@ -81,7 +81,23 @@ def _class_path_override(cls: type) -> str | None: return None -def _resolve_public_class_path(cls: type) -> str: +def resolve_public_class_path(cls: type) -> str: + """Resolve the stable public ``class_path`` for an opted-in component class. + + Uses the decorator ``class_path=`` override when present, otherwise + ``cls.__module__ + "." + cls.__qualname__``. Verifies that importing the + path yields exactly *cls*. + + Args: + cls: The concrete class to resolve. + + Returns: + The importable public dotted path. + + Raises: + ComponentConfigError: If *cls* is local, the path is not importable, or + the path resolves to a different object. + """ path = _class_path_override(cls) or f"{cls.__module__}.{cls.__qualname__}" if "" in cls.__qualname__: @@ -99,6 +115,10 @@ def _resolve_public_class_path(cls: type) -> str: return path +# Private alias kept for call sites that predate the public name. +_resolve_public_class_path = resolve_public_class_path + + def _component_path_prefix(value: object) -> str: cls = type(value) return _class_path_override(cls) or f"{cls.__module__}.{cls.__qualname__}" diff --git a/src/physicalai/robot/transport/_owner_config.py b/src/physicalai/robot/transport/_owner_config.py index 88643d1..82abf1e 100644 --- a/src/physicalai/robot/transport/_owner_config.py +++ b/src/physicalai/robot/transport/_owner_config.py @@ -9,12 +9,17 @@ subprocess stdin handshake. The owner must construct the robot driver itself: a live serial/socket -handle cannot cross a process boundary (D15). Only an importable -``robot_class`` plus JSON-serializable ``robot_kwargs`` survive that -boundary — arbitrary robot types, including third-party plugins, work -without any registry lookup here. +handle cannot cross a process boundary (D15). Only a trusted +:class:`~physicalai.config.ComponentConfig` (``class_path`` + ``init_args``) +survives that boundary — arbitrary robot types, including third-party +plugins, work without any registry lookup here. -Security: *robot_class* is trusted local application/config input, exactly +Private stdin is ``robot: ComponentConfig`` only. Flat keys +(``robot_class`` / ``robot_kwargs``) are unsupported and rejected before +import or hardware access. Public ``SharedRobot`` and ``physicalai robot +serve`` use the same ``robot`` / ``--robot`` shape. + +Security: ``class_path`` is trusted local application/config input, exactly like a jsonargparse ``class_path`` (``docs/development/security.md`` rules 4, 9, 11). It must never originate from network-received data (e.g. a ``/metadata`` payload) — that would let an untrusted peer choose an @@ -25,12 +30,21 @@ import json import math -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import TYPE_CHECKING, Any -from ._importing import import_dotted_path +from physicalai.config import ( + ComponentConfig, + ComponentConfigError, + instantiate, + resolve_public_class_path, + validate_component_config, +) +from physicalai.config.importing import import_dotted_path if TYPE_CHECKING: + from collections.abc import Mapping + from physicalai.robot.interface import Robot DEFAULT_RATE_HZ = 100.0 @@ -42,38 +56,73 @@ justify a different value for a specific robot class. """ +_UNSUPPORTED_FLAT_STDIN_KEYS = ("robot_class", "robot_kwargs") + def normalize_robot_class(robot_class: type | str) -> str: - """Normalize a robot class reference to an importable dotted path. + """Normalize a robot class reference to its public import path. - An explicit string path is trusted and returned unchanged. A class - object is converted via ``cls.__module__ + "." + cls.__qualname__``, - because Python does not retain the re-export path through which a - class happened to be imported by the caller. + Matches :func:`physicalai.config.to_config` path selection: decorator + ``class_path=`` override when present, otherwise + ``__module__.__qualname__``. String paths are imported first so a + defining-module path (for example ``physicalai.robot.so101.so101.SO101``) + becomes the public re-export (``physicalai.robot.SO101``) before store, + metadata advertise, and conflict compare. Args: robot_class: A class object, or its dotted import path as a string. Returns: - The normalized dotted path. + The normalized public dotted path. Raises: - TypeError: If *robot_class* is neither a string nor a class. - ValueError: If a class object is a local class (defined inside a - function) — those have no stable import path and cannot be - reconstructed in the owner subprocess. + TypeError: If *robot_class* is neither a string nor a class, or a + string path does not resolve to a class. + ValueError: If a class object is a local class, or the public path + cannot be resolved. """ if isinstance(robot_class, str): - return robot_class + try: + resolved = import_dotted_path(robot_class) + except (ValueError, ImportError, AttributeError) as exc: + msg = f"could not import robot_class {robot_class!r}: {exc}" + raise ValueError(msg) from exc + if not isinstance(resolved, type): + msg = f"robot_class {robot_class!r} does not resolve to a class (got {type(resolved).__name__})" + raise TypeError(msg) + robot_class = resolved if not isinstance(robot_class, type): msg = f"robot_class must be a class or a dotted path string, got {type(robot_class).__name__}" raise TypeError(msg) - path = f"{robot_class.__module__}.{robot_class.__qualname__}" - if "" in robot_class.__qualname__: - msg = f"robot_class {path!r} is a local class and cannot be auto-spawned; give it a module-level definition" - raise ValueError(msg) - return path + try: + return resolve_public_class_path(robot_class) + except ComponentConfigError as exc: + raise ValueError(str(exc)) from exc + + +def normalize_robot_config(robot: Mapping[str, object]) -> ComponentConfig: + """Validate a robot :class:`~physicalai.config.ComponentConfig` and normalize ``class_path``. + + Args: + robot: Candidate ``class_path`` + ``init_args`` mapping. + + Returns: + A validated config whose ``class_path`` is the public import path. + + Raises: + ValueError: If ``class_path`` cannot be imported or is not JSON-safe + together with ``init_args``. + """ + validated = validate_component_config(dict(robot), path="robot") + class_path = normalize_robot_class(validated["class_path"]) + init_args = validated["init_args"] + try: + json.dumps({"class_path": class_path, "init_args": init_args}) + except TypeError as exc: + msg = f"robot.init_args must be JSON-serializable (e.g. paths as str, not objects): {exc}" + raise ValueError(msg) from exc + return {"class_path": class_path, "init_args": dict(init_args)} @dataclass(frozen=True) @@ -88,9 +137,7 @@ class RobotOwnerConfig: Attributes: name: The robot's logical name (keys the Zenoh topics). - robot_class: Normalized importable dotted path to the driver class. - robot_kwargs: JSON-serializable keyword arguments forwarded to the - driver constructor (e.g. ``calibration`` as a file path). + robot: Trusted construction config (``class_path`` + ``init_args``). allow_remote: Whether the owner's Zenoh session is reachable beyond localhost. Fixed for the owner's lifetime once spawned. ``True`` exposes an unauthenticated physical ``/action`` endpoint @@ -101,18 +148,17 @@ class RobotOwnerConfig: """ name: str - robot_class: str - robot_kwargs: dict[str, Any] = field(default_factory=dict) + robot: Mapping[str, object] allow_remote: bool = False rate_hz: float = DEFAULT_RATE_HZ idle_timeout: float | None = 10.0 def __post_init__(self) -> None: - """Validate ``rate_hz`` and that ``robot_kwargs`` is JSON-serializable. + """Validate transport fields and normalize ``robot`` to a public ComponentConfig. Raises: - ValueError: If ``rate_hz`` is not finite and positive, or - ``robot_kwargs`` contains non-JSON-serializable values. + ValueError: If ``rate_hz`` / ``idle_timeout`` are invalid, or + ``robot`` is not a JSON-serializable ComponentConfig. """ if ( isinstance(self.rate_hz, bool) @@ -133,25 +179,32 @@ def __post_init__(self) -> None: from ._ids import validate_name # noqa: PLC0415 validate_name(self.name) - if not isinstance(self.robot_class, str) or not self.robot_class.strip() or "." not in self.robot_class: - msg = f"robot_class must be a nonempty dotted path, got {self.robot_class!r}" - raise ValueError(msg) - try: - json.dumps(self.robot_kwargs) - except TypeError as exc: - msg = f"robot_kwargs must be JSON-serializable (e.g. paths as str, not objects): {exc}" - raise ValueError(msg) from exc + object.__setattr__(self, "robot", normalize_robot_config(self.robot)) + + @property + def robot_class(self) -> str: + """Public ``class_path`` advertised on network metadata as ``robot_class``.""" + return str(self.robot["class_path"]) def to_json_dict(self) -> dict[str, Any]: """Serialize to a JSON-safe dictionary for the stdin handshake. Returns: - Dictionary with every dataclass field. + Dictionary with every dataclass field (new ``robot:`` shape only). + + Raises: + TypeError: If ``robot.init_args`` is not a mapping. """ + init_args = self.robot["init_args"] + if not isinstance(init_args, dict): + msg = f"robot.init_args must be a mapping, got {type(init_args).__name__}" + raise TypeError(msg) return { "name": self.name, - "robot_class": self.robot_class, - "robot_kwargs": self.robot_kwargs, + "robot": { + "class_path": self.robot["class_path"], + "init_args": dict(init_args), + }, "allow_remote": self.allow_remote, "rate_hz": self.rate_hz, "idle_timeout": self.idle_timeout, @@ -161,16 +214,35 @@ def to_json_dict(self) -> dict[str, Any]: def from_json_dict(cls, data: dict[str, Any]) -> RobotOwnerConfig: """Deserialize from a JSON dictionary. + Rejects unsupported flat stdin keys (``robot_class`` / ``robot_kwargs``) + before any import or hardware access. + Args: data: Dictionary produced by :meth:`to_json_dict`. Returns: A new :class:`RobotOwnerConfig` instance. + + Raises: + ValueError: If flat keys are present or ``robot`` is missing / + invalid. + TypeError: If ``data`` is not a mapping. """ + if not isinstance(data, dict): + msg = f"owner config must be a mapping, got {type(data).__name__}" + raise TypeError(msg) + + unsupported = [key for key in _UNSUPPORTED_FLAT_STDIN_KEYS if key in data] + if unsupported: + msg = f"unsupported owner stdin keys {unsupported}; require 'robot' with class_path + init_args" + raise ValueError(msg) + if "robot" not in data: + msg = "owner config missing required 'robot' ComponentConfig" + raise ValueError(msg) + return cls( name=data["name"], - robot_class=data["robot_class"], - robot_kwargs=data.get("robot_kwargs", {}), + robot=data["robot"], allow_remote=data.get("allow_remote", False), rate_hz=data.get("rate_hz", DEFAULT_RATE_HZ), idle_timeout=data.get("idle_timeout", 10.0), @@ -179,14 +251,21 @@ def from_json_dict(cls, data: dict[str, Any]) -> RobotOwnerConfig: def build(self) -> Robot: """Instantiate the robot driver described by this config. + Owner stdin is a trusted parent→child handshake only — never pass + network metadata to this path. Uses :func:`physicalai.config.instantiate` + on the trusted ``robot`` ComponentConfig, then verifies the + :class:`~physicalai.robot.Robot` protocol. + Returns: A new, not-yet-connected driver instance. Raises: - TypeError: If ``robot_class`` does not resolve to a class. + TypeError: If the instantiated object does not satisfy ``Robot``. """ - cls = import_dotted_path(self.robot_class) - if not isinstance(cls, type): - msg = f"robot_class {self.robot_class!r} does not resolve to a class (got {type(cls).__name__})" + from physicalai.robot.interface import Robot # noqa: PLC0415 + + driver = instantiate(self.robot) # type: ignore[arg-type] + if not isinstance(driver, Robot): + msg = f"{self.robot_class!r} does not satisfy the Robot protocol (got {type(driver).__name__})" raise TypeError(msg) - return cls(**self.robot_kwargs) + return driver diff --git a/src/physicalai/robot/transport/_owner_worker.py b/src/physicalai/robot/transport/_owner_worker.py index fed690c..69ff653 100644 --- a/src/physicalai/robot/transport/_owner_worker.py +++ b/src/physicalai/robot/transport/_owner_worker.py @@ -34,6 +34,7 @@ from loguru import logger +from physicalai.config import ComponentConfigError from physicalai.robot.errors import RobotTransportError from physicalai.robot.interface import Robot from physicalai.robot.transport._codec import ( # noqa: PLC2701 @@ -168,7 +169,7 @@ def _build_metadata( """Assemble the ``/metadata`` record advertised by the queryable. Args: - config: Worker config (for name / robot_class). + config: Worker config (for name / public ``robot.class_path``). driver: Connected driver (for joint names). device_ids: This owner's sorted, deduplicated device ids. Omitted from advertised metadata when remote transport is enabled. @@ -183,7 +184,9 @@ def _build_metadata( metadata: dict[str, Any] = { "protocol_version": ROBOT_TRANSPORT_PROTOCOL_VERSION, "name": config.name, - "robot_class": config.robot_class, + # Network key stays ``robot_class`` (protocol version unchanged); value is + # the normalized public ComponentConfig class_path. + "robot_class": config.robot["class_path"], "host": default_host(), "joint_names": joint_names, "num_joints": len(joint_names), @@ -436,15 +439,16 @@ def _startup(config: RobotOwnerConfig) -> _Endpoints: _StartupError: Naming the failure phase, for the caller to report via :func:`signal_error`. """ + class_path = config.robot["class_path"] try: - logger.trace(f"Constructing robot driver {config.robot_class!r}") + logger.trace(f"Constructing robot driver {class_path!r}") driver = config.build() except Exception as exc: - msg = f"failed to construct {config.robot_class!r}: {exc}" + msg = f"failed to construct {class_path!r}: {exc}" raise _StartupError(msg, phase="construction_failed") from exc if not isinstance(driver, Robot): - msg = f"{config.robot_class!r} does not satisfy the Robot protocol" + msg = f"{class_path!r} does not satisfy the Robot protocol" raise _StartupError(msg, phase="construction_failed") device_ids = tuple(sorted(set(driver.device_ids))) @@ -558,7 +562,7 @@ def main() -> int: sys.stdin.close() try: config = RobotOwnerConfig.from_json_dict(json.loads(raw)) - except (json.JSONDecodeError, ValueError, KeyError, TypeError) as exc: + except (json.JSONDecodeError, ValueError, KeyError, TypeError, ComponentConfigError) as exc: signal_error(f"invalid worker config: {exc}", phase="invalid_config") return 1 diff --git a/src/physicalai/robot/transport/_shared_robot.py b/src/physicalai/robot/transport/_shared_robot.py index 50c3e39..a903419 100644 --- a/src/physicalai/robot/transport/_shared_robot.py +++ b/src/physicalai/robot/transport/_shared_robot.py @@ -9,6 +9,10 @@ that finds no existing owner spawns one; later instances (for the same *name*, anywhere reachable) attach. +Construction is :class:`~physicalai.config.ComponentConfig`-only via +``robot=``, :meth:`from_config`, or :meth:`from_robot`. Pass ``robot=None`` +(or use :meth:`attach`) for attach-only. + Unlike the superseded connection-derived ``robot_id``, *name* is a required, caller-chosen logical identifier — routing never needs a live driver instance to resolve. Physical device identity @@ -40,7 +44,7 @@ from ._codec import ROBOT_TRANSPORT_PROTOCOL_VERSION, TransportObservation, decode_metadata, decode_state, encode_action from ._ids import KEY_PREFIX, METADATA_WILDCARD, action_key, metadata_key, state_key, validate_name from ._lock import active_owner_device_ids, registered_owner_names -from ._owner_config import DEFAULT_RATE_HZ, normalize_robot_class +from ._owner_config import DEFAULT_RATE_HZ, normalize_robot_config from ._session import open_session if TYPE_CHECKING: @@ -48,7 +52,8 @@ import numpy as np - from physicalai.robot import RobotObservation + from physicalai.config import ComponentConfig + from physicalai.robot import Robot, RobotObservation _PROBE_TIMEOUT = 1.0 _RACE_RETRY_TIMEOUT = 5.0 @@ -177,17 +182,17 @@ class SharedRobot: Zenoh. Satisfies the :class:`~physicalai.robot.Robot` protocol, so it is a drop-in replacement for a direct driver. + Prefer :meth:`from_config` or :meth:`from_robot`. The constructor takes + ``robot: ComponentConfig`` to spawn, or ``robot=None`` (attach-only; + :meth:`attach` is the explicit form). + Args: name: Required logical name — keys the Zenoh topics directly. Two instances constructed with the same *name* (anywhere reachable under the chosen transport scope) share one owner. - robot_class: Driver class (or its dotted import path) to spawn if - no owner exists yet for *name*. ``None`` means attach-only — - use :meth:`attach` for that case instead of passing this - directly. - robot_kwargs: JSON-serializable driver constructor kwargs (e.g. - ``port``, ``calibration`` as a path, ``role`` as a str), used - only when this instance spawns the owner. + robot: Trusted driver :class:`~physicalai.config.ComponentConfig` to + spawn if no owner exists yet for *name*. ``None`` means + attach-only — use :meth:`attach` for that case. allow_remote: Whether this instance's own session — and, if it spawns the owner, the owner's session for its whole lifetime — is reachable beyond localhost. Defaults to the secure, @@ -203,8 +208,7 @@ def __init__( self, name: str, *, - robot_class: type | str | None = None, - robot_kwargs: Mapping[str, object] | None = None, + robot: ComponentConfig | Mapping[str, object] | None = None, allow_remote: bool = False, rate_hz: float = DEFAULT_RATE_HZ, idle_timeout: float = 10.0, @@ -212,8 +216,7 @@ def __init__( _session: object | None = None, ) -> None: self._name = validate_name(name) - self._robot_class = normalize_robot_class(robot_class) if robot_class is not None else None - self._robot_kwargs: dict[str, object] = dict(robot_kwargs or {}) + self._robot = None if robot is None else normalize_robot_config(robot) self._allow_remote = allow_remote self._rate_hz = rate_hz self._idle_timeout = idle_timeout @@ -229,6 +232,100 @@ def __init__( self._latest: TransportObservation | None = None self._connected = False + @classmethod + def from_config( + cls, + robot_config: ComponentConfig | Mapping[str, object], + *, + name: str, + allow_remote: bool = False, + rate_hz: float = DEFAULT_RATE_HZ, + idle_timeout: float = 10.0, + connect_timeout: float = 10.0, + _session: object | None = None, + ) -> SharedRobot: + """Primary API: spawn/attach from a trusted robot ComponentConfig. + + Args: + robot_config: Trusted ``class_path`` + ``init_args`` for the driver. + name: Logical owner name (Zenoh topic key). + allow_remote: Whether this session / spawned owner may leave localhost. + rate_hz: Owner loop rate when this instance spawns the owner. + idle_timeout: Idle self-exit timeout for a spawned owner. + connect_timeout: Overall budget for :meth:`connect`. + + Returns: + A ``SharedRobot`` that stores the normalized ComponentConfig and + writes only the new owner stdin shape on spawn. + """ + return cls( + name, + robot=robot_config, + allow_remote=allow_remote, + rate_hz=rate_hz, + idle_timeout=idle_timeout, + connect_timeout=connect_timeout, + _session=_session, + ) + + @classmethod + def from_robot( + cls, + robot: Robot, + *, + name: str, + allow_remote: bool = False, + rate_hz: float = DEFAULT_RATE_HZ, + idle_timeout: float = 10.0, + connect_timeout: float = 10.0, + _session: object | None = None, + ) -> SharedRobot: + """Sugar over :meth:`from_config` for an opted-in live driver. + + Requires :func:`~physicalai.config.is_config_exportable`. Rejects a + connected driver and never disconnects a caller-owned instance + implicitly — release hardware before calling, or prefer + :meth:`from_config` when no live instance is needed. + + Args: + robot: Disconnected, ``@export_config``-marked driver instance. + name: Logical owner name (Zenoh topic key). + allow_remote: Whether this session / spawned owner may leave localhost. + rate_hz: Owner loop rate when this instance spawns the owner. + idle_timeout: Idle self-exit timeout for a spawned owner. + connect_timeout: Overall budget for :meth:`connect`. + + Returns: + A ``SharedRobot`` built from :func:`~physicalai.config.to_config`. + + Raises: + ValueError: If *robot* is not exportable or is still connected. + """ + from physicalai.config import is_config_exportable, to_config # noqa: PLC0415 + + if not is_config_exportable(robot): + msg = ( + f"{type(robot).__module__}.{type(robot).__qualname__} is not " + "config-exportable; decorate with @export_config or pass from_config(...)" + ) + raise ValueError(msg) + if robot.is_connected(): + msg = ( + "SharedRobot.from_robot() requires a disconnected driver; " + "disconnect explicitly before sharing, or use from_config(to_config(...)) " + "after releasing hardware you own" + ) + raise ValueError(msg) + return cls.from_config( + to_config(robot), + name=name, + allow_remote=allow_remote, + rate_hz=rate_hz, + idle_timeout=idle_timeout, + connect_timeout=connect_timeout, + _session=_session, + ) + @classmethod def attach( cls, @@ -255,6 +352,11 @@ def attach( """ return cls(name, allow_remote=allow_remote, connect_timeout=connect_timeout, _session=_session) + @property + def _robot_class(self) -> str | None: + """Public ``class_path`` used for metadata conflict diagnostics.""" + return None if self._robot is None else self._robot["class_path"] + @property def name(self) -> str: """This robot's logical name, keying its Zenoh topics.""" @@ -336,8 +438,8 @@ def _spawn_or_reprobe(self, timeout: float) -> dict[str, Any]: The owner's metadata record. Raises: - RobotTransportError: If ``robot_class`` was not given (attach- - only) or spawning failed for a reason other than a benign + RobotTransportError: If no robot config was given (attach-only) + or spawning failed for a reason other than a benign same-device race. RobotNameConflict: If the race was against a *different*-device owner under the same name. @@ -345,8 +447,8 @@ def _spawn_or_reprobe(self, timeout: float) -> dict[str, Any]: locked under another name — propagated as-is, no re-probe needed. """ - if self._robot_class is None: - msg = f"no owner found for {self._name!r} (attach-only mode: robot_class not provided)" + if self._robot is None: + msg = f"no owner found for {self._name!r} (attach-only mode: robot config not provided)" raise RobotTransportError(msg) from ._owner import RobotOwner # noqa: PLC0415 @@ -354,8 +456,7 @@ def _spawn_or_reprobe(self, timeout: float) -> dict[str, Any]: config = RobotOwnerConfig( name=self._name, - robot_class=self._robot_class, - robot_kwargs=self._robot_kwargs, + robot=self._robot, allow_remote=self._allow_remote, rate_hz=self._rate_hz, idle_timeout=self._idle_timeout, @@ -439,14 +540,16 @@ def _validate_metadata(self, metadata: dict[str, Any]) -> None: msg = f"owner of {self._name!r} published malformed /metadata: {metadata!r}" raise RobotTransportError(msg) - if self._robot_class is not None: + if self._robot is not None: + expected = self._robot["class_path"] advertised = metadata.get("robot_class") - if advertised != self._robot_class: + if advertised != expected: logger.warning( - f"SharedRobot(name={self._name!r}) was constructed with robot_class={self._robot_class!r} " - f"but the existing owner advertises robot_class={advertised!r}. Not fatal — public " - "re-exports, wrappers, or subclasses can preserve the wire contract — but double-check " - "this is the robot you expect.", + f"SharedRobot(name={self._name!r}) was constructed with " + f"robot.class_path={expected!r} but the existing owner advertises " + f"robot_class={advertised!r}. Not fatal — public re-exports, wrappers, or " + "subclasses can preserve the wire contract — but double-check this is the " + "robot you expect.", ) def _attach(self) -> None: diff --git a/tests/unit/cli/test_robot_cli.py b/tests/unit/cli/test_robot_cli.py index ab19564..31e747a 100644 --- a/tests/unit/cli/test_robot_cli.py +++ b/tests/unit/cli/test_robot_cli.py @@ -28,8 +28,10 @@ def _serve_cfg(**overrides: object) -> SimpleNamespace: values: dict[str, object] = { "name": "left-arm", - "robot_class": "tests.unit.robot.transport.fake.FakeRobot", - "robot_kwargs": {"port": "/dev/fake0"}, + "robot": { + "class_path": "tests.unit.robot.transport.fake.FakeRobot", + "init_args": {"port": "/dev/fake0"}, + }, "allow_remote": False, "rate_hz": 100.0, "verbose": False, @@ -57,11 +59,22 @@ def _run_owner(config: object, shutdown: threading.Event, *, ready: object, on_e config = captured["config"] assert config.idle_timeout is None # type: ignore[attr-defined] assert config.name == "left-arm" # type: ignore[attr-defined] + assert config.robot == { # type: ignore[attr-defined] + "class_path": "tests.unit.robot.transport.fake.FakeRobot", + "init_args": {"port": "/dev/fake0"}, + } stderr = capsys.readouterr().err # type: ignore[attr-defined] assert "unauthenticated" not in stderr assert "[local-only]" in stderr +def test_serve_requires_robot_component_config(capsys: object) -> None: + with patch.object(robot_module, "run_owner") as run: + assert robot_module.serve(_serve_cfg(robot=None)) == 1 + run.assert_not_called() + assert "requires --robot" in capsys.readouterr().err # type: ignore[attr-defined] + + def test_serve_allow_remote_warns_and_tags_mode(capsys: object) -> None: def _run_owner( # noqa: ARG001 _config: object, @@ -203,9 +216,36 @@ def test_empty_discovery_json_is_array(capsys: object) -> None: def test_parser_does_not_expose_idle_timeout() -> None: parser = robot_module.build_parser() cfg = parser.parse_args( - ["serve", "--name", "left-arm", "--robot_class", "pkg.mod.Robot"], + [ + "serve", + "--name", + "left-arm", + "--robot.class_path", + "pkg.mod.Robot", + "--robot.init_args", + "{}", + ], ) assert not hasattr(cfg.serve, "idle_timeout") + assert not hasattr(cfg.serve, "robot_class") + assert not hasattr(cfg.serve, "robot_kwargs") + + +def test_parser_accepts_robot_component_config() -> None: + parser = robot_module.build_parser() + cfg = parser.parse_args( + [ + "serve", + "--name", + "left-arm", + "--robot.class_path", + "tests.unit.robot.transport.fake.FakeRobot", + "--robot.init_args", + '{"port": "/dev/fake0"}', + ], + ) + assert cfg.serve.robot["class_path"] == "tests.unit.robot.transport.fake.FakeRobot" + assert cfg.serve.robot["init_args"]["port"] == "/dev/fake0" def test_discover_rejects_invalid_timeout(capsys: object) -> None: @@ -231,12 +271,10 @@ def test_serve_process_sigterm_disconnects_and_releases_locks(tmp_path: Path) -> "serve", "--name", name, - "--robot_class", + "--robot.class_path", "tests.unit.robot.transport.fake.FakeRobot", - "--robot_kwargs.port", - port, - "--robot_kwargs.disconnect_marker", - str(marker), + "--robot.init_args", + json.dumps({"port": port, "disconnect_marker": str(marker)}), ], stderr=subprocess.PIPE, text=True, diff --git a/tests/unit/robot/transport/fake.py b/tests/unit/robot/transport/fake.py index ecda1a8..581e4d8 100644 --- a/tests/unit/robot/transport/fake.py +++ b/tests/unit/robot/transport/fake.py @@ -17,6 +17,8 @@ import numpy as np +from physicalai.config import export_config + NUM_JOINTS = 6 @@ -35,6 +37,7 @@ def state(self) -> np.ndarray: return self.joint_positions +@export_config class FakeRobot: """In-memory robot producing synthetic observations. diff --git a/tests/unit/robot/transport/test_latency.py b/tests/unit/robot/transport/test_latency.py index c9e621a..9475ba4 100644 --- a/tests/unit/robot/transport/test_latency.py +++ b/tests/unit/robot/transport/test_latency.py @@ -40,8 +40,10 @@ class TestActionLatency: def robot(self, unique_id: str) -> Generator[SharedRobot, None, None]: robot = SharedRobot( unique_id.replace("/", "-"), - robot_class=FAKE_ROBOT_CLASS, - robot_kwargs={"device_ids": [f"fake:{unique_id}"]}, + robot={ + "class_path": FAKE_ROBOT_CLASS, + "init_args": {"device_ids": [f"fake:{unique_id}"]}, + }, rate_hz=_RATE_HZ, idle_timeout=3.0, ) diff --git a/tests/unit/robot/transport/test_owner_config.py b/tests/unit/robot/transport/test_owner_config.py index 0873020..e1b5904 100644 --- a/tests/unit/robot/transport/test_owner_config.py +++ b/tests/unit/robot/transport/test_owner_config.py @@ -5,34 +5,54 @@ import json import pickle +import subprocess import sys from typing import Any, cast from unittest.mock import MagicMock, patch import pytest -from physicalai.robot.transport._owner_config import RobotOwnerConfig, normalize_robot_class +from physicalai.config import ComponentConfigError +from physicalai.robot.transport._owner import RobotOwner +from physicalai.robot.transport._owner_config import ( + RobotOwnerConfig, + normalize_robot_class, + normalize_robot_config, +) +from .conftest import FAKE_ROBOT_CLASS from .fake import FakeRobot +# SO101 imports scservo_sdk at module load; keep unit tests hardware-free. +sys.modules.setdefault("scservo_sdk", MagicMock()) -class _Outer: - class Inner: ... +def _fake_robot(**init_args: object) -> dict[str, object]: + return {"class_path": FAKE_ROBOT_CLASS, "init_args": dict(init_args)} -class _RecordingRobot: - """Stands in for a vendor driver class resolved via a stubbed module.""" - def __init__(self, **kwargs: object) -> None: - self.kwargs = kwargs +@pytest.fixture +def mock_scservo_sdk() -> Any: + sdk = MagicMock() + with patch.dict(sys.modules, {"scservo_sdk": sdk}): + yield sdk -class TestNormalizeRobotClass: - def test_string_passthrough(self) -> None: - assert normalize_robot_class("pkg.mod.Cls") == "pkg.mod.Cls" +class _Outer: + class Inner: ... + +class TestNormalizeRobotClass: def test_class_object(self) -> None: - assert normalize_robot_class(FakeRobot) == "tests.unit.robot.transport.fake.FakeRobot" + assert normalize_robot_class(FakeRobot) == FAKE_ROBOT_CLASS + + def test_string_defining_module_normalizes_to_public(self, mock_scservo_sdk: MagicMock) -> None: + # SO101 is defined under so101.so101 but exports physicalai.robot.SO101. + defining = "physicalai.robot.so101.so101.SO101" + assert normalize_robot_class(defining) == "physicalai.robot.SO101" + + def test_public_path_idempotent(self, mock_scservo_sdk: MagicMock) -> None: + assert normalize_robot_class("physicalai.robot.SO101") == "physicalai.robot.SO101" def test_nested_qualname(self) -> None: path = normalize_robot_class(_Outer.Inner) @@ -48,90 +68,190 @@ def test_non_class_non_string_raises(self) -> None: with pytest.raises(TypeError, match="must be a class or a dotted path string"): normalize_robot_class(123) # type: ignore[arg-type] + def test_non_class_path_raises(self) -> None: + with pytest.raises(TypeError, match="does not resolve to a class"): + normalize_robot_class("physicalai.robot.transport._ids.KEY_PREFIX") + + def test_unknown_path_raises(self) -> None: + with pytest.raises(ValueError, match="could not import"): + normalize_robot_class("totally.unknown.module.Cls") + class TestRobotOwnerConfig: def test_picklable(self) -> None: - config = RobotOwnerConfig(name="left-arm", robot_class="pkg.mod.Cls", robot_kwargs={"port": "/dev/ttyUSB0"}) + config = RobotOwnerConfig(name="left-arm", robot=_fake_robot(port="/dev/ttyUSB0")) restored = pickle.loads(pickle.dumps(config)) assert restored == config def test_json_roundtrip(self) -> None: - config = RobotOwnerConfig(name="left-arm", robot_class="pkg.mod.Cls", robot_kwargs={"ip": "10.0.0.2"}) + config = RobotOwnerConfig(name="left-arm", robot=_fake_robot(ip="10.0.0.2")) assert RobotOwnerConfig.from_json_dict(json.loads(json.dumps(config.to_json_dict()))) == config def test_defaults(self) -> None: - config = RobotOwnerConfig(name="left-arm", robot_class="pkg.mod.Cls") - assert config.robot_kwargs == {} + config = RobotOwnerConfig(name="left-arm", robot=_fake_robot()) + assert config.robot == {"class_path": FAKE_ROBOT_CLASS, "init_args": {}} + assert config.robot_class == FAKE_ROBOT_CLASS assert config.allow_remote is False assert config.rate_hz == 100.0 assert config.idle_timeout == 10.0 def test_persistent_idle_timeout_json_roundtrip(self) -> None: - config = RobotOwnerConfig(name="left-arm", robot_class="pkg.mod.Cls", idle_timeout=None) + config = RobotOwnerConfig(name="left-arm", robot=_fake_robot(), idle_timeout=None) restored = RobotOwnerConfig.from_json_dict(json.loads(json.dumps(config.to_json_dict()))) assert restored == config assert restored.idle_timeout is None def test_invalid_name_raises(self) -> None: with pytest.raises(ValueError, match="invalid robot name"): - RobotOwnerConfig(name="left/arm", robot_class="pkg.mod.Cls") + RobotOwnerConfig(name="left/arm", robot=_fake_robot()) @pytest.mark.parametrize("rate_hz", [float("nan"), True, "100"]) def test_invalid_rate_types_raise(self, rate_hz: object) -> None: with pytest.raises(ValueError, match="rate_hz must be finite"): - RobotOwnerConfig(name="left-arm", robot_class="pkg.mod.Cls", rate_hz=rate_hz) # type: ignore[arg-type] + RobotOwnerConfig(name="left-arm", robot=_fake_robot(), rate_hz=rate_hz) # type: ignore[arg-type] @pytest.mark.parametrize("idle_timeout", [0.0, -1.0, float("inf"), float("nan"), True, "10"]) def test_invalid_idle_timeout_raises(self, idle_timeout: object) -> None: with pytest.raises(ValueError, match="idle_timeout must be finite"): RobotOwnerConfig( name="left-arm", - robot_class="pkg.mod.Cls", + robot=_fake_robot(), idle_timeout=cast(Any, idle_timeout), ) def test_zero_rate_raises(self) -> None: with pytest.raises(ValueError, match="rate_hz must be finite"): - RobotOwnerConfig(name="left-arm", robot_class="pkg.mod.Cls", rate_hz=0.0) + RobotOwnerConfig(name="left-arm", robot=_fake_robot(), rate_hz=0.0) def test_negative_rate_raises(self) -> None: with pytest.raises(ValueError, match="rate_hz must be finite"): - RobotOwnerConfig(name="left-arm", robot_class="pkg.mod.Cls", rate_hz=-1.0) + RobotOwnerConfig(name="left-arm", robot=_fake_robot(), rate_hz=-1.0) def test_infinite_rate_raises(self) -> None: with pytest.raises(ValueError, match="rate_hz must be finite"): - RobotOwnerConfig(name="left-arm", robot_class="pkg.mod.Cls", rate_hz=float("inf")) + RobotOwnerConfig(name="left-arm", robot=_fake_robot(), rate_hz=float("inf")) - def test_non_serializable_kwargs_raises(self) -> None: + def test_non_serializable_init_args_raises(self) -> None: with pytest.raises(ValueError, match="JSON-serializable"): - RobotOwnerConfig(name="left-arm", robot_class="pkg.mod.Cls", robot_kwargs={"calibration": object()}) + RobotOwnerConfig( + name="left-arm", + robot={"class_path": FAKE_ROBOT_CLASS, "init_args": {"calibration": object()}}, + ) def test_build_known_class(self) -> None: - config = RobotOwnerConfig( - name="left-arm", - robot_class="tests.unit.robot.transport.fake.FakeRobot", - robot_kwargs={"port": "/dev/ttyUSB0"}, - ) + config = RobotOwnerConfig(name="left-arm", robot=_fake_robot(port="/dev/ttyUSB0")) robot = config.build() assert isinstance(robot, FakeRobot) - def test_build_arbitrary_module_level_class(self) -> None: - # No registry to update: any importable class works, mirroring how - # the vendor SDK is stubbed for so101 elsewhere in this test suite. - mock_module = MagicMock() - mock_module.SO101 = _RecordingRobot - config = RobotOwnerConfig(name="left-arm", robot_class="physicalai.robot.so101.SO101", robot_kwargs={"port": "x"}) - with patch.dict(sys.modules, {"physicalai.robot.so101": mock_module}): - robot = config.build() - assert isinstance(robot, _RecordingRobot) - assert robot.kwargs == {"port": "x"} - def test_build_non_class_path_raises(self) -> None: - config = RobotOwnerConfig(name="left-arm", robot_class="physicalai.robot.transport._ids.KEY_PREFIX") with pytest.raises(TypeError, match="does not resolve to a class"): - config.build() + RobotOwnerConfig( + name="left-arm", + robot={"class_path": "physicalai.robot.transport._ids.KEY_PREFIX", "init_args": {}}, + ) def test_build_unknown_path_raises(self) -> None: - config = RobotOwnerConfig(name="left-arm", robot_class="totally.unknown.module.Cls") with pytest.raises(ValueError, match="could not import"): - config.build() + RobotOwnerConfig( + name="left-arm", + robot={"class_path": "totally.unknown.module.Cls", "init_args": {}}, + ) + + def test_flat_stdin_rejected_before_import(self) -> None: + flat = { + "name": "left-arm", + "robot_class": "totally.unknown.module.Cls", + "robot_kwargs": {"port": "/dev/ttyUSB0"}, + } + with pytest.raises(ValueError, match="unsupported owner stdin keys") as exc_info: + RobotOwnerConfig.from_json_dict(flat) + assert "robot_class" in str(exc_info.value) + + def test_flat_robot_kwargs_alone_rejected(self) -> None: + with pytest.raises(ValueError, match="unsupported owner stdin keys"): + RobotOwnerConfig.from_json_dict( + { + "name": "left-arm", + "robot": _fake_robot(), + "robot_kwargs": {}, + }, + ) + + def test_missing_robot_rejected(self) -> None: + with pytest.raises(ValueError, match="missing required 'robot'"): + RobotOwnerConfig.from_json_dict({"name": "left-arm"}) + + def test_malformed_robot_rejected_before_import(self) -> None: + with pytest.raises(ComponentConfigError): + RobotOwnerConfig.from_json_dict( + { + "name": "left-arm", + "robot": {"class_path": "totally.unknown.module.Cls", "extra": 1}, + }, + ) + + def test_defining_module_path_normalized_on_store(self, mock_scservo_sdk: MagicMock) -> None: + config = RobotOwnerConfig( + name="left-arm", + robot={ + "class_path": "physicalai.robot.so101.so101.SO101", + "init_args": {"port": "/dev/ttyUSB0", "calibration": "./cal.json"}, + }, + ) + assert config.robot["class_path"] == "physicalai.robot.SO101" + assert config.robot_class == "physicalai.robot.SO101" + assert config.to_json_dict()["robot"]["class_path"] == "physicalai.robot.SO101" + + def test_relative_path_survives_json_and_popen_inherits_cwd(self, monkeypatch: pytest.MonkeyPatch) -> None: + config = RobotOwnerConfig( + name="left-arm", + robot=_fake_robot(calibration="./calibration.json"), + ) + payload = config.to_json_dict() + assert payload["robot"]["init_args"]["calibration"] == "./calibration.json" + + captured: dict[str, object] = {} + + class _FakeProc: + stdin = None + stdout = None + + def __init__(self, *_args: object, **kwargs: object) -> None: + captured.update(kwargs) + self.stdin = _FakeStdin() + self.stdout = _FakeStdout() + + def poll(self) -> int | None: + return 0 + + def terminate(self) -> None: + return None + + def kill(self) -> None: + return None + + def wait(self, timeout: float | None = None) -> int: # noqa: ARG002 + return 0 + + class _FakeStdin: + def write(self, _data: bytes) -> None: + return None + + def close(self) -> None: + return None + + class _FakeStdout: + def readline(self) -> bytes: + return b"READY\n" + + monkeypatch.setattr(subprocess, "Popen", _FakeProc) + monkeypatch.setattr(RobotOwner, "_read_stdout_line", lambda _self, _timeout: "READY") + owner = RobotOwner(config) + owner.start(timeout=1.0) + assert "cwd" not in captured + + +class TestNormalizeRobotConfig: + def test_normalize_robot_config_rejects_malformed(self) -> None: + with pytest.raises(ComponentConfigError): + normalize_robot_config({"class_path": FAKE_ROBOT_CLASS, "extra": True}) diff --git a/tests/unit/robot/transport/test_owner_handshake.py b/tests/unit/robot/transport/test_owner_handshake.py index 9938c61..590737a 100644 --- a/tests/unit/robot/transport/test_owner_handshake.py +++ b/tests/unit/robot/transport/test_owner_handshake.py @@ -16,11 +16,14 @@ from .fake import FakeRobot -def _owner(unique_id: str, **robot_kwargs: object) -> RobotOwner: +def _fake_robot(**init_args: object) -> dict[str, object]: + return {"class_path": FAKE_ROBOT_CLASS, "init_args": dict(init_args)} + + +def _owner(unique_id: str, **robot_init_args: object) -> RobotOwner: config = RobotOwnerConfig( name=unique_id.replace("/", "-"), - robot_class=FAKE_ROBOT_CLASS, - robot_kwargs={"device_ids": [f"fake:{unique_id}"], **robot_kwargs}, + robot=_fake_robot(device_ids=[f"fake:{unique_id}"], **robot_init_args), idle_timeout=2.0, ) return RobotOwner(config) @@ -33,7 +36,7 @@ def test_startup_failure_after_connect_disconnects_and_releases_locks( name = unique_id.replace("/", "-") device_id = f"fake:{unique_id}" driver = FakeRobot(device_ids=(device_id,), fail_observation=True) - config = RobotOwnerConfig(name=name, robot_class=FAKE_ROBOT_CLASS) + config = RobotOwnerConfig(name=name, robot=_fake_robot()) monkeypatch.setattr(RobotOwnerConfig, "build", lambda _self: driver) with pytest.raises(_StartupError, match="fake observation failure") as exc_info: @@ -47,7 +50,7 @@ def test_startup_failure_after_connect_disconnects_and_releases_locks( @pytest.mark.parametrize("allow_remote", [False, True]) def test_metadata_redacts_device_ids_for_remote_owner(allow_remote: bool) -> None: - config = RobotOwnerConfig(name="left-arm", robot_class=FAKE_ROBOT_CLASS, allow_remote=allow_remote) + config = RobotOwnerConfig(name="left-arm", robot=_fake_robot(), allow_remote=allow_remote) metadata = _build_metadata( config, FakeRobot(device_ids=("serial:ttyUSB0",)), @@ -56,6 +59,7 @@ def test_metadata_redacts_device_ids_for_remote_owner(allow_remote: bool) -> Non ) assert metadata["host"] + assert metadata["robot_class"] == FAKE_ROBOT_CLASS if allow_remote: assert "device_ids" not in metadata else: @@ -72,7 +76,7 @@ def test_endpoint_collision_error_identifies_endpoint_and_remediation( bind_host: str, ) -> None: name = unique_id.replace("/", "-") - config = RobotOwnerConfig(name=name, robot_class=FAKE_ROBOT_CLASS, allow_remote=allow_remote) + config = RobotOwnerConfig(name=name, robot=_fake_robot(), allow_remote=allow_remote) def _fail_open_session(*_args: object, **_kwargs: object) -> None: raise OSError("address already in use") diff --git a/tests/unit/robot/transport/test_owner_worker.py b/tests/unit/robot/transport/test_owner_worker.py index b22fbf8..f85ffc3 100644 --- a/tests/unit/robot/transport/test_owner_worker.py +++ b/tests/unit/robot/transport/test_owner_worker.py @@ -3,6 +3,8 @@ from __future__ import annotations +import io +import json import threading import time from dataclasses import dataclass, field @@ -11,7 +13,7 @@ from physicalai.robot.transport import _owner_worker from physicalai.robot.transport._owner_config import RobotOwnerConfig -from physicalai.robot.transport._owner_worker import OwnerEvent, OwnerExitReason, _run_loop, run_owner +from physicalai.robot.transport._owner_worker import OwnerEvent, OwnerExitReason, _run_loop, main, run_owner from .fake import FakeRobot @@ -137,7 +139,11 @@ def test_disconnect_failure_upgrades_clean_shutdown(monkeypatch: Any) -> None: locks=SimpleNamespace(release_all=lambda: None), ) monkeypatch.setattr(_owner_worker, "_startup", lambda _config: endpoints) - config = RobotOwnerConfig(name="left-arm", robot_class="pkg.mod.Robot", idle_timeout=None) + config = RobotOwnerConfig( + name="left-arm", + robot={"class_path": "tests.unit.robot.transport.fake.FakeRobot", "init_args": {}}, + idle_timeout=None, + ) result = run_owner(config, shutdown) @@ -157,7 +163,11 @@ def test_ready_failure_still_disconnects(monkeypatch: Any) -> None: # noqa: ANN locks=SimpleNamespace(release_all=lambda: None), ) monkeypatch.setattr(_owner_worker, "_startup", lambda _config: endpoints) - config = RobotOwnerConfig(name="left-arm", robot_class="pkg.mod.Robot", idle_timeout=None) + config = RobotOwnerConfig( + name="left-arm", + robot={"class_path": "tests.unit.robot.transport.fake.FakeRobot", "init_args": {}}, + idle_timeout=None, + ) def _fail_ready() -> None: raise RuntimeError("readiness output failed") @@ -167,3 +177,25 @@ def _fail_ready() -> None: assert result.reason is OwnerExitReason.LOOP_FAILURE assert result.exit_code == 1 assert driver.disconnect_called + + +def test_main_malformed_robot_stdin_signals_invalid_config(monkeypatch: Any) -> None: # noqa: ANN401 + """Malformed new-shape robot: must ERROR with invalid_config, not crash.""" + payload = json.dumps( + { + "name": "left-arm", + "robot": {"class_path": "tests.unit.robot.transport.fake.FakeRobot", "extra": 1}, + }, + ) + errors: list[tuple[str, str | None]] = [] + + def _capture_error(msg: str, tb: str | None = None, *, phase: str | None = None, **_kwargs: object) -> None: + errors.append((msg, phase)) + + monkeypatch.setattr(_owner_worker, "signal_error", _capture_error) + monkeypatch.setattr(_owner_worker.sys, "stdin", io.StringIO(payload)) + + assert main() == 1 + assert len(errors) == 1 + assert errors[0][1] == "invalid_config" + assert "invalid worker config" in errors[0][0] diff --git a/tests/unit/robot/transport/test_shared_robot.py b/tests/unit/robot/transport/test_shared_robot.py index 8dfb6cb..8b05350 100644 --- a/tests/unit/robot/transport/test_shared_robot.py +++ b/tests/unit/robot/transport/test_shared_robot.py @@ -30,11 +30,13 @@ _STATE_DIM = 12 # fake ships positions + velocities -def _shared_robot(name: str, *, allow_remote: bool = False, **robot_kwargs: object) -> SharedRobot: +def _shared_robot(name: str, *, allow_remote: bool = False, **robot_init_args: object) -> SharedRobot: return SharedRobot( name, - robot_class=FAKE_ROBOT_CLASS, - robot_kwargs={"device_ids": [f"fake:{name}"], **robot_kwargs}, + robot={ + "class_path": FAKE_ROBOT_CLASS, + "init_args": {"device_ids": [f"fake:{name}"], **robot_init_args}, + }, idle_timeout=0.5, allow_remote=allow_remote, ) @@ -75,14 +77,52 @@ def test_invalid_name_raises(self) -> None: with pytest.raises(ValueError, match="invalid robot name"): SharedRobot("bad/name") - def test_attach_only_has_no_robot_class(self) -> None: + def test_attach_only_has_no_robot_config(self) -> None: robot = SharedRobot.attach("left-arm") assert robot.name == "left-arm" + assert robot._robot is None assert robot.device_ids == () - def test_class_object_normalized(self) -> None: - robot = SharedRobot("left-arm", robot_class=FakeRobot, robot_kwargs={"port": "/dev/ttyUSB0"}) - assert robot._robot_class == "tests.unit.robot.transport.fake.FakeRobot" + def test_robot_component_config_normalized(self) -> None: + robot = SharedRobot( + "left-arm", + robot={"class_path": FAKE_ROBOT_CLASS, "init_args": {"port": "/dev/ttyUSB0"}}, + ) + assert robot._robot == { + "class_path": FAKE_ROBOT_CLASS, + "init_args": {"port": "/dev/ttyUSB0"}, + } + + def test_from_config_stores_component_config(self) -> None: + robot = SharedRobot.from_config( + {"class_path": FAKE_ROBOT_CLASS, "init_args": {"port": "/dev/fake0"}}, + name="left-arm", + ) + assert robot._robot == {"class_path": FAKE_ROBOT_CLASS, "init_args": {"port": "/dev/fake0"}} + + def test_from_robot_requires_exportable(self) -> None: + class _Bare: + def is_connected(self) -> bool: + return False + + with pytest.raises(ValueError, match="not config-exportable"): + SharedRobot.from_robot(_Bare(), name="left-arm") # type: ignore[arg-type] + + def test_from_robot_rejects_connected_without_disconnect(self) -> None: + driver = FakeRobot(port="/dev/fake0") + driver.connect() + assert driver.is_connected() + with pytest.raises(ValueError, match="disconnected driver"): + SharedRobot.from_robot(driver, name="left-arm") + assert driver.is_connected() + assert not driver.disconnect_called + + def test_from_robot_exports_disconnected_driver(self) -> None: + driver = FakeRobot(port="/dev/fake0", device_ids=("fake:/dev/fake0",)) + robot = SharedRobot.from_robot(driver, name="left-arm") + assert robot._robot is not None + assert robot._robot["class_path"] == FAKE_ROBOT_CLASS + assert robot._robot["init_args"]["port"] == "/dev/fake0" def test_satisfies_robot_protocol(self) -> None: from physicalai.robot import Robot @@ -231,15 +271,21 @@ def test_class_mismatch_warns_but_attaches(self, module_owner: SharedRobot, capl """robot_class mismatch on an existing owner is diagnostic, not fatal.""" import logging + from physicalai.robot.transport._owner_config import RobotOwnerConfig + impostor = SharedRobot( module_owner.name, - robot_class="physicalai.robot.transport._session.open_session", - robot_kwargs={"device_ids": [f"fake:{module_owner.name}"]}, + robot={ + "class_path": f"{RobotOwnerConfig.__module__}.{RobotOwnerConfig.__qualname__}", + "init_args": {}, + }, ) with caplog.at_level(logging.WARNING): impostor.connect() # attaches to the same owner; must not raise try: assert impostor.is_connected() + # loguru writes to stderr; message shape is asserted via construction above + assert impostor._robot_class != module_owner._robot_class finally: impostor.disconnect() @@ -260,7 +306,10 @@ def test_device_already_owned_under_another_name(self, module_owner: SharedRobot assert metadata is not None shared_device = metadata["device_ids"][0] other_name = unique_id.replace("/", "-") - second = SharedRobot(other_name, robot_class=FAKE_ROBOT_CLASS, robot_kwargs={"device_ids": [shared_device]}) + second = SharedRobot( + other_name, + robot={"class_path": FAKE_ROBOT_CLASS, "init_args": {"device_ids": [shared_device]}}, + ) with pytest.raises(RobotDeviceAlreadyOwned): second.connect() @@ -294,8 +343,7 @@ def test_differing_device_race_raises_name_conflict(self, unique_id: str, *, all def _run(key: str, device_id: str) -> None: robot = SharedRobot( name, - robot_class=FAKE_ROBOT_CLASS, - robot_kwargs={"device_ids": [device_id]}, + robot={"class_path": FAKE_ROBOT_CLASS, "init_args": {"device_ids": [device_id]}}, allow_remote=allow_remote, ) barrier.wait() From 0af724e0c7488bcab8e139f7e55f72553363f0fd Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Thu, 23 Jul 2026 10:11:35 +0200 Subject: [PATCH 09/16] step 5 --- docs/development/component-config-brief.md | 6 +- docs/development/component-config-report.md | 27 +- docs/development/component-config.md | 104 ++--- docs/explanation/cameras.md | 27 +- examples/capture/shared_camera.ipynb | 8 +- examples/runtime/runtime.yaml | 57 +-- examples/tutorials/collect_train_deploy.ipynb | 42 +- src/physicalai/capture/camera.py | 8 +- .../capture/cameras/basler/_camera.py | 2 + .../capture/cameras/realsense/_camera.py | 2 + src/physicalai/capture/cameras/uvc/_camera.py | 2 + src/physicalai/capture/factory.py | 61 ++- src/physicalai/capture/transport/__init__.py | 8 +- .../capture/transport/_publisher.py | 11 +- .../capture/transport/_publisher_worker.py | 96 +++- .../capture/transport/_shared_camera.py | 238 +++++++--- src/physicalai/capture/transport/_spec.py | 265 +++++++++-- src/physicalai/capture/transport/builtin.py | 92 ++++ tests/unit/capture/conftest.py | 7 +- tests/unit/capture/fake.py | 2 + .../capture/test_camera_component_config.py | 100 +++++ tests/unit/capture/test_factory.py | 41 +- tests/unit/capture/test_transport.py | 416 ++++++++++++++---- 23 files changed, 1297 insertions(+), 325 deletions(-) create mode 100644 src/physicalai/capture/transport/builtin.py create mode 100644 tests/unit/capture/test_camera_component_config.py diff --git a/docs/development/component-config-brief.md b/docs/development/component-config-brief.md index 42f5499..8a8c064 100644 --- a/docs/development/component-config-brief.md +++ b/docs/development/component-config-brief.md @@ -49,8 +49,10 @@ flowchart LR - Robots (SO101, WidowX, bimanual as one owner), cameras, PolicySource graph, path-rooted `InferenceModel`, RobotRuntime + listed callbacks -- SharedCamera: built-in `service_name` derived in transport; third-party must - pass an explicit name +- SharedCamera: ComponentConfig-only (`camera=` / `from_config` / + `from_camera`); shareable built-in `service_name` derived in transport + (`uvc` / `realsense` / `basler`); stubs (`ip` / `genicam`) and third-party + must pass an explicit name; flat `camera_type` / `camera_kwargs` rejected - Trust: `instantiate` only on local / parent→child config — never network metadata ## Out of scope (v1) diff --git a/docs/development/component-config-report.md b/docs/development/component-config-report.md index 983e6e2..a5b67ee 100644 --- a/docs/development/component-config-report.md +++ b/docs/development/component-config-report.md @@ -208,26 +208,27 @@ flowchart TB ### SharedCamera naming -| Mode | `service_name` | -| ----------------- | -------------------------------------------------------------------------- | -| Built-in spawn | Derived in transport: `class_path` → legacy `CameraType` token + device id | -| Third-party spawn | **Required** explicit name (no hashing) | -| Attach-only | Required; no construction config | +| Mode | `service_name` | +| ------------------------ | -------------------------------------------------------------------------------------------------- | +| Shareable built-in spawn | Derived in transport (`builtin.py`): `class_path` → `uvc` / `realsense` / `basler` + device id | +| Stub / third-party spawn | **Required** explicit name (`ip` / `genicam` not in the map; same rule as third-party; no hashing) | +| Attach-only | Required; no construction config | `service_name` lives **beside** `camera: ComponentConfig`, never inside `init_args`. ### Public API -| Surface | Accept | Preferred | -| ---------------------- | ------------------------------------------------ | ----------------------------- | -| `SharedRobot` | `robot=` ComponentConfig (or `None` / `attach`) | `from_config` / `from_robot` | -| `SharedCamera` | `camera=` **xor** `camera_type`+kwargs (adapter) | `from_config` / `from_camera` | -| CLI `physicalai robot` | `--robot` ComponentConfig (required) | `--robot` | -| Metadata | Keep key `robot_class` | Value = public `class_path` | +| Surface | Accept | Preferred | +| ---------------------- | ----------------------------------------------- | ----------------------------- | +| `SharedRobot` | `robot=` ComponentConfig (or `None` / `attach`) | `from_config` / `from_robot` | +| `SharedCamera` | `camera=` ComponentConfig (or `None` / attach) | `from_config` / `from_camera` | +| CLI `physicalai robot` | `--robot` ComponentConfig (required) | `--robot` | +| Metadata | Keep key `robot_class` | Value = public `class_path` | Flat `robot_class` / `robot_kwargs` on SharedRobot, serve CLI, and owner stdin -are unsupported (rejected on stdin; removed from the public API). Camera -still keeps a temporary XOR adapter until its cutover step. +are unsupported (rejected on stdin; removed from the public API). Flat +`camera_type` / `camera_kwargs` on SharedCamera and publisher stdin are +likewise unsupported — ComponentConfig-only, matching SharedRobot. `from_robot` / `from_camera`: require exportable, **reject if connected**, never disconnect implicitly. diff --git a/docs/development/component-config.md b/docs/development/component-config.md index 3f9cd76..fa9c88c 100644 --- a/docs/development/component-config.md +++ b/docs/development/component-config.md @@ -479,41 +479,35 @@ Per-arm `SharedRobot` wrapping of nested arms is out of scope. ### Cameras Camera implementations opt in using public class paths. This supports -third-party cameras without extending the `create_camera()` registry. - -Today's `CameraSpec(camera_type, camera_kwargs)` cannot preserve a third-party -class path because `build()` routes through the built-in `create_camera()` -registry. Supporting third-party replay therefore requires a semantic -transport migration, not a wrapper translation. - -Change the private publisher envelope to contain `camera: ComponentConfig` -plus transport fields including `service_name`; `build()` delegates to -`instantiate()` and verifies the result satisfies `Camera`. This is a private -startup-wire **hard cutover** in the same PR as the SharedCamera spawn path: -rewrite the envelope, update fixtures/tests, and drop the legacy -`camera_type` + `camera_kwargs` reader. No `config_format` field and no -dual-read. Normalize built-in names to public class paths in the new shape. -Third-party camera sharing is not supported until this step lands. +third-party cameras without extending the `create_camera()` registry for +shared spawn. + +The private publisher envelope contains `camera: ComponentConfig` plus +transport fields including `service_name`; `build()` delegates to +`instantiate()` and verifies the result satisfies `Camera`. Writers and +readers speak only this shape. Flat `camera_type` + `camera_kwargs` are +rejected before import. Built-in names are public class paths in the new +shape. #### SharedCamera service naming Transport owns naming; `ComponentConfig` never embeds `service_name`. -Today spawn derives -`physicalai/camera/{camera_type}/{device_id}/frame` from the registry enum. -After the envelope migration there is no `camera_type` on the construction -config. Rules: - -- **Built-in spawn:** a private map from public `class_path` to the legacy - `CameraType` token (`uvc`, `ip`, `realsense`, `basler`, `genicam`). That map - lives in **capture transport**, not in `physicalai.config`. Derive - `service_name` with the existing scheme using that token plus device id from - `init_args` (`serial_number` else `device`, including `/dev/` symlink - resolve). This preserves existing attacher discovery. -- **Third-party spawn:** require an explicit `service_name` on `SharedCamera` / - the publisher envelope; fail before publisher start if missing. Do not hash - `class_path` or `init_args` into a name (unstable across arg ordering and - omitted defaults). +Spawn has no `camera_type` on the construction config. Rules: + +- **Shareable built-in spawn:** a private map from public `class_path` to the + legacy `CameraType` token for backends with a real driver under `cameras/` + (`uvc`, `realsense`, `basler` only). That map lives in capture transport + (`builtin.py`), not in `physicalai.config`. Derive `service_name` with the + existing scheme using that token plus device id from `init_args` + (`serial_number` else `device`, including `/dev/` symlink resolve). This + preserves existing attacher discovery. +- **Stub / non-shareable registry types and third-party spawn:** `ip` and + `genicam` are intentionally absent from the map (same rule as third-party). + Require an explicit `service_name` on `SharedCamera` / the publisher + envelope; fail before publisher start if missing. Do not hash `class_path` + or `init_args` into a name (unstable across arg ordering and omitted + defaults). - **Attach-only:** unchanged — `service_name` required; no construction config needed. - The publisher payload carries `service_name` **alongside** @@ -665,9 +659,10 @@ v1 rules: absolutizing paths; that is out of scope for v1. `port` remains absolute (`/dev/…`); relative serial ports are unsupported. -Camera URL/stream fields are not filesystem paths. The built-in camera -`class_path` → `CameraType` map lives in **capture transport**, not in -`physicalai.config`. +Camera URL/stream fields are not filesystem paths. The shareable built-in +camera `class_path` → `CameraType` map (`uvc` / `realsense` / `basler`) lives +in **capture transport** (`builtin.py`), not in `physicalai.config`. Stub +types (`ip`, `genicam`) are not in that map. ### Public SharedRobot, CLI, and metadata @@ -705,16 +700,19 @@ three APIs: API. Transport kwargs (zero-copy, validation, idle timeout, …) stay on the SharedCamera / publisher side, never inside `ComponentConfig`. - **`SharedCamera.from_camera(camera, *, service_name=None, …)`** — sugar: - require `is_config_exportable(camera)`, reject if `camera.is_connected()` + require `is_config_exportable(camera)`, reject if `camera.is_connected` (fail before publisher spawn), never disconnect a caller-owned live camera implicitly, then `from_config(to_config(camera), …)`. No ad-hoc kwargs scrape. -- **Constructor adapter:** accept `camera: ComponentConfig` **XOR** legacy - `camera_type` + kwargs. Passing both is an error. Legacy flat form packs - `camera: ComponentConfig` and writes only the new stdin. -- **Who derives `service_name`:** `from_config` and the adapter constructor. - If `service_name` is omitted and `class_path` is a known built-in, derive - via the transport map + device id from `init_args`. If third-party and +- **Constructor:** accept `camera: ComponentConfig` to spawn, or + `camera=None` + `service_name` for attach-only (prefer + :meth:`SharedCamera.from_publisher`). Flat `camera_type` / + `camera_kwargs` are unsupported on the public API and publisher stdin + (rejected before import) — not a dual adapter API. +- **Who derives `service_name`:** `from_config` and the constructor. + If `service_name` is omitted and `class_path` is a shareable built-in + (`uvc` / `realsense` / `basler`), derive via the transport map + device id + from `init_args`. If stub (`ip` / `genicam`) or third-party and `service_name` is omitted, fail before spawn. The publisher envelope always carries a concrete `service_name`; the child never re-derives it. - **Attach-only / `from_publisher(service_name=…)`:** unchanged. @@ -860,10 +858,13 @@ specific subclasses only where tests or callers distinguish phases. 5. Wire camera implementations, then hard-cutover the camera publisher stdin to `camera: ComponentConfig` with `service_name` beside it (same PR: rewrite fixtures; no dual-read). Add `SharedCamera.from_config()` / - `from_camera()`, XOR adapter ctor, and transport-owned built-in - class-path → type-token map for derived names; third-party requires - explicit `service_name`. Do not claim third-party shared camera support - before this lands. + `from_camera()`; public ctor accepts only `camera=` / `from_config` / + `from_camera` (flat `camera_type` / `camera_kwargs` unsupported on public + API and stdin — same post-step-4 SharedRobot simplification). Transport- + owned shareable class-path → type-token map (`uvc` / `realsense` / + `basler`) for derived names; stub (`ip` / `genicam`) and third-party + require explicit `service_name`. Do not claim third-party shared camera + support before this lands. 6. Wire the exact PolicySource graph listed above, `TeleopSource`, path-rooted `InferenceModel` configuration, and the v1 callback set. 7. Wire `RobotRuntime` and verify emitted nested configs load through both @@ -921,15 +922,16 @@ cutovers; do not describe them as schema-preserving. - Robot owner and camera publisher subprocess handshakes remain JSON-only, accept only the new `robot:` / `camera: ComponentConfig` shape, and reject unsupported flat stdin (`robot_class` / `camera_type` forms) before import. -- Built-in SharedCamera spawn derives the legacy `service_name` via the - transport class-path → type-token map inside `from_config` / the adapter - ctor; third-party spawn without explicit `service_name` fails before - publisher start; the publisher envelope carries a concrete `service_name` - not inside `init_args`. +- Shareable SharedCamera spawn (`uvc` / `realsense` / `basler`) derives the + legacy `service_name` via the transport class-path → type-token map inside + `from_config` / the constructor; stub (`ip` / `genicam`) and third-party + spawn without explicit `service_name` fails before publisher start; the + publisher envelope carries a concrete `service_name` not inside + `init_args`. - `SharedCamera.from_camera()` is sugar over `from_config(to_config(...))`, requires exportability, rejects connected cameras before publisher spawn, - and never disconnects implicitly; public ctor XOR-rejects simultaneous - `camera` and legacy `camera_type` + kwargs. + and never disconnects implicitly; public ctor and publisher stdin reject + flat `camera_type` / `camera_kwargs` as unsupported (not a dual API). - Third-party camera class paths survive the publisher envelope and bypass the built-in camera registry. - Public `SharedRobot` ctor and `physicalai robot serve` accept only diff --git a/docs/explanation/cameras.md b/docs/explanation/cameras.md index 247eff7..7599dbb 100644 --- a/docs/explanation/cameras.md +++ b/docs/explanation/cameras.md @@ -28,16 +28,37 @@ Camera instances are not thread-safe. Use one thread per camera instance or add ## SharedCamera -For multi-process or multi-thread access, use `SharedCamera`. It wraps any camera and handles IPC transport automatically. +`SharedCamera` lets one publisher subprocess own a camera's exclusive hardware +connection while any number of subscribers read frames over iceoryx2. It +satisfies the same `Camera` protocol as a direct driver. + +Construction is ComponentConfig-only (`from_config` / `from_camera`), matching +`SharedRobot`. Prefer `from_config` (or YAML) when sharing. `from_camera` is +export-only sugar after disconnect — it does not hand off an open device into +the child; the publisher always opens fresh. Never keep a direct camera +connected to the same device while sharing: another connected holder causes +open failure. ```python -from physicalai.capture import SharedCamera, UVCCamera +from physicalai.capture import SharedCamera +from physicalai.config import to_config -shared = SharedCamera(UVCCamera(device="/dev/video0")) +# Prefer config-only / disconnected export — no live direct camera held open +shared = SharedCamera.from_config( + { + "class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": "/dev/video0", "backend": "v4l2"}, + }, +) +# Equivalent after to_config(disconnected_driver): +# shared = SharedCamera.from_config(to_config(driver)) shared.connect() # Safe to read from multiple threads/processes frame = shared.read_latest() ``` +`create_camera(..., shared=True)` remains a convenience for shareable built-ins +(`uvc`, `realsense`, `basler`) that packs a type into `SharedCamera(camera=...)`. + `SharedCamera` is the recommended approach for production deployments where multiple consumers need camera frames. It avoids the need for manual synchronization and handles frame distribution efficiently. diff --git a/examples/capture/shared_camera.ipynb b/examples/capture/shared_camera.ipynb index 91ea741..eb16dc5 100644 --- a/examples/capture/shared_camera.ipynb +++ b/examples/capture/shared_camera.ipynb @@ -14,7 +14,7 @@ "the camera, buffer management, or copy overhead.\n", "\n", "Because the transport layer is [iceoryx2](https://github.com/eclipse-iceoryx/iceoryx2),\n", - "any language with iceoryx2 bindings (Rust, C, C++, Python, …) can subscribe to\n", + "any language with iceoryx2 bindings (Rust, C, C++, Python, \u2026) can subscribe to\n", "the same camera stream.\n", "\n", "**Why use SharedCamera:**\n", @@ -81,16 +81,16 @@ "metadata": {}, "outputs": [], "source": [ - "from physicalai.capture import SharedCamera\n", + "from physicalai.capture import SharedCamera, create_camera\n", "\n", "# --- Edit these to match your setup ---\n", "CAMERA_TYPE = CameraType.UVC\n", "DEVICE_KWARGS = {\"device\": 0} # or {\"serial_number\": \"123456\"} for realsense/basler\n", "# ---------------------------------------\n", "\n", - "cam = SharedCamera(CAMERA_TYPE, zero_copy=True, **DEVICE_KWARGS)\n", + "cam: SharedCamera = create_camera(CAMERA_TYPE, shared=True, zero_copy=True, **DEVICE_KWARGS) # type: ignore[assignment]\n", "cam.connect()\n", - "print(f\"Connected to {cam.device_id}\")" + "print(f\"Connected to {cam.device_id}\")\n" ] }, { diff --git a/examples/runtime/runtime.yaml b/examples/runtime/runtime.yaml index 034bbbf..d948603 100644 --- a/examples/runtime/runtime.yaml +++ b/examples/runtime/runtime.yaml @@ -47,21 +47,23 @@ runtime: overhead: # scene/workspace view class_path: physicalai.capture.SharedCamera init_args: - camera_type: uvc - camera_kwargs: - device: /dev/video0 # CHANGE_ME: use /dev/v4l/by-id/... for stable identity - width: 640 - height: 480 - fps: 30 + camera: + class_path: physicalai.capture.UVCCamera + init_args: + device: /dev/video0 # CHANGE_ME: use /dev/v4l/by-id/... for stable identity + width: 640 + height: 480 + fps: 30 gripper: # wrist-mounted view (rename to ``arm`` if the model expects that key) class_path: physicalai.capture.SharedCamera init_args: - camera_type: uvc - camera_kwargs: - device: /dev/video2 # CHANGE_ME - width: 640 - height: 480 - fps: 30 + camera: + class_path: physicalai.capture.UVCCamera + init_args: + device: /dev/video2 # CHANGE_ME + width: 640 + height: 480 + fps: 30 fps: 30.0 @@ -73,30 +75,31 @@ runtime: # and start ``rerun`` separately. # # Cameras are declared again here so the callback can subscribe to the - # same shared-memory streams as the runtime. SharedCamera matches - # publishers by ``camera_type`` + ``camera_kwargs``, so keep these blocks - # in sync with the runtime ``cameras:`` section above. + # same shared-memory streams as the runtime. Keep these nested ``camera:`` + # blocks in sync with the runtime ``cameras:`` section above. - class_path: physicalai.runtime.RerunCallback init_args: cameras: overhead: class_path: physicalai.capture.SharedCamera init_args: - camera_type: uvc - camera_kwargs: - device: /dev/video0 # CHANGE_ME: same as overhead camera above - width: 640 - height: 480 - fps: 30 + camera: + class_path: physicalai.capture.UVCCamera + init_args: + device: /dev/video0 # CHANGE_ME: same as overhead camera above + width: 640 + height: 480 + fps: 30 gripper: class_path: physicalai.capture.SharedCamera init_args: - camera_type: uvc - camera_kwargs: - device: /dev/video2 # CHANGE_ME: same as gripper camera above - width: 640 - height: 480 - fps: 30 + camera: + class_path: physicalai.capture.UVCCamera + init_args: + device: /dev/video2 # CHANGE_ME: same as gripper camera above + width: 640 + height: 480 + fps: 30 mode: spawn log_images: true image_decimation: 1 # log every Nth frame; raise to reduce viewer load diff --git a/examples/tutorials/collect_train_deploy.ipynb b/examples/tutorials/collect_train_deploy.ipynb index dc61ebe..0e5426c 100644 --- a/examples/tutorials/collect_train_deploy.ipynb +++ b/examples/tutorials/collect_train_deploy.ipynb @@ -5,12 +5,12 @@ "id": "7c7e915d", "metadata": {}, "source": [ - "# Collect → Train → Deploy with SO-101\n", + "# Collect \u2192 Train \u2192 Deploy with SO-101\n", "\n", - "End-to-end workflow: collect teleoperation demos on an **SO-101** arm, fine-tune a **π0.5** policy, and deploy it back on the robot using `physicalai` runtime.\n", + "End-to-end workflow: collect teleoperation demos on an **SO-101** arm, fine-tune a **\u03c00.5** policy, and deploy it back on the robot using `physicalai` runtime.\n", "\n", "1. **Collect** demonstration data (teleoperation with leader/follower arms)\n", - "2. **Train** a π0.5 visuomotor diffusion policy\n", + "2. **Train** a \u03c00.5 visuomotor diffusion policy\n", "3. **Deploy** the trained policy on hardware via `physicalai` runtime\n", "\n", "---" @@ -28,7 +28,7 @@ "| | Requirement |\n", "|--|-------------|\n", "| **Robot** | SO-101 follower arm (+ leader arm for data collection) |\n", - "| **Cameras** | 1–2 USB cameras (UVC/RealSense/Basler compatible) |\n", + "| **Cameras** | 1\u20132 USB cameras (UVC/RealSense/Basler compatible) |\n", "| **Training GPU** | 40 GB+ VRAM (e.g. 2*B70 or A100) |\n", "| **Deploy machine** | Intel CPU, GPU, or NPU with OpenVINO support |\n", "| **OS** | Linux (Ubuntu 22.04+ recommended) |\n", @@ -36,8 +36,8 @@ "\n", "### Install packages\n", "\n", - "- **`physicalai`** — runtime for deployment (Step 3)\n", - "- **`physicalai-train`** — fine-tuning library (Step 2), distributed via [Physical AI Studio](https://github.com/open-edge-platform/physical-ai-studio)" + "- **`physicalai`** \u2014 runtime for deployment (Step 3)\n", + "- **`physicalai-train`** \u2014 fine-tuning library (Step 2), distributed via [Physical AI Studio](https://github.com/open-edge-platform/physical-ai-studio)" ] }, { @@ -87,7 +87,7 @@ "\n", "> **Note:** LeRobot uses `so100` as the robot type for both SO-100 and SO-101 hardware.\n", "\n", - "Aim for **50–100 demonstrations** of the target task with varied object positions." + "Aim for **50\u2013100 demonstrations** of the target task with varied object positions." ] }, { @@ -97,11 +97,11 @@ "source": [ "## Step 2: Train a Policy\n", "\n", - "Train a π0.5 (pi-zero-point-five) visuomotor diffusion policy on the collected demonstrations.\n", + "Train a \u03c00.5 (pi-zero-point-five) visuomotor diffusion policy on the collected demonstrations.\n", "\n", "### Option A: Physical AI Studio (recommended)\n", "\n", - "Launch a training job from the web UI — select your dataset, choose the π0.5 architecture, and configure training parameters visually.\n", + "Launch a training job from the web UI \u2014 select your dataset, choose the \u03c00.5 architecture, and configure training parameters visually.\n", "\n", "Recommended training parameters:\n", "| Parameter | Value |\n", @@ -113,7 +113,7 @@ "| Compile model | true |\n", "\n", "\n", - "> **Note:** Training π0.5 requires at least **40 GB of VRAM** (e.g. NVIDIA A100).\n", + "> **Note:** Training \u03c00.5 requires at least **40 GB of VRAM** (e.g. NVIDIA A100).\n", "\n", "### Option B: `physicalai-train` library" ] @@ -131,13 +131,13 @@ "from physicalai.policies import Pi05\n", "from physicalai.train import Trainer\n", "\n", - "# ── Config ──\n", + "# \u2500\u2500 Config \u2500\u2500\n", "# Physical AI Studio: ~/.cache/physicalai/datasets//\n", "# LeRobot: data/ (relative to where you ran the collection script)\n", "DATASET_PATH = \"~/.cache/physicalai/datasets//\"\n", "CHECKPOINT_DIR = \"./checkpoints\"\n", "\n", - "# Fine-tune π0.5 from the pretrained base\n", + "# Fine-tune \u03c00.5 from the pretrained base\n", "model = Pi05(\n", " pretrained_name_or_path=\"lerobot/pi05_base\",\n", " dtype=\"bfloat16\",\n", @@ -182,7 +182,7 @@ "source": [ "### Export to OpenVINO\n", "\n", - "If using **Physical AI Studio**, the model is automatically exported to OpenVINO format when you download it — no extra step needed.\n", + "If using **Physical AI Studio**, the model is automatically exported to OpenVINO format when you download it \u2014 no extra step needed.\n", "\n", "If training via the library, export manually:" ] @@ -226,7 +226,7 @@ "metadata": {}, "outputs": [], "source": [ - "# Discover available cameras — use the device IDs from this output in the config below\n", + "# Discover available cameras \u2014 use the device IDs from this output in the config below\n", "from physicalai.capture import discover_all\n", "\n", "for driver, devices in discover_all().items():\n", @@ -234,7 +234,7 @@ " continue\n", " print(f\"\\n[{driver}]\")\n", " for dev in devices:\n", - " print(f\" {dev.device_id} — {dev.name}\")" + " print(f\" {dev.device_id} \u2014 {dev.name}\")" ] }, { @@ -256,7 +256,7 @@ "# LeRobot: ~/.cache/calibration/so101_follower.json\n", "SO101_CALIBRATION = \"~/.local/share/physicalai/robots//calibrations/.json\"\n", "\n", - "# Cameras — use the same names and setup as during dataset collection\n", + "# Cameras \u2014 use the same names and setup as during dataset collection\n", "CAMERAS = [\n", " (\"overhead\", \"uvc\", \"/dev/video0\"),\n", " (\"arm\", \"uvc\", \"/dev/video1\"),\n", @@ -308,14 +308,14 @@ "source": [ "from typing import Any\n", "\n", - "from physicalai.capture import SharedCamera\n", + "from physicalai.capture import SharedCamera, create_camera\n", "from physicalai.robot import SO101\n", "\n", "# Cameras\n", "cameras = {}\n", "for name, driver, device_id in CAMERAS:\n", " kwargs: dict[str, Any] = {\"serial_number\": device_id} if driver == \"realsense\" else {\"device\": device_id}\n", - " cam = SharedCamera(driver, **kwargs, width=CAMERA_WIDTH, height=CAMERA_HEIGHT, fps=CAMERA_FPS)\n", + " cam: SharedCamera = create_camera(driver, shared=True, width=CAMERA_WIDTH, height=CAMERA_HEIGHT, fps=CAMERA_FPS, **kwargs) # type: ignore[assignment]\n", " cam.connect()\n", " cameras[name] = cam\n", " print(f\"Camera '{name}' connected: {cam.actual_width}x{cam.actual_height} @ {cam.actual_fps}fps\")\n", @@ -333,7 +333,7 @@ "source": [ "### Build and run the policy runtime\n", "\n", - "`RobotRuntime` runs the control loop: observe → infer → send actions at the target FPS. The action source (`PolicySource`) adapts the model + execution + action-queue pipeline.\n", + "`RobotRuntime` runs the control loop: observe \u2192 infer \u2192 send actions at the target FPS. The action source (`PolicySource`) adapts the model + execution + action-queue pipeline.\n", "\n", "> **Safety:** The robot will begin moving immediately. Keep your hand near the power switch or e-stop." ] @@ -361,10 +361,10 @@ ")\n", "\n", "with runtime:\n", - " print(f\"Running policy at {FPS} fps for {DURATION_S}s — task: {TASK!r}\")\n", + " print(f\"Running policy at {FPS} fps for {DURATION_S}s \u2014 task: {TASK!r}\")\n", " steps = runtime.run(duration_s=DURATION_S)\n", "\n", - "print(f\"\\nDone — {steps} steps, {policy_source.action_queue.total_pops} actions popped\")" + "print(f\"\\nDone \u2014 {steps} steps, {policy_source.action_queue.total_pops} actions popped\")" ] }, { diff --git a/src/physicalai/capture/camera.py b/src/physicalai/capture/camera.py index 064df50..8c1d982 100644 --- a/src/physicalai/capture/camera.py +++ b/src/physicalai/capture/camera.py @@ -63,14 +63,16 @@ class Camera(ABC): def __init__( self, *, - color_mode: ColorMode = ColorMode.RGB, + color_mode: ColorMode | str = ColorMode.RGB, ) -> None: """Store requested capture parameters. Args: - color_mode: Pixel format for colour image reads. + color_mode: Pixel format for colour image reads. Accepts + :class:`ColorMode` or its string value so + :func:`~physicalai.config.instantiate` round-trips work. """ - self._color_mode = color_mode + self._color_mode = ColorMode(color_mode) self.__executor: ThreadPoolExecutor | None = None # ------------------------------------------------------------------ diff --git a/src/physicalai/capture/cameras/basler/_camera.py b/src/physicalai/capture/cameras/basler/_camera.py index 2b50d71..2b932f7 100644 --- a/src/physicalai/capture/cameras/basler/_camera.py +++ b/src/physicalai/capture/cameras/basler/_camera.py @@ -13,6 +13,7 @@ from physicalai.capture.camera import Camera, ColorMode from physicalai.capture.errors import CaptureError, CaptureTimeoutError, NotConnectedError from physicalai.capture.frame import Frame +from physicalai.config import export_config if TYPE_CHECKING: import numpy as np @@ -20,6 +21,7 @@ from physicalai.capture.discovery import DeviceInfo +@export_config(class_path="physicalai.capture.BaslerCamera") class BaslerCamera(Camera): """Basler camera using pypylon SDK.""" diff --git a/src/physicalai/capture/cameras/realsense/_camera.py b/src/physicalai/capture/cameras/realsense/_camera.py index 6a2340f..b325b39 100644 --- a/src/physicalai/capture/cameras/realsense/_camera.py +++ b/src/physicalai/capture/cameras/realsense/_camera.py @@ -14,11 +14,13 @@ from physicalai.capture.errors import CaptureError, CaptureTimeoutError, NotConnectedError from physicalai.capture.frame import Frame from physicalai.capture.mixins.depth import DepthMixin +from physicalai.config import export_config if TYPE_CHECKING: from physicalai.capture.discovery import DeviceInfo +@export_config(class_path="physicalai.capture.RealSenseCamera") class RealSenseCamera(DepthMixin, Camera): """RealSense color and depth camera.""" diff --git a/src/physicalai/capture/cameras/uvc/_camera.py b/src/physicalai/capture/cameras/uvc/_camera.py index 66d2d95..b8d6b71 100644 --- a/src/physicalai/capture/cameras/uvc/_camera.py +++ b/src/physicalai/capture/cameras/uvc/_camera.py @@ -18,6 +18,7 @@ from physicalai.capture.camera import Camera, ColorMode from physicalai.capture.cameras.uvc._camera_setting import CameraSetting # noqa: PLC2701 +from physicalai.config import export_config if TYPE_CHECKING: from physicalai.capture.cameras.uvc.v4l2 import V4L2Camera @@ -25,6 +26,7 @@ from physicalai.capture.frame import Frame +@export_config(class_path="physicalai.capture.UVCCamera") class UVCCamera(Camera): """Camera facade for UVC devices (USB Video Class). diff --git a/src/physicalai/capture/factory.py b/src/physicalai/capture/factory.py index 96abbec..3a901c3 100644 --- a/src/physicalai/capture/factory.py +++ b/src/physicalai/capture/factory.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from loguru import logger @@ -14,8 +14,17 @@ if TYPE_CHECKING: from physicalai.capture.camera import Camera +_SHARED_TRANSPORT_KEYS = frozenset({ + "zero_copy", + "validate_on_connect", + "overwrite_settings", + "idle_timeout", + "service_name", + "color_mode", +}) -def create_camera(camera_type: str, *, shared: bool = False, **kwargs) -> Camera: # noqa: ANN003 + +def create_camera(camera_type: str, *, shared: bool = False, **kwargs: Any) -> Camera: # noqa: ANN401 """Create a camera by type name. Args: @@ -24,21 +33,59 @@ def create_camera(camera_type: str, *, shared: bool = False, **kwargs) -> Camera Case-insensitive. shared: If True, wrap the camera in a :class:`SharedCamera` (iceoryx2 shared-memory transport). Requires the - ``transport`` extra. - **kwargs: Forwarded to the camera constructor. + ``transport`` extra. Only backends with a real shared registry + entry (``uvc``, ``realsense``, ``basler``) support derived + ``service_name``; stub types must use + :meth:`SharedCamera.from_config` with an explicit + ``service_name`` once a driver exists. + **kwargs: Forwarded to the camera constructor. When *shared* is + True, SharedCamera transport knobs (``zero_copy``, + ``validate_on_connect``, ``overwrite_settings``, + ``idle_timeout``, ``service_name``, ``color_mode``) are peeled + off for the subscriber; remaining kwargs become + ``camera.init_args``. Returns: A new camera instance. Raises: - ValueError: If *camera_type* is not a recognised name. + ValueError: If *camera_type* is not a recognised name, or *shared* + is True for a type without shared service-name derivation. """ camera_type = camera_type.lower() if shared: from physicalai.capture.transport import SharedCamera # noqa: PLC0415 - - return SharedCamera(camera_type, **kwargs) + from physicalai.capture.transport.builtin import ( # noqa: PLC0415 + builtin_class_path_for_type, + builtin_shared_type_tokens, + ) + + class_path = builtin_class_path_for_type(camera_type) + if class_path is None: + if camera_type in {t.value for t in CameraType}: + shareable = ", ".join(sorted(builtin_shared_type_tokens())) + msg = ( + f"camera type {camera_type!r} does not support shared=True " + f"(no shareable driver for service-name derivation); " + f"shareable types: {shareable}. " + "Use SharedCamera.from_config(..., service_name=...) once a " + "driver exists, or create_camera without shared." + ) + raise ValueError(msg) + msg = f"Unknown camera type {camera_type!r}. Expected one of: {', '.join(CameraType)}" + raise ValueError(msg) + + transport: dict[str, Any] = {} + init_args = dict(kwargs) + for key in _SHARED_TRANSPORT_KEYS: + if key in init_args: + transport[key] = init_args.pop(key) + + return SharedCamera( + camera={"class_path": class_path, "init_args": init_args}, + **transport, + ) if camera_type == CameraType.UVC: from physicalai.capture.cameras.uvc import UVCCamera # noqa: PLC0415 diff --git a/src/physicalai/capture/transport/__init__.py b/src/physicalai/capture/transport/__init__.py index 80ed9a3..3549e0f 100644 --- a/src/physicalai/capture/transport/__init__.py +++ b/src/physicalai/capture/transport/__init__.py @@ -4,9 +4,11 @@ """Shared-memory camera transport via iceoryx2. Provides :class:`SharedCamera` as the public entry point for -multi-process camera sharing. Use the constructor for auto-spawn mode -(``SharedCamera(camera_type, ...)``) or -:meth:`SharedCamera.from_publisher` for subscribe-only mode. +multi-process camera sharing. Prefer :meth:`SharedCamera.from_config` +(or YAML); :meth:`SharedCamera.from_camera` is export-only sugar after +disconnect. Or use ``SharedCamera(camera=...)``. Use +:meth:`SharedCamera.from_publisher` for subscribe-only mode. The publisher +owns the device exclusively — do not keep a direct camera open while sharing. Requires the ``transport`` extra:: diff --git a/src/physicalai/capture/transport/_publisher.py b/src/physicalai/capture/transport/_publisher.py index e5284f1..3f7aa38 100644 --- a/src/physicalai/capture/transport/_publisher.py +++ b/src/physicalai/capture/transport/_publisher.py @@ -25,7 +25,7 @@ class CameraPublisher: self-terminates via idle timeout when zero subscribers remain. Args: - spec: Camera construction specification. + spec: Camera construction specification (``camera: ComponentConfig``). service_name: iceoryx2 service name for the pub-sub channel. idle_timeout: Seconds with zero subscribers before self-exit. max_subscribers: Maximum concurrent subscribers. @@ -62,8 +62,7 @@ def start(self, timeout: float = 10.0) -> None: return config: dict = { - "camera_type": self._spec.camera_type, - "camera_kwargs": self._spec.camera_kwargs, + **self._spec.to_json_dict(), "service_name": self._service_name, "idle_timeout": self._idle_timeout, "max_subscribers": self._max_subscribers, @@ -74,9 +73,9 @@ def start(self, timeout: float = 10.0) -> None: # interpreter) plus a hardcoded internal module path. shell=True is not # used, so there is no shell-injection risk. Configuration is delivered # to the worker via stdin as JSON, not as argv arguments; callers are - # responsible for validating spec fields (camera_type, camera_kwargs, - # service_name, _factory_override) before constructing a - # CameraPublisher. + # responsible for validating spec fields before constructing a + # CameraPublisher. No cwd= override — child inherits parent cwd for + # relative path resolution in camera init_args. self._process = subprocess.Popen( # nosec: B603 [sys.executable, "-m", "physicalai.capture.transport._publisher_worker"], stdin=subprocess.PIPE, diff --git a/src/physicalai/capture/transport/_publisher_worker.py b/src/physicalai/capture/transport/_publisher_worker.py index 862c147..2452c48 100644 --- a/src/physicalai/capture/transport/_publisher_worker.py +++ b/src/physicalai/capture/transport/_publisher_worker.py @@ -7,6 +7,7 @@ import contextlib import ctypes +import errno import importlib import json import os @@ -27,9 +28,55 @@ _MAX_CONSECUTIVE_FAILURES = 5 _CONTROL_MAX_SLICE_LEN = 4096 +# Substrings that reliably indicate the device is held by another opener. +_BUSY_MESSAGE_MARKERS = ( + "device or resource busy", + "resource busy", + "already in use", + "device busy", +) + shutdown = threading.Event() +def _looks_like_device_busy(exc: BaseException) -> bool: + """Return True when *exc* (or its cause chain) indicates a busy device. + + Only matches ``errno.EBUSY`` and well-known busy message substrings — + does not guess from generic permission or open failures. + """ + seen: set[int] = set() + current: BaseException | None = exc + while current is not None and id(current) not in seen: + seen.add(id(current)) + if isinstance(current, OSError) and current.errno == errno.EBUSY: + return True + text = str(current).lower() + if any(marker in text for marker in _BUSY_MESSAGE_MARKERS): + return True + current = current.__cause__ or current.__context__ + return False + + +def _format_camera_open_error(exc: BaseException) -> str: + """Format a camera open/connect failure for the READY/ERROR handshake. + + When a busy device is detectable, append exclusive-ownership guidance. + + Returns: + Error string for the publisher ``ERROR:`` handshake line. + """ + base = f"{type(exc).__name__}: {exc}" + if not _looks_like_device_busy(exc): + return base + return ( + f"{base}. The publisher subprocess opens the camera exclusively; " + "another process or a still-connected direct Camera holding the same " + "device will cause open to fail. Disconnect other holders and prefer " + "SharedCamera.from_config without keeping a live direct camera open." + ) + + def sigterm_handler(_signum: int, _frame: FrameType | None) -> None: shutdown.set() @@ -85,18 +132,25 @@ def build_camera(config: dict) -> Camera: """Instantiate a camera from a JSON config dict. Args: - config: Configuration dict with camera_type, camera_kwargs, and - optional _factory_override. + config: Publisher envelope with ``camera: ComponentConfig``, and + optional ``_factory_override`` for tests. Returns: - Connected camera instance. + Camera instance (not yet connected). """ factory_override = config.get("_factory_override") if factory_override: + from physicalai.capture.transport._spec import CameraSpec # noqa: PLC0415 + + # Validate / reject flat keys even on the factory-override path. + spec = CameraSpec.from_json_dict(config) module_path, _, attr = factory_override.rpartition(":") mod = importlib.import_module(module_path) factory = getattr(mod, attr) - return factory(**config.get("camera_kwargs", {})) + init_args = spec.camera.get("init_args", {}) + if not isinstance(init_args, dict): + init_args = {} + return factory(**init_args) from physicalai.capture.transport._spec import CameraSpec # noqa: PLC0415, PLC2701 @@ -105,16 +159,19 @@ def build_camera(config: dict) -> Camera: def _camera_fps_from_config(config: dict[str, object]) -> int: - """Extract fps from camera config when camera_kwargs is mapping-like. + """Extract fps from camera ComponentConfig init_args. Returns: - Requested fps value, or 0 when camera_kwargs is absent or invalid. + Requested fps value, or 0 when absent or invalid. """ - camera_kwargs = config.get("camera_kwargs") - if not isinstance(camera_kwargs, dict): + camera = config.get("camera") + if not isinstance(camera, dict): + return 0 + init_args = camera.get("init_args") + if not isinstance(init_args, dict): return 0 - fps = camera_kwargs.get("fps", 0) + fps = init_args.get("fps", 0) return int(fps) @@ -222,9 +279,17 @@ def _handle_reconfigure(state: _PublisherState, request: dict, service_name: str old_camera = state.camera old_fps = state.camera_fps - new_config = { - "camera_type": spec_data.get("camera_type", old_config.get("camera_type")), - "camera_kwargs": spec_data.get("camera_kwargs", {}), + from physicalai.config import ComponentConfigError # noqa: PLC0415 + + from ._spec import validate_publisher_config # noqa: PLC0415 + + try: + camera_cfg = validate_publisher_config(spec_data) + except (TypeError, ValueError, ComponentConfigError) as exc: + return {"ok": False, "error": f"invalid reconfigure spec: {exc}"} + + new_config: dict[str, object] = { + "camera": dict(camera_cfg), "service_name": service_name, } # Preserve factory override if present in original config @@ -241,6 +306,7 @@ def _handle_reconfigure(state: _PublisherState, request: dict, service_name: str new_camera.connect() except Exception as exc: # noqa: BLE001 logger.error(f"Reconfigure failed for {service_name}: {exc}. Attempting restore.") + open_err = _format_camera_open_error(exc) try: restored_camera = build_camera(old_config) restored_camera.connect() @@ -252,8 +318,8 @@ def _handle_reconfigure(state: _PublisherState, request: dict, service_name: str f"Cannot restore old config for {service_name} — shutting down.", ) shutdown.set() - return {"ok": False, "error": f"reconfigure failed and restore failed: {exc}"} - return {"ok": False, "error": str(exc)} + return {"ok": False, "error": f"reconfigure failed and restore failed: {open_err}"} + return {"ok": False, "error": open_err} new_fps = _camera_fps_from_config(new_config) state.camera = new_camera @@ -344,7 +410,7 @@ def main() -> int: # noqa: C901, PLR0912, PLR0914, PLR0915 if saved_stdout_fd is not None: restore_stdout(saved_stdout_fd) saved_stdout_fd = None - signal_error(f"{type(exc).__name__}: {exc}", tb=traceback.format_exc()) + signal_error(_format_camera_open_error(exc), tb=traceback.format_exc()) if camera is not None: try: camera.disconnect() diff --git a/src/physicalai/capture/transport/_shared_camera.py b/src/physicalai/capture/transport/_shared_camera.py index 09e6485..f232363 100644 --- a/src/physicalai/capture/transport/_shared_camera.py +++ b/src/physicalai/capture/transport/_shared_camera.py @@ -1,7 +1,16 @@ # Copyright (C) 2026 Intel Corporation # SPDX-License-Identifier: Apache-2.0 -"""Shared-memory camera subscriber transport based on iceoryx2.""" +"""Shared-memory camera subscriber transport based on iceoryx2. + +Construction is :class:`~physicalai.config.ComponentConfig`-only via +``camera=``, :meth:`SharedCamera.from_config`, or :meth:`from_camera`. +Prefer :meth:`from_config` when sharing; :meth:`from_camera` is export-only +sugar after disconnect. Pass ``camera=None`` with ``service_name`` (or use +:meth:`from_publisher`) for attach-only. Flat ``camera_type`` / +``camera_kwargs`` are unsupported. The publisher owns the device exclusively — +do not keep a direct camera open on the same hardware while sharing. +""" from __future__ import annotations @@ -9,21 +18,22 @@ import ctypes import time from importlib import import_module -from pathlib import Path from typing import TYPE_CHECKING, Any, cast from loguru import logger -from physicalai.capture.camera import Camera, CameraType, ColorMode +from physicalai.capture.camera import Camera, ColorMode from physicalai.capture.errors import CaptureError, CaptureTimeoutError, NotConnectedError from ._header import FrameHeader, decode_header, decode_rgb +from ._spec import derive_service_name, normalize_camera_config if TYPE_CHECKING: from collections.abc import Mapping from physicalai.capture.frame import Frame from physicalai.capture.transport._publisher import CameraPublisher + from physicalai.config import ComponentConfig _SERVICE_NAME_EXPECTED_PARTS = 5 @@ -74,16 +84,6 @@ def _probe_with_retry(service_name: str, timeout: float, interval: float = 0.1) time.sleep(interval) -def _derive_service_name(camera_type: str, camera_kwargs: Mapping[str, object]) -> str: - device_id = camera_kwargs.get("serial_number", camera_kwargs.get("device", 0)) - # Resolve symlinks so that /dev/v4l/by-id/... and /dev/videoN produce - # the same service name for the same physical device. - if isinstance(device_id, str) and device_id.startswith("/dev/"): - resolved = Path(device_id).resolve() - device_id = resolved.name - return f"physicalai/camera/{camera_type}/{device_id}/frame" - - class SharedCamera(Camera): """Camera subscriber that reads frames from shared memory via iceoryx2. @@ -91,21 +91,32 @@ class SharedCamera(Camera): Multiple SharedCamera instances can subscribe to the same publisher for zero-copy fan-out. + Prefer :meth:`from_config` when sharing. :meth:`from_camera` is export-only + sugar after disconnect — never keep a direct camera open while sharing. + The constructor takes ``camera: ComponentConfig`` to spawn, or + ``camera=None`` + ``service_name`` for attach-only (:meth:`from_publisher` + is the explicit form). + + The publisher subprocess owns the device exclusively. Another connected + holder of the same hardware will cause open to fail; this API does not + hand off an already-open device into the child. + Args: - camera_type: Logical camera type (auto-spawn mode), or ``None`` to - subscribe to an existing publisher only. - color_mode: Pixel format for returned frames. + camera: Trusted camera :class:`~physicalai.config.ComponentConfig` to + spawn if no publisher exists yet for the derived or explicit + ``service_name``. ``None`` means attach-only. + color_mode: Pixel format preference for this subscriber. zero_copy: If True, returned frames reference the iceoryx2 SHM buffer directly (read-only). Otherwise, frames are copied. - service_name: Override the iceoryx2 service name. Defaults to one - derived from ``camera_type`` and identifying ``camera_kwargs``. + service_name: iceoryx2 service name. Derived for built-in + ``class_path`` values when omitted; required for third-party + cameras and for attach-only. validate_on_connect: If True and an existing publisher's frame dimensions do not match ``width``/``height`` in - ``camera_kwargs``, :meth:`connect` raises + ``camera.init_args``, :meth:`connect` raises :class:`CaptureError`. If False (default), the mismatch is logged as a warning and the existing publisher's resolution - is used. This validation applies only during - :meth:`connect`. + is used. overwrite_settings: If True, attempt to reconfigure the publisher to match requested settings on config mismatch. Requires the publisher to support the control channel (phase 2+). @@ -117,39 +128,28 @@ class SharedCamera(Camera): def __init__( self, - camera_type: CameraType | str | None, *, - color_mode: ColorMode = ColorMode.RGB, + camera: ComponentConfig | Mapping[str, object] | None = None, + color_mode: ColorMode | str = ColorMode.RGB, zero_copy: bool = False, service_name: str | None = None, validate_on_connect: bool = False, overwrite_settings: bool = False, idle_timeout: float = 5.0, - camera_kwargs: Mapping[str, object] | None = None, - **extra_camera_kwargs: object, ) -> None: - try: - camera_type = CameraType(camera_type) if camera_type is not None else None - except ValueError as exc: - valid = ", ".join(m.value for m in CameraType) - msg = f"unknown camera_type {camera_type!r}; expected one of: {valid}" - raise ValueError(msg) from exc - - if camera_type is None and service_name is None: - msg = "must provide camera_type or service_name" + if camera is None and service_name is None: + msg = "must provide camera ComponentConfig or service_name" raise ValueError(msg) - merged_camera_kwargs: dict[str, object] = {**(camera_kwargs or {}), **extra_camera_kwargs} - - if camera_type is not None: - service_name = _derive_service_name(camera_type, merged_camera_kwargs) + normalized = None if camera is None else normalize_camera_config(camera) + if normalized is not None: + service_name = derive_service_name(normalized, service_name=service_name) elif service_name is None: - msg = "service_name must be provided if camera_type is None" + msg = "service_name must be provided if camera is None" raise ValueError(msg) super().__init__(color_mode=color_mode) - self._camera_type = camera_type - self._camera_kwargs = merged_camera_kwargs + self._camera = normalized self._service_name: str = service_name self._zero_copy = zero_copy self._validate_on_connect = validate_on_connect @@ -165,19 +165,137 @@ def __init__( self._subscriber: Any | None = None self._listener: Any | None = None + @classmethod + def from_config( + cls, + config: ComponentConfig | Mapping[str, object], + *, + service_name: str | None = None, + color_mode: ColorMode | str = ColorMode.RGB, + zero_copy: bool = False, + validate_on_connect: bool = False, + overwrite_settings: bool = False, + idle_timeout: float = 5.0, + ) -> SharedCamera: + """Primary API: spawn/attach from a trusted camera ComponentConfig. + + Args: + config: Trusted ``class_path`` + ``init_args`` for the camera. + service_name: Explicit iceoryx2 name; derived for built-ins when omitted. + color_mode: Subscriber pixel-format preference. + zero_copy: Whether frames reference SHM directly. + validate_on_connect: Raise on resolution mismatch at connect. + overwrite_settings: Reconfigure publisher on mismatch. + idle_timeout: Idle self-exit timeout for a spawned publisher. + + Returns: + A ``SharedCamera`` that stores the normalized ComponentConfig and + writes only the new publisher stdin shape on spawn. + """ + return cls( + camera=config, + service_name=service_name, + color_mode=color_mode, + zero_copy=zero_copy, + validate_on_connect=validate_on_connect, + overwrite_settings=overwrite_settings, + idle_timeout=idle_timeout, + ) + + @classmethod + def from_camera( + cls, + camera: Camera, + *, + service_name: str | None = None, + color_mode: ColorMode | str = ColorMode.RGB, + zero_copy: bool = False, + validate_on_connect: bool = False, + overwrite_settings: bool = False, + idle_timeout: float = 5.0, + ) -> SharedCamera: + """Export-only sugar over :meth:`from_config` for an opted-in live camera. + + Prefer :meth:`from_config` / YAML when sharing. This method only + exports a construction recipe via :func:`~physicalai.config.to_config`; + the publisher subprocess opens the device fresh. It does **not** hand + off an already-open device into the child. Requires + :func:`~physicalai.config.is_config_exportable`. Rejects a connected + camera and never disconnects a caller-owned instance implicitly — + disconnect before calling. Other process/device holders are not + detected here; a busy device fails later when the publisher opens. + + Args: + camera: Disconnected, ``@export_config``-marked camera instance. + service_name: Explicit iceoryx2 name; derived for built-ins when omitted. + color_mode: Subscriber pixel-format preference. + zero_copy: Whether frames reference SHM directly. + validate_on_connect: Raise on resolution mismatch at connect. + overwrite_settings: Reconfigure publisher on mismatch. + idle_timeout: Idle self-exit timeout for a spawned publisher. + + Returns: + A ``SharedCamera`` built from :func:`~physicalai.config.to_config`. + + Raises: + ValueError: If *camera* is not exportable or is still connected. + """ + from physicalai.config import is_config_exportable, to_config # noqa: PLC0415 + + if not is_config_exportable(camera): + msg = ( + f"{type(camera).__module__}.{type(camera).__qualname__} is not " + "config-exportable; decorate with @export_config or pass from_config(...)" + ) + raise ValueError(msg) + if camera.is_connected: + msg = ( + "SharedCamera.from_camera() is export-only sugar and requires a " + "disconnected camera; the publisher opens the device fresh and " + "does not take over a live handle. Disconnect explicitly before " + "sharing, or prefer from_config(to_config(...)) / YAML without " + "keeping a direct camera open" + ) + raise ValueError(msg) + return cls.from_config( + to_config(camera), + service_name=service_name, + color_mode=color_mode, + zero_copy=zero_copy, + validate_on_connect=validate_on_connect, + overwrite_settings=overwrite_settings, + idle_timeout=idle_timeout, + ) + @classmethod def from_publisher( cls, service_name: str, *, - color_mode: ColorMode = ColorMode.RGB, + color_mode: ColorMode | str = ColorMode.RGB, zero_copy: bool = False, validate_on_connect: bool = False, overwrite_settings: bool = False, idle_timeout: float = 5.0, ) -> SharedCamera: + """Attach-only construction: subscribe to an existing publisher by name. + + Never spawns a publisher — :meth:`connect` times out if none is + reachable. + + Args: + service_name: iceoryx2 service name of an existing publisher. + color_mode: Subscriber pixel-format preference. + zero_copy: Whether frames reference SHM directly. + validate_on_connect: Raise on resolution mismatch at connect. + overwrite_settings: Reconfigure publisher on mismatch. + idle_timeout: Unused for attach-only (no spawn); kept for API parity. + + Returns: + An attach-only ``SharedCamera``. + """ return cls( - None, + camera=None, color_mode=color_mode, zero_copy=zero_copy, service_name=service_name, @@ -190,11 +308,11 @@ def connect(self, timeout: float = 5.0) -> None: if self._connected: return - if self._camera_type is not None and not _probe_service(self._service_name): + if self._camera is not None and not _probe_service(self._service_name): from ._publisher import CameraPublisher # noqa: PLC0415 from ._spec import CameraSpec # noqa: PLC0415 - spec = CameraSpec(self._camera_type, self._camera_kwargs) + spec = CameraSpec(camera=self._camera) publisher = CameraPublisher(spec, self._service_name, idle_timeout=self._idle_timeout) try: publisher.start() @@ -202,7 +320,11 @@ def connect(self, timeout: float = 5.0) -> None: if _probe_with_retry(self._service_name, timeout=3.0): logger.debug(f"Lost publisher race for {self._service_name} — using existing") else: - msg = f"failed to start camera publisher for {self._service_name}" + msg = ( + f"failed to start camera publisher for {self._service_name}: {exc}. " + "The publisher opens the device exclusively; another connected " + "holder of the same hardware will cause open to fail." + ) raise CaptureError(msg) from exc else: self._publisher = publisher @@ -375,6 +497,10 @@ def _request_reconfigure(self, timeout: float = 5.0) -> dict: """ import json # noqa: PLC0415 + if self._camera is None: + msg = "reconfigure requires a camera ComponentConfig (attach-only SharedCamera has none)" + raise CaptureError(msg) + iox2 = cast("Any", import_module("iceoryx2")) control_name = f"{self._service_name}/control" @@ -391,12 +517,13 @@ def _request_reconfigure(self, timeout: float = 5.0) -> dict: msg = f"publisher does not support reconfigure (no control service at {control_name})" raise CaptureError(msg) from exc - camera_type = self._camera_type.value if self._camera_type is not None else None request_payload = json.dumps({ "kind": "RECONFIGURE", "spec": { - "camera_type": camera_type, - "camera_kwargs": dict(self._camera_kwargs), + "camera": { + "class_path": self._camera["class_path"], + "init_args": dict(self._camera["init_args"]), # type: ignore[arg-type] + }, }, }).encode() @@ -507,14 +634,19 @@ def _check_config_match(self, header: FrameHeader) -> None: ) def _detect_mismatch(self, header: FrameHeader) -> tuple[str, str] | None: - """Compare header against requested kwargs. + """Compare header against requested camera init_args. Returns: ``(want_str, actual_str)`` if mismatch found, ``None`` otherwise. """ - want_w = self._camera_kwargs.get("width") - want_h = self._camera_kwargs.get("height") - want_fps = self._camera_kwargs.get("fps") + if self._camera is None: + return None + init_args = self._camera.get("init_args", {}) + if not isinstance(init_args, dict): + return None + want_w = init_args.get("width") + want_h = init_args.get("height") + want_fps = init_args.get("fps") if want_w is None and want_h is None and want_fps is None: return None diff --git a/src/physicalai/capture/transport/_spec.py b/src/physicalai/capture/transport/_spec.py index 69b5f11..90d28c7 100644 --- a/src/physicalai/capture/transport/_spec.py +++ b/src/physicalai/capture/transport/_spec.py @@ -1,68 +1,281 @@ # Copyright (C) 2026 Intel Corporation # SPDX-License-Identifier: Apache-2.0 -"""Serializable camera construction spec for transport endpoints.""" +"""Serializable camera construction spec for transport endpoints. + +Private publisher stdin is ``camera: ComponentConfig`` only. The publisher +envelope is validated schema-positively: required ``camera``, known transport +keys, and rejection of unknown keys (including legacy flat +``camera_type`` / ``camera_kwargs``) before import or hardware access. Public +``SharedCamera`` uses the same ``camera`` / :meth:`~SharedCamera.from_config` +shape. + +Security: ``class_path`` is trusted local application/config input. It must +never originate from network-received data. +""" from __future__ import annotations -from dataclasses import dataclass, field +import json +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING, Any +from physicalai.config import ( + ComponentConfig, + ComponentConfigError, + instantiate, + resolve_public_class_path, + validate_component_config, +) +from physicalai.config.importing import import_dotted_path + +from .builtin import builtin_type_for_class_path + if TYPE_CHECKING: from physicalai.capture.camera import Camera +# Allowed keys on publisher stdin and reconfigure ``spec`` payloads. +# Everything else (including legacy flat camera_type / camera_kwargs) is an +# unknown-key schema error. Keep this the single allowlist — stdin parse and +# reconfigure share :func:`validate_publisher_config`. +_PUBLISHER_ENVELOPE_KEYS = frozenset({ + "camera", + "service_name", + "idle_timeout", + "max_subscribers", + "_factory_override", +}) + + +def validate_publisher_config(data: Mapping[str, Any]) -> Mapping[str, object]: + """Validate a publisher stdin or reconfigure payload schema-positively. + + Requires ``camera`` with a valid ComponentConfig shape. Allows only known + transport envelope keys. Unknown keys (including legacy flat + ``camera_type`` / ``camera_kwargs``) raise a clear schema error. + + Args: + data: Full publisher stdin dict or a reconfigure ``spec`` fragment. + + Returns: + The validated ``camera`` ComponentConfig mapping (not yet + public-path-normalized — :class:`CameraSpec` / ``normalize_camera_config`` + do that). + + Raises: + TypeError: If *data* is not a mapping, or ``camera`` is not a mapping. + ValueError: If required ``camera`` is missing or unknown keys are present. + """ + if not isinstance(data, Mapping): + msg = f"publisher config must be a mapping, got {type(data).__name__}" + raise TypeError(msg) + + unknown = sorted(set(data) - _PUBLISHER_ENVELOPE_KEYS) + if unknown: + msg = ( + f"unknown publisher config keys {unknown}; " + "require 'camera' with class_path + init_args " + f"(allowed envelope keys: {sorted(_PUBLISHER_ENVELOPE_KEYS)})" + ) + raise ValueError(msg) + + if "camera" not in data: + msg = "publisher config missing required 'camera' ComponentConfig" + raise ValueError(msg) + + camera = data["camera"] + if not isinstance(camera, Mapping): + msg = f"publisher 'camera' must be a mapping, got {type(camera).__name__}" + raise TypeError(msg) + + return validate_component_config(dict(camera), path="camera") + + +def normalize_camera_class(camera_class: type | str) -> str: + """Normalize a camera class reference to its public import path. + + Args: + camera_class: A class object, or its dotted import path as a string. + + Returns: + The normalized public dotted path. + + Raises: + TypeError: If *camera_class* is neither a string nor a class, or a + string path does not resolve to a class. + ValueError: If a class object is a local class, or the public path + cannot be resolved. + """ + if isinstance(camera_class, str): + try: + resolved = import_dotted_path(camera_class) + except (ValueError, ImportError, AttributeError) as exc: + msg = f"could not import camera class_path {camera_class!r}: {exc}" + raise ValueError(msg) from exc + if not isinstance(resolved, type): + msg = f"camera class_path {camera_class!r} does not resolve to a class (got {type(resolved).__name__})" + raise TypeError(msg) + camera_class = resolved + if not isinstance(camera_class, type): + msg = f"camera class_path must be a class or a dotted path string, got {type(camera_class).__name__}" + raise TypeError(msg) + + try: + return resolve_public_class_path(camera_class) + except ComponentConfigError as exc: + raise ValueError(str(exc)) from exc + + +def normalize_camera_config(camera: Mapping[str, object]) -> ComponentConfig: + """Validate a camera ComponentConfig and normalize ``class_path``. + + Args: + camera: Candidate ``class_path`` + ``init_args`` mapping. + + Returns: + A validated config whose ``class_path`` is the public import path. + + Raises: + ValueError: If ``class_path`` cannot be imported or is not JSON-safe + together with ``init_args``. + """ + validated = validate_component_config(dict(camera), path="camera") + class_path = normalize_camera_class(validated["class_path"]) + init_args = validated["init_args"] + try: + json.dumps({"class_path": class_path, "init_args": init_args}) + except TypeError as exc: + msg = f"camera.init_args must be JSON-serializable: {exc}" + raise ValueError(msg) from exc + return {"class_path": class_path, "init_args": dict(init_args)} + + +def derive_service_name( + camera: Mapping[str, object], + *, + service_name: str | None = None, +) -> str: + """Resolve iceoryx2 ``service_name`` for a camera ComponentConfig. + + Built-in public class paths derive ``physicalai/camera/{token}/{device_id}/frame`` + via the transport class-path → type-token map. Third-party / unknown + class paths (including stub types without a shared registry entry) require + an explicit *service_name*. + + Args: + camera: Normalized or raw camera ComponentConfig. + service_name: Explicit override; when set, returned unchanged. + + Returns: + Concrete service name for the publisher envelope. + + Raises: + ValueError: If *service_name* is omitted for a non-built-in class_path. + """ + if service_name is not None: + return service_name + + class_path = str(camera["class_path"]) + token = builtin_type_for_class_path(class_path) + if token is None: + msg = ( + f"camera {class_path!r} requires an explicit service_name; " + "built-in derivation only covers shareable physicalai.capture " + "backends (uvc, realsense, basler)" + ) + raise ValueError(msg) + + init_args = camera.get("init_args", {}) + if not isinstance(init_args, Mapping): + init_args = {} + device_id = init_args.get("serial_number", init_args.get("device", 0)) + # Resolve symlinks so that /dev/v4l/by-id/... and /dev/videoN produce + # the same service name for the same physical device. + if isinstance(device_id, str) and device_id.startswith("/dev/"): + device_id = Path(device_id).resolve().name + return f"physicalai/camera/{token}/{device_id}/frame" + @dataclass(frozen=True) class CameraSpec: """Config payload describing how to construct a camera instance. Attributes: - camera_type: Logical camera type passed to :func:`create_camera`. - camera_kwargs: Keyword arguments forwarded to the camera constructor. + camera: Trusted construction config (``class_path`` + ``init_args``). """ - camera_type: str - camera_kwargs: dict[str, Any] = field(default_factory=dict) + camera: Mapping[str, object] - # ------------------------------------------------------------------ - # JSON serialization (used by subprocess publisher protocol) - # ------------------------------------------------------------------ + def __post_init__(self) -> None: + """Normalize ``camera`` to a public ComponentConfig.""" + object.__setattr__(self, "camera", normalize_camera_config(self.camera)) def to_json_dict(self) -> dict[str, Any]: - """Serialize to a JSON-safe dictionary. + """Serialize the construction fragment for the stdin handshake. Returns: - Dictionary with ``camera_type`` and ``camera_kwargs`` keys. + Dictionary with ``camera`` only (transport fields are merged by + the publisher). + + Raises: + TypeError: If ``camera.init_args`` is not a mapping. """ - return {"camera_type": self.camera_type, "camera_kwargs": self.camera_kwargs} + init_args = self.camera["init_args"] + if not isinstance(init_args, dict): + msg = f"camera.init_args must be a mapping, got {type(init_args).__name__}" + raise TypeError(msg) + return { + "camera": { + "class_path": self.camera["class_path"], + "init_args": dict(init_args), + }, + } @classmethod def from_json_dict(cls, data: dict[str, Any]) -> CameraSpec: - """Deserialize from a JSON dictionary. + """Deserialize from a JSON dictionary (full publisher envelope or fragment). + + Uses :func:`validate_publisher_config` so stdin and reconfigure share + one schema (required ``camera``, known transport keys, unknown keys + rejected). Args: - data: Dictionary with ``camera_type`` and optional - ``camera_kwargs`` keys. + data: Dictionary produced by :meth:`to_json_dict` or a publisher + stdin envelope containing ``camera``. Returns: A new :class:`CameraSpec` instance. + + Raises: + TypeError: If ``data`` is not a mapping. """ - return cls( - camera_type=data["camera_type"], - camera_kwargs=data.get("camera_kwargs", {}), - ) + if not isinstance(data, dict): + msg = f"camera spec must be a mapping, got {type(data).__name__}" + raise TypeError(msg) - # ------------------------------------------------------------------ - # Factory - # ------------------------------------------------------------------ + camera = validate_publisher_config(data) + return cls(camera=camera) def build(self) -> Camera: """Instantiate the camera described by this spec. + Uses :func:`physicalai.config.instantiate` on the trusted ``camera`` + ComponentConfig, then verifies the :class:`~physicalai.capture.camera.Camera` + protocol. Does not route through :func:`~physicalai.capture.create_camera`, + so third-party class paths work without a registry entry. + Returns: - A new camera instance configured from ``camera_type`` and - ``camera_kwargs``. + A new, not-yet-connected camera instance. + + Raises: + TypeError: If the instantiated object does not satisfy ``Camera``. """ - from physicalai.capture.factory import create_camera # noqa: PLC0415 + from physicalai.capture.camera import Camera # noqa: PLC0415 - return create_camera(self.camera_type, **self.camera_kwargs) + driver = instantiate(self.camera) # type: ignore[arg-type] + if not isinstance(driver, Camera): + msg = f"{self.camera['class_path']!r} does not satisfy the Camera protocol (got {type(driver).__name__})" + raise TypeError(msg) + return driver diff --git a/src/physicalai/capture/transport/builtin.py b/src/physicalai/capture/transport/builtin.py new file mode 100644 index 0000000..827eefa --- /dev/null +++ b/src/physicalai/capture/transport/builtin.py @@ -0,0 +1,92 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Built-in camera type token ↔ public class_path for SharedCamera. + +Single source of truth for service-name derivation and +``create_camera(..., shared=True)``. Only backends with a real driver +package under ``cameras/`` are listed — IP/Genicam stubs are intentionally +absent so shared spawn cannot claim phantom coverage. +""" + +from __future__ import annotations + +import importlib +from functools import lru_cache + +from physicalai.config import ComponentConfigError, resolve_public_class_path + +# token → (import module, attribute, decorator-declared public class_path). +# Public paths match ``@export_config(class_path=...)`` on each driver. +# Fallback strings keep service_name derivation working when an optional +# camera extra is not installed (must not import pyrealsense2/pypylon). +_BUILTIN_SHARED: dict[str, tuple[str, str, str]] = { + "uvc": ( + "physicalai.capture.cameras.uvc", + "UVCCamera", + "physicalai.capture.UVCCamera", + ), + "realsense": ( + "physicalai.capture.cameras.realsense", + "RealSenseCamera", + "physicalai.capture.RealSenseCamera", + ), + "basler": ( + "physicalai.capture.cameras.basler", + "BaslerCamera", + "physicalai.capture.BaslerCamera", + ), +} + + +@lru_cache(maxsize=1) +def _token_to_class_path() -> dict[str, str]: + """Resolve public class_path per token, preferring live ``@export_config``. + + Returns: + Mapping from shareable type token to public ``class_path``. + """ + resolved: dict[str, str] = {} + for token, (module_name, attr, fallback) in _BUILTIN_SHARED.items(): + try: + module = importlib.import_module(module_name) + cls = getattr(module, attr) + resolved[token] = resolve_public_class_path(cls) + except (ImportError, AttributeError, ComponentConfigError, TypeError, ValueError): + resolved[token] = fallback + return resolved + + +@lru_cache(maxsize=1) +def _class_path_to_token() -> dict[str, str]: + return {path: token for token, path in _token_to_class_path().items()} + + +def builtin_shared_type_tokens() -> frozenset[str]: + """Return CameraType tokens that support shared service-name derivation.""" + return frozenset(_BUILTIN_SHARED) + + +def builtin_class_path_for_type(token: str) -> str | None: + r"""Return the public class_path for a built-in shared camera type token. + + Args: + token: Lowercase ``CameraType`` value (e.g. ``\"uvc\"``). + + Returns: + Public ``class_path``, or ``None`` if the token is not a shareable built-in. + """ + return _token_to_class_path().get(token) + + +def builtin_type_for_class_path(class_path: str) -> str | None: + """Return the legacy type token for a built-in public class_path. + + Args: + class_path: Normalized public camera ``class_path``. + + Returns: + Type token (``uvc`` / ``realsense`` / ``basler``), or ``None`` for + third-party / stub / unknown paths. + """ + return _class_path_to_token().get(class_path) diff --git a/tests/unit/capture/conftest.py b/tests/unit/capture/conftest.py index e031109..f60d5c3 100644 --- a/tests/unit/capture/conftest.py +++ b/tests/unit/capture/conftest.py @@ -6,7 +6,6 @@ from __future__ import annotations import importlib.util -import sys from typing import TYPE_CHECKING import pytest @@ -18,12 +17,16 @@ HAS_ICEORYX2 = importlib.util.find_spec("iceoryx2") is not None +FAKE_CAMERA_CLASS = "tests.unit.capture.fake.FakeCamera" + @pytest.fixture def fake_camera_spec() -> CameraSpec: from physicalai.capture.transport._spec import CameraSpec # noqa: PLC0415 - return CameraSpec(camera_type="fake", camera_kwargs={}) + return CameraSpec( + camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {}}, + ) @pytest.fixture diff --git a/tests/unit/capture/fake.py b/tests/unit/capture/fake.py index 5121b21..58bfa05 100644 --- a/tests/unit/capture/fake.py +++ b/tests/unit/capture/fake.py @@ -17,8 +17,10 @@ from physicalai.capture.camera import Camera, ColorMode from physicalai.capture.errors import NotConnectedError from physicalai.capture.frame import Frame +from physicalai.config import export_config +@export_config class FakeCamera(Camera): """In-memory camera that produces synthetic frames. diff --git a/tests/unit/capture/test_camera_component_config.py b/tests/unit/capture/test_camera_component_config.py new file mode 100644 index 0000000..72979c9 --- /dev/null +++ b/tests/unit/capture/test_camera_component_config.py @@ -0,0 +1,100 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +# ruff:file-ignore[undocumented-public-module, undocumented-public-class, undocumented-public-method, undocumented-public-init, assert, magic-value-comparison] + +"""Construction round-trips for camera ``@export_config`` wiring.""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Generator +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from physicalai.capture.camera import Camera, ColorMode +from physicalai.config import instantiate, is_config_exportable, to_config + + +def _assert_construction_round_trip(camera: object) -> dict[str, Any]: + assert is_config_exportable(camera) + assert isinstance(camera, Camera) + config = to_config(camera) + wire: dict[str, Any] = json.loads(json.dumps(config)) + restored = instantiate(wire) + assert type(restored) is type(camera) + assert to_config(restored) == wire + assert not restored.is_connected # type: ignore[union-attr] + return wire + + +class TestUVCCameraComponentConfig: + def test_v4l2_backend_round_trip(self) -> None: + from physicalai.capture import UVCCamera + + camera = UVCCamera(device=0, width=640, height=480, fps=30, backend="v4l2") + wire = _assert_construction_round_trip(camera) + assert wire["class_path"] == "physicalai.capture.UVCCamera" + assert wire["init_args"]["device"] == 0 + assert wire["init_args"]["backend"] == "v4l2" + assert wire["init_args"]["width"] == 640 + + def test_color_mode_enum_round_trips_as_string(self) -> None: + from physicalai.capture import UVCCamera + + camera = UVCCamera(device=1, color_mode=ColorMode.BGR, backend="v4l2") + wire = _assert_construction_round_trip(camera) + assert wire["init_args"]["color_mode"] == "bgr" + + +@pytest.fixture +def mock_pyrealsense2() -> Generator[MagicMock, None, None]: + mock_rs = MagicMock() + with patch.dict(sys.modules, {"pyrealsense2": mock_rs}): + # Force reload if already imported with real/missing SDK. + sys.modules.pop("physicalai.capture.cameras.realsense._camera", None) + sys.modules.pop("physicalai.capture.cameras.realsense", None) + yield mock_rs + + +class TestRealSenseCameraComponentConfig: + def test_round_trip(self, mock_pyrealsense2: MagicMock) -> None: + from physicalai.capture.cameras.realsense import RealSenseCamera + + camera = RealSenseCamera(serial_number="SN123", width=640, height=480, fps=30) + wire = _assert_construction_round_trip(camera) + assert wire["class_path"] == "physicalai.capture.RealSenseCamera" + assert wire["init_args"] == { + "serial_number": "SN123", + "width": 640, + "height": 480, + "fps": 30, + } + + +@pytest.fixture +def mock_pypylon() -> Generator[None, None, None]: + mock_pylon = MagicMock() + mock_genicam = MagicMock() + with ( + patch.dict(sys.modules, {"pypylon": MagicMock(), "pypylon.pylon": mock_pylon, "pypylon.genicam": mock_genicam}), + patch.dict(sys.modules, {"cv2": MagicMock()}), + ): + sys.modules.pop("physicalai.capture.cameras.basler._camera", None) + sys.modules.pop("physicalai.capture.cameras.basler", None) + yield + + +class TestBaslerCameraComponentConfig: + def test_round_trip(self, mock_pypylon: None) -> None: + from physicalai.capture.cameras.basler import BaslerCamera + + camera = BaslerCamera(serial_number="BAS-1", fps=15, width=800, height=600) + wire = _assert_construction_round_trip(camera) + assert wire["class_path"] == "physicalai.capture.BaslerCamera" + assert wire["init_args"]["serial_number"] == "BAS-1" + assert wire["init_args"]["fps"] == 15 + assert wire["init_args"]["width"] == 800 + assert wire["init_args"]["height"] == 600 diff --git a/tests/unit/capture/test_factory.py b/tests/unit/capture/test_factory.py index aa32952..427cfae 100644 --- a/tests/unit/capture/test_factory.py +++ b/tests/unit/capture/test_factory.py @@ -6,8 +6,12 @@ import pytest from physicalai.capture.discovery import discover_all -from physicalai.capture.errors import MissingDependencyError from physicalai.capture.factory import create_camera +from physicalai.capture.transport.builtin import ( + builtin_class_path_for_type, + builtin_shared_type_tokens, + builtin_type_for_class_path, +) class TestCreateCamera: @@ -24,6 +28,41 @@ def test_case_insensitive(self) -> None: cam = create_camera("UVC", backend="v4l2") assert isinstance(cam, UVCCamera) + def test_shared_stub_types_rejected(self) -> None: + for stub in ("ip", "genicam"): + with pytest.raises(ValueError, match="does not support shared=True"): + create_camera(stub, shared=True, device=0) + + def test_shared_builtin_uses_registry_class_path(self) -> None: + cam = create_camera("uvc", shared=True, device=0, backend="v4l2") + assert cam._camera is not None # type: ignore[attr-defined] + assert cam._camera["class_path"] == builtin_class_path_for_type("uvc") # type: ignore[attr-defined] + assert cam._service_name == "physicalai/camera/uvc/0/frame" # type: ignore[attr-defined] + + +class TestBuiltinSharedRegistry: + """Single source of truth for shareable type ↔ class_path.""" + + def test_shareable_tokens(self) -> None: + assert builtin_shared_type_tokens() == frozenset({"uvc", "realsense", "basler"}) + + def test_round_trip_uvc(self) -> None: + path = builtin_class_path_for_type("uvc") + assert path == "physicalai.capture.UVCCamera" + assert builtin_type_for_class_path(path) == "uvc" + + def test_matches_export_config_when_importable(self) -> None: + from physicalai.capture.cameras.uvc import UVCCamera + from physicalai.config import resolve_public_class_path + + assert builtin_class_path_for_type("uvc") == resolve_public_class_path(UVCCamera) + + def test_no_phantom_ip_genicam(self) -> None: + assert builtin_class_path_for_type("ip") is None + assert builtin_class_path_for_type("genicam") is None + assert builtin_type_for_class_path("physicalai.capture.IPCamera") is None + assert builtin_type_for_class_path("physicalai.capture.GenicamCamera") is None + class TestDiscoverAll: """discover_all() aggregation.""" diff --git a/tests/unit/capture/test_transport.py b/tests/unit/capture/test_transport.py index 500aaa6..c1e014a 100644 --- a/tests/unit/capture/test_transport.py +++ b/tests/unit/capture/test_transport.py @@ -26,7 +26,9 @@ encode_frame, ) from physicalai.capture.transport._shared_camera import SharedCamera -from physicalai.capture.transport._spec import CameraSpec +from physicalai.capture.transport._spec import CameraSpec, derive_service_name + +from .conftest import FAKE_CAMERA_CLASS HAS_ICEORYX2 = importlib.util.find_spec("iceoryx2") is not None @@ -39,27 +41,76 @@ def _service_name() -> str: class TestCameraSpec: def test_picklable(self) -> None: - spec = CameraSpec(camera_type="uvc", camera_kwargs={"device": 0, "width": 640}) + spec = CameraSpec( + camera={ + "class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": 0, "width": 640}, + }, + ) blob = pickle.dumps(spec) restored = pickle.loads(blob) - assert restored.camera_type == spec.camera_type - assert restored.camera_kwargs == spec.camera_kwargs + assert restored.camera == spec.camera - def test_build_delegates_to_factory(self) -> None: - spec = CameraSpec(camera_type="uvc", camera_kwargs={"device": 1, "fps": 30}) + def test_build_uses_instantiate(self) -> None: + spec = CameraSpec( + camera={ + "class_path": FAKE_CAMERA_CLASS, + "init_args": {"width": 320, "height": 240}, + }, + ) + cam = spec.build() + assert type(cam).__name__ == "FakeCamera" + assert getattr(cam, "_width") == 320 + assert getattr(cam, "_height") == 240 + + def test_from_json_dict_rejects_flat_keys(self) -> None: + with pytest.raises(ValueError, match="unknown publisher config keys"): + CameraSpec.from_json_dict( + {"camera_type": "uvc", "camera_kwargs": {"device": 0}, "service_name": "x"}, + ) - with patch("physicalai.capture.factory.create_camera") as mock_create: - spec.build() + def test_from_json_dict_rejects_unknown_keys(self) -> None: + with pytest.raises(ValueError, match="unknown publisher config keys"): + CameraSpec.from_json_dict( + { + "camera": {"class_path": FAKE_CAMERA_CLASS, "init_args": {}}, + "service_name": "x", + "extra_field": 1, + }, + ) - mock_create.assert_called_once_with("uvc", device=1, fps=30) + def test_from_json_dict_requires_camera(self) -> None: + with pytest.raises(ValueError, match="missing required 'camera'"): + CameraSpec.from_json_dict({"service_name": "x"}) - def test_default_kwargs_empty_dict(self) -> None: - spec = CameraSpec("uvc") - assert spec.camera_kwargs == {} + def test_validate_publisher_config_shared_helper(self) -> None: + from physicalai.capture.transport._spec import validate_publisher_config + + camera = validate_publisher_config( + { + "camera": {"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 8}}, + "service_name": "physicalai/test/x/frame", + "idle_timeout": 1.0, + }, + ) + assert camera["class_path"] == FAKE_CAMERA_CLASS + init_args = camera["init_args"] + assert isinstance(init_args, dict) + assert init_args["width"] == 8 + + def test_to_json_dict_camera_shape(self) -> None: + spec = CameraSpec( + camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {"device_name": "d0"}}, + ) + payload = spec.to_json_dict() + assert set(payload) == {"camera"} + assert payload["camera"]["class_path"] == FAKE_CAMERA_CLASS + assert "service_name" not in payload["camera"]["init_args"] class TestFrameHeader: + def test_sizeof_is_44(self) -> None: assert ctypes.sizeof(FrameHeader) == 44 assert HEADER_SIZE == ctypes.sizeof(FrameHeader) @@ -170,43 +221,163 @@ def test_fps_defaults_to_zero(self) -> None: class TestSharedCameraConstruction: - """Unit tests for SharedCamera constructor and from_publisher.""" + """Unit tests for SharedCamera constructor / from_config / from_camera.""" - def test_constructor_with_camera_type(self) -> None: - cam = SharedCamera("uvc", device=0) - assert cam._camera_type == "uvc" + def test_from_config_derives_builtin_service_name(self) -> None: + cam = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", "init_args": {"device": 0}}, + ) + assert cam._camera is not None + assert cam._camera["class_path"] == "physicalai.capture.UVCCamera" assert cam._service_name == "physicalai/camera/uvc/0/frame" assert cam.device_id == "0" - def test_constructor_with_explicit_service_name(self) -> None: + def test_from_publisher(self) -> None: cam = SharedCamera.from_publisher("custom/name") + assert cam._camera is None assert cam._service_name == "custom/name" - def test_from_publisher(self) -> None: - cam = SharedCamera.from_publisher("physicalai/camera/uvc/0/frame") - assert cam._camera_type is None - assert cam._service_name == "physicalai/camera/uvc/0/frame" - def test_constructor_rejects_no_args(self) -> None: with pytest.raises(ValueError, match="must provide"): - SharedCamera(None) - - def test_constructor_rejects_service_name_as_type(self) -> None: - with pytest.raises(ValueError): - SharedCamera("physicalai/camera/uvc/0/frame") + SharedCamera() def test_default_device_zero(self) -> None: - cam = SharedCamera("uvc") - assert cam._service_name is not None + cam = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", "init_args": {}}, + ) assert cam._service_name.endswith("/0/frame") def test_serial_number_in_service_name(self) -> None: - cam = SharedCamera("realsense", serial_number="12345") - assert cam._service_name is not None - assert "12345" in cam._service_name + name = derive_service_name( + { + "class_path": "physicalai.capture.RealSenseCamera", + "init_args": {"serial_number": "12345"}, + }, + ) + assert name == "physicalai/camera/realsense/12345/frame" + + def test_basler_token_derivation(self) -> None: + name = derive_service_name( + { + "class_path": "physicalai.capture.BaslerCamera", + "init_args": {"serial_number": "abc"}, + }, + ) + assert name == "physicalai/camera/basler/abc/frame" + + def test_third_party_requires_explicit_service_name(self) -> None: + with pytest.raises(ValueError, match="requires an explicit service_name"): + SharedCamera.from_config( + {"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 64}}, + ) + + def test_ip_genicam_class_paths_require_explicit_service_name(self) -> None: + """Stub / phantom paths are not in the shareable builtin map.""" + for class_path in ( + "physicalai.capture.IPCamera", + "physicalai.capture.GenicamCamera", + ): + with pytest.raises(ValueError, match="requires an explicit service_name"): + derive_service_name({"class_path": class_path, "init_args": {"device": 0}}) + + def test_third_party_with_explicit_service_name(self) -> None: + cam = SharedCamera.from_config( + {"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 64}}, + service_name="physicalai/test/third/frame", + ) + assert cam._service_name == "physicalai/test/third/frame" + assert cam._camera is not None + assert cam._camera["class_path"] == FAKE_CAMERA_CLASS + + def test_envelope_service_name_not_in_init_args(self) -> None: + cam = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", "init_args": {"device": 1}}, + ) + assert cam._camera is not None + assert "service_name" not in cam._camera["init_args"] + assert derive_service_name(cam._camera) == cam._service_name + + def test_from_camera_requires_exportable(self) -> None: + class _Bare: + @property + def is_connected(self) -> bool: + return False + + with pytest.raises(ValueError, match="not config-exportable"): + SharedCamera.from_camera(_Bare()) # type: ignore[arg-type] + + def test_from_camera_rejects_connected_without_disconnect(self) -> None: + from tests.unit.capture.fake import FakeCamera + + driver = FakeCamera(width=32, height=32) + driver.connect() + assert driver.is_connected + with pytest.raises(ValueError, match="export-only sugar"): + SharedCamera.from_camera(driver, service_name="physicalai/test/x/frame") + assert driver.is_connected + + def test_from_camera_exports_disconnected(self) -> None: + from tests.unit.capture.fake import FakeCamera + + driver = FakeCamera(width=32, height=32, device_name="d1") + cam = SharedCamera.from_camera(driver, service_name="physicalai/test/x/frame") + assert cam._camera is not None + assert cam._camera["class_path"] == FAKE_CAMERA_CLASS + assert cam._camera["init_args"]["width"] == 32 + + def test_third_party_build_bypasses_create_camera_registry(self) -> None: + spec = CameraSpec( + camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 16, "height": 16}}, + ) + with patch("physicalai.capture.factory.create_camera") as mock_create: + cam = spec.build() + mock_create.assert_not_called() + assert getattr(cam, "_width") == 16 + + def test_publisher_stdin_carries_concrete_service_name(self) -> None: + from physicalai.capture.transport._publisher import CameraPublisher + + captured: dict = {} + + class _FakeProc: + def __init__(self, *args: object, **kwargs: object) -> None: + assert "cwd" not in kwargs + self.stdin = MagicMock() + self.stdout = MagicMock() + self.stdout.readline.return_value = b"READY\n" + def _write(data: bytes) -> None: + captured.update(__import__("json").loads(data.decode())) + self.stdin.write.side_effect = _write + self.poll = MagicMock(return_value=0) + + def terminate(self) -> None: + return None + + def kill(self) -> None: + return None + + def wait(self, timeout: float | None = None) -> int: + return 0 + + spec = CameraSpec( + camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 8}}, + ) + publisher = CameraPublisher(spec, "physicalai/test/svc/frame") + with ( + patch("physicalai.capture.transport._publisher.subprocess.Popen", _FakeProc), + patch("physicalai.capture.transport._publisher.select.select", return_value=([object()], [], [])), + ): + publisher.start(timeout=1.0) + + assert captured["service_name"] == "physicalai/test/svc/frame" + assert "camera" in captured + assert "camera_type" not in captured + assert "camera_kwargs" not in captured + assert "service_name" not in captured["camera"]["init_args"] class TestSharedCameraSpawnFlow: + """Unit tests for SharedCamera auto-spawn and race recovery flow.""" @staticmethod @@ -257,7 +428,9 @@ def test_connect_spawns_publisher_when_none_found( mock_publisher = MagicMock() mock_publisher_cls.return_value = mock_publisher - camera = SharedCamera("uvc", device=0) + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", "init_args": {"device": 0}}, + ) with patch.object(camera, "_decode_sample", return_value=(MagicMock(), MagicMock())): camera.connect(timeout=0.1) @@ -279,7 +452,9 @@ def test_connect_skips_spawn_when_publisher_found( mock_import_module.return_value = iox2 mock_probe.return_value = True - camera = SharedCamera("uvc", device=0) + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", "init_args": {"device": 0}}, + ) with patch.object(camera, "_decode_sample", return_value=(MagicMock(), MagicMock())): camera.connect(timeout=0.1) @@ -304,7 +479,9 @@ def test_connect_race_recovery( mock_publisher.start.side_effect = RuntimeError("publisher already running") mock_publisher_cls.return_value = mock_publisher - camera = SharedCamera("uvc", device=0) + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", "init_args": {"device": 0}}, + ) with patch.object(camera, "_decode_sample", return_value=(MagicMock(), MagicMock())): camera.connect(timeout=0.1) @@ -315,7 +492,9 @@ def test_connect_race_recovery( mock_publisher.start.assert_called_once_with() def test_disconnect_stops_spawned_publisher(self) -> None: - camera = SharedCamera("uvc", device=0) + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", "init_args": {"device": 0}}, + ) spawned_publisher = MagicMock() camera._publisher = spawned_publisher camera._connected = True @@ -365,7 +544,11 @@ def test_validate_on_connect_raises_on_resolution_mismatch( mock_import_module.return_value = iox2 mock_probe.return_value = True # publisher exists, no spawn - camera = SharedCamera("uvc", device=0, width=640, height=480, validate_on_connect=True) + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": 0, "width": 640, "height": 480}}, + validate_on_connect=True, + ) with patch.object(camera, "_decode_sample", return_value=self._header_frame(1920, 1080)): with pytest.raises(CaptureError, match="does not match"): camera.connect(timeout=0.1) @@ -387,7 +570,11 @@ def test_validate_on_connect_spawned_publisher_mismatch( mock_import_module.return_value = iox2 mock_probe.return_value = False - camera = SharedCamera("uvc", device=0, width=640, height=480, validate_on_connect=True) + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": 0, "width": 640, "height": 480}}, + validate_on_connect=True, + ) with patch.object(camera, "_decode_sample", return_value=self._header_frame(1920, 1080)): with pytest.raises(CaptureError, match="does not match"): camera.connect(timeout=0.1) @@ -407,7 +594,11 @@ def test_no_validate_on_connect_warns_on_mismatch_and_attaches( mock_import_module.return_value = iox2 mock_probe.return_value = True - camera = SharedCamera("uvc", device=0, width=640, height=480, validate_on_connect=False) + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": 0, "width": 640, "height": 480}}, + validate_on_connect=False, + ) with patch.object(camera, "_decode_sample", return_value=self._header_frame(1920, 1080)): camera.connect(timeout=0.1) @@ -425,7 +616,11 @@ def test_validate_on_connect_silent_on_match( mock_import_module.return_value = iox2 mock_probe.return_value = True - camera = SharedCamera("uvc", device=0, width=640, height=480, validate_on_connect=True) + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": 0, "width": 640, "height": 480}}, + validate_on_connect=True, + ) with patch.object(camera, "_decode_sample", return_value=self._header_frame(640, 480)): camera.connect(timeout=0.1) @@ -443,7 +638,10 @@ def test_no_dimensions_requested_skips_check( mock_import_module.return_value = iox2 mock_probe.return_value = True - camera = SharedCamera("uvc", device=0, validate_on_connect=True) + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", "init_args": {"device": 0}}, + validate_on_connect=True, + ) with patch.object(camera, "_decode_sample", return_value=self._header_frame(1920, 1080)): camera.connect(timeout=0.1) @@ -461,7 +659,11 @@ def test_fps_mismatch_validate_on_connect_raises( mock_import_module.return_value = iox2 mock_probe.return_value = True - camera = SharedCamera("uvc", device=0, width=640, height=480, fps=30, validate_on_connect=True) + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": 0, "width": 640, "height": 480, "fps": 30}}, + validate_on_connect=True, + ) with patch.object(camera, "_decode_sample", return_value=self._header_frame(640, 480, fps=60)): with pytest.raises(CaptureError, match="does not match"): camera.connect(timeout=0.1) @@ -481,7 +683,11 @@ def test_no_validate_on_connect_warns_once_not_repeatedly( mock_import_module.return_value = iox2 mock_probe.return_value = True - camera = SharedCamera("uvc", device=0, width=640, height=480, validate_on_connect=False) + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": 0, "width": 640, "height": 480}}, + validate_on_connect=False, + ) hf = self._header_frame(1920, 1080) with patch.object(camera, "_decode_sample", return_value=hf): camera.connect(timeout=0.1) @@ -531,11 +737,9 @@ def test_overwrite_reconfigure_success( mock_import_module.return_value = iox2 mock_probe.return_value = True - camera = SharedCamera( - "uvc", - device=0, - width=640, - height=480, + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": 0, "width": 640, "height": 480}}, validate_on_connect=True, overwrite_settings=True, ) @@ -559,11 +763,9 @@ def test_overwrite_validate_on_connect_reconfigure_failure_raises( mock_import_module.return_value = iox2 mock_probe.return_value = True - camera = SharedCamera( - "uvc", - device=0, - width=640, - height=480, + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": 0, "width": 640, "height": 480}}, validate_on_connect=True, overwrite_settings=True, ) @@ -592,11 +794,9 @@ def test_overwrite_no_validate_on_connect_reconfigure_failure_warns( mock_import_module.return_value = iox2 mock_probe.return_value = True - camera = SharedCamera( - "uvc", - device=0, - width=640, - height=480, + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": 0, "width": 640, "height": 480}}, validate_on_connect=False, overwrite_settings=True, ) @@ -624,11 +824,9 @@ def test_no_control_service_validate_on_connect_raises( mock_import_module.return_value = iox2 mock_probe.return_value = True - camera = SharedCamera( - "uvc", - device=0, - width=640, - height=480, + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": 0, "width": 640, "height": 480}}, validate_on_connect=True, overwrite_settings=True, ) @@ -657,11 +855,9 @@ def test_no_control_service_no_validate_on_connect_warns( mock_import_module.return_value = iox2 mock_probe.return_value = True - camera = SharedCamera( - "uvc", - device=0, - width=640, - height=480, + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": 0, "width": 640, "height": 480}}, validate_on_connect=False, overwrite_settings=True, ) @@ -689,11 +885,9 @@ def test_reconfigure_only_attempted_once( mock_import_module.return_value = iox2 mock_probe.return_value = True - camera = SharedCamera( - "uvc", - device=0, - width=640, - height=480, + camera = SharedCamera.from_config( + {"class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": 0, "width": 640, "height": 480}}, validate_on_connect=False, overwrite_settings=True, ) @@ -741,8 +935,11 @@ def test_context_manager(self, fake_camera_spec: CameraSpec) -> None: def test_start_failure_propagates(self) -> None: from physicalai.capture.transport._publisher import CameraPublisher - bad_spec = CameraSpec(camera_type="does-not-exist", camera_kwargs={}) - publisher = CameraPublisher(bad_spec, _service_name()) + publisher = CameraPublisher( + CameraSpec(camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {}}), + _service_name(), + _factory_override="tests.unit.capture.fake:DoesNotExist", + ) with pytest.raises(CaptureError, match="failed"): publisher.start(timeout=2.0) @@ -832,7 +1029,9 @@ def test_reconfigure_resolution_change(self) -> None: from physicalai.capture.transport._publisher import CameraPublisher service_name = f"physicalai/test/{uuid4().hex[:8]}/frame" - spec = CameraSpec(camera_type="fake", camera_kwargs={"width": 320, "height": 240}) + spec = CameraSpec( + camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 320, "height": 240}}, + ) publisher = CameraPublisher( spec, service_name, @@ -847,8 +1046,10 @@ def test_reconfigure_resolution_change(self) -> None: assert frame.data.shape == (240, 320, 3) camera._overwrite_settings = True - camera._camera_type = None - camera._camera_kwargs = {"width": 640, "height": 480} + camera._camera = { + "class_path": FAKE_CAMERA_CLASS, + "init_args": {"width": 640, "height": 480}, + } result = camera._request_reconfigure(timeout=5.0) assert result["ok"] is True @@ -868,7 +1069,9 @@ def test_reconfigure_failure_restores_old(self) -> None: from physicalai.capture.transport._publisher import CameraPublisher service_name = f"physicalai/test/{uuid4().hex[:8]}/frame" - spec = CameraSpec(camera_type="fake", camera_kwargs={"width": 320, "height": 240}) + spec = CameraSpec( + camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 320, "height": 240}}, + ) publisher = CameraPublisher( spec, service_name, @@ -877,11 +1080,14 @@ def test_reconfigure_failure_restores_old(self) -> None: publisher.start(timeout=10.0) try: - camera = SharedCamera.from_publisher(service_name) + camera = SharedCamera.from_config( + {"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 320, "height": 240}}, + service_name=service_name, + ) camera.connect(timeout=5.0) result = camera._request_reconfigure(timeout=5.0) - assert result["ok"] is False or result["ok"] is True + assert result["ok"] is True frame = camera.read(timeout=5.0) assert frame.data.shape[0] > 0 @@ -890,13 +1096,20 @@ def test_reconfigure_failure_restores_old(self) -> None: publisher.stop() def test_no_control_service_on_v1_publisher(self) -> None: - camera = SharedCamera.from_publisher(f"physicalai/test/{uuid4().hex[:8]}/frame") + camera = SharedCamera.from_config( + {"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 640}}, + service_name=f"physicalai/test/{uuid4().hex[:8]}/frame", + ) camera._overwrite_settings = True - camera._camera_kwargs = {"width": 640} with pytest.raises(CaptureError, match="does not support reconfigure"): camera._request_reconfigure(timeout=1.0) + def test_attach_only_reconfigure_requires_camera_config(self) -> None: + camera = SharedCamera.from_publisher(f"physicalai/test/{uuid4().hex[:8]}/frame") + with pytest.raises(CaptureError, match="requires a camera ComponentConfig"): + camera._request_reconfigure(timeout=1.0) + def test_end_to_end_overwrite_on_connect(self) -> None: """Full flow: overwrite_settings triggers auto-reconfigure during connect.""" import time @@ -905,7 +1118,9 @@ def test_end_to_end_overwrite_on_connect(self) -> None: service_name = f"physicalai/test/{uuid4().hex[:8]}/frame" # Publisher starts serving 320×240 - spec = CameraSpec(camera_type="fake", camera_kwargs={"width": 320, "height": 240}) + spec = CameraSpec( + camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 320, "height": 240}}, + ) publisher = CameraPublisher( spec, service_name, @@ -915,11 +1130,9 @@ def test_end_to_end_overwrite_on_connect(self) -> None: try: # Subscriber requests 640×480 — connect should auto-reconfigure publisher - camera = SharedCamera( - None, + camera = SharedCamera.from_config( + {"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 640, "height": 480}}, service_name=service_name, - width=640, - height=480, overwrite_settings=True, ) camera.connect(timeout=10.0) @@ -933,3 +1146,30 @@ def test_end_to_end_overwrite_on_connect(self) -> None: camera.disconnect() finally: publisher.stop() + + +class TestPublisherOpenErrorMessaging: + """Busy-device clarification for publisher open failures.""" + + def test_ebusy_is_detectable(self) -> None: + import errno + + from physicalai.capture.transport._publisher_worker import ( + _format_camera_open_error, + _looks_like_device_busy, + ) + + busy = OSError(errno.EBUSY, "Device or resource busy") + assert _looks_like_device_busy(busy) + msg = _format_camera_open_error(busy) + assert "opens the camera exclusively" in msg + + def test_generic_error_unchanged(self) -> None: + from physicalai.capture.transport._publisher_worker import ( + _format_camera_open_error, + _looks_like_device_busy, + ) + + exc = RuntimeError("no such device") + assert not _looks_like_device_busy(exc) + assert _format_camera_open_error(exc) == "RuntimeError: no such device" From 6323de4601f06110e0ceb6b4aab235c1d4a3409f Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Thu, 23 Jul 2026 11:53:39 +0200 Subject: [PATCH 10/16] step 6/7 --- docs/development/component-config-brief.md | 7 +- docs/development/component-config-report.md | 42 +++--- docs/development/component-config.md | 72 +++++++++-- docs/how-to/runtime/share-a-robot.md | 44 ++++--- docs/reference/cli.md | 12 +- examples/runtime/runtime.yaml | 27 +--- .../capture/transport/_shared_camera.py | 57 +++++++- src/physicalai/config/__init__.py | 2 + src/physicalai/config/_export.py | 118 ++++++++++++++--- src/physicalai/inference/model.py | 2 + .../robot/transport/_shared_robot.py | 85 +++++++++++- .../runtime/action_sources/policy.py | 2 + .../runtime/action_sources/teleop.py | 2 + .../runtime/callbacks/async_callback.py | 2 + src/physicalai/runtime/callbacks/console.py | 3 + src/physicalai/runtime/callbacks/jsonl.py | 3 + src/physicalai/runtime/callbacks/low_pass.py | 3 + src/physicalai/runtime/callbacks/rerun.py | 3 + src/physicalai/runtime/core.py | 2 + .../runtime/execution/async_execution.py | 2 + src/physicalai/runtime/execution/queue.py | 2 + src/physicalai/runtime/execution/rtc.py | 2 + src/physicalai/runtime/execution/rtc_queue.py | 3 + src/physicalai/runtime/execution/sync.py | 2 + src/physicalai/runtime/smoothers.py | 7 + .../capture/test_camera_component_config.py | 56 ++++++++ tests/unit/config/test_component_config.py | 46 +++++++ .../unit/robot/test_robot_component_config.py | 122 ++++++++++++++++++ 28 files changed, 623 insertions(+), 107 deletions(-) diff --git a/docs/development/component-config-brief.md b/docs/development/component-config-brief.md index 8a8c064..ddbb2ca 100644 --- a/docs/development/component-config-brief.md +++ b/docs/development/component-config-brief.md @@ -18,10 +18,11 @@ cannot share third-party drivers cleanly. ## Proposal ```text -@export_config / @export_config(class_path=...) → remember supplied __init__ args +@export_config / @export_config(class_path=..., scalar_var_kwargs=...) → remember supplied __init__ args to_config(live) → {class_path, init_args} # ComponentConfig instantiate(cfg)→ fresh disconnected instance to_config_value() → domain arg → plain JSON inside init_args +# scalar_var_kwargs=True: flattened **kwargs must be JSON scalars (InferenceModel) ``` Same shape as existing jsonargparse configs. Protocols (`Robot`, `Camera`, …) stay @@ -53,6 +54,10 @@ flowchart LR `from_camera`); shareable built-in `service_name` derived in transport (`uvc` / `realsense` / `basler`); stubs (`ip` / `genicam`) and third-party must pass an explicit name; flat `camera_type` / `camera_kwargs` rejected +- `SharedRobot` / `SharedCamera` are `@export_config` **construction + recipes** (no run-state snapshot); `to_config` of a runtime that holds + Shared\* is supported — enables Studio step-8; Studio client drop of + interim serializers remains step 8 - Trust: `instantiate` only on local / parent→child config — never network metadata ## Out of scope (v1) diff --git a/docs/development/component-config-report.md b/docs/development/component-config-report.md index a5b67ee..a55b114 100644 --- a/docs/development/component-config-report.md +++ b/docs/development/component-config-report.md @@ -90,6 +90,7 @@ ComponentConfig = {"class_path": str, "init_args": dict[str, JsonValue]} @export_config # opt-in on concrete classes @export_config(class_path="...") # when public import ≠ defining module +@export_config(..., scalar_var_kwargs=True) # seal flattened **kwargs to JSON scalars to_config(value) # live → ComponentConfig instantiate(config) # trusted ComponentConfig → fresh object is_config_exportable # @export_config marker only @@ -218,12 +219,12 @@ flowchart TB ### Public API -| Surface | Accept | Preferred | -| ---------------------- | ----------------------------------------------- | ----------------------------- | -| `SharedRobot` | `robot=` ComponentConfig (or `None` / `attach`) | `from_config` / `from_robot` | -| `SharedCamera` | `camera=` ComponentConfig (or `None` / attach) | `from_config` / `from_camera` | -| CLI `physicalai robot` | `--robot` ComponentConfig (required) | `--robot` | -| Metadata | Keep key `robot_class` | Value = public `class_path` | +| Surface | Accept | Preferred | +| ---------------------- | ------------------------------------------------------------------------------------- | ----------------------------- | +| `SharedRobot` | `robot=` ComponentConfig (or `None` / `attach`); `@export_config` construction recipe | `from_config` / `from_robot` | +| `SharedCamera` | `camera=` ComponentConfig (or `None` / attach); `@export_config` construction recipe | `from_config` / `from_camera` | +| CLI `physicalai robot` | `--robot` ComponentConfig (required) | `--robot` | +| Metadata | Keep key `robot_class` | Value = public `class_path` | Flat `robot_class` / `robot_kwargs` on SharedRobot, serve CLI, and owner stdin are unsupported (rejected on stdin; removed from the public API). Flat @@ -325,18 +326,19 @@ gantt Docs / preview versioning note :d2, 8, 9 ``` -| Step | Deliverable | -| ---: | -------------------------------------------------------------------------------------------- | -| 1 | `ComponentConfig`, instantiate, depth/cycles, shared importer (no inference behavior change) | -| 2 | `@export_config`, `to_config`, `is_config_exportable` | -| 3 | SO101, WidowXAI, BimanualWidowXAI round-trips | -| 4 | SharedRobot + owner stdin + public/CLI ComponentConfig-only (cwd inherit for relatives) | -| 5 | SharedCamera + publisher stdin hard cutover + `service_name` rules | -| 6 | PolicySource graph, TeleopSource, path-rooted InferenceModel, v1 callbacks | -| 7 | RobotRuntime -> `instantiate` + jsonargparse | -| 8 | Studio drops interim serializers | -| 9 | User-facing docs | -| 10 | Separate: inference factory follow-up (optional) | +| Step | Deliverable | Status | +| ---: | -------------------------------------------------------------------------------------------- | -------- | +| 1 | `ComponentConfig`, instantiate, depth/cycles, shared importer (no inference behavior change) | **done** | +| 2 | `@export_config`, `to_config`, `is_config_exportable` | **done** | +| 3 | SO101, WidowXAI, BimanualWidowXAI round-trips | **done** | +| 4 | SharedRobot + owner stdin + public/CLI ComponentConfig-only (cwd inherit for relatives) | **done** | +| 5 | SharedCamera + publisher stdin hard cutover + `service_name` rules | **done** | +| 6 | PolicySource graph, TeleopSource, path-rooted InferenceModel, v1 callbacks | **done** | +| 7 | RobotRuntime -> `instantiate` + jsonargparse | **done** | +| 7b | SharedRobot / SharedCamera `@export_config` construction recipes (runtime `to_config` path) | **done** | +| 8 | Studio drops interim serializers (enabled by 7b; Studio client still this step) | | +| 9 | User-facing docs | | +| 10 | Separate: inference factory follow-up (optional) | | Implement **one step at a time**. Keep the design note open as the contract. @@ -345,7 +347,8 @@ Implement **one step at a time**. Keep the design note open as the contract. ## Plugin checklist 1. Implement `Robot` / `Camera` / `ActionSource` / callback protocol -2. `@export_config` — use `@export_config(class_path="…")` when re-exported +2. `@export_config` — use `@export_config(class_path="…")` when re-exported; + `scalar_var_kwargs=True` when flattened `**kwargs` must be JSON scalars 3. JSON-normalizable args (JSON / nested `@export_config` / `to_config_value()`); constructors accept normalized forms 4. Relative path args OK; keep cwd stable through Shared\* spawn (or use absolute/`Path` as given) @@ -360,6 +363,7 @@ Implement **one step at a time**. Keep the design note open as the contract. | ------------------------------ | ------------------------------------------------------------------- | | `@export_config` | Opt into `ComponentConfig` export (stores supplied `__init__` args) | | `@export_config(class_path=…)` | Public re-export path when ≠ defining module | +| `scalar_var_kwargs=True` | Seal flattened `**kwargs` to JSON scalars (default `False`) | | `to_config_value()` | Domain arg → plain JSON inside `init_args` (`ConfigValue`) | | `ComponentConfig` | Wire shape shared with jsonargparse | | `to_config` / `instantiate` | Export / trusted rebuild | diff --git a/docs/development/component-config.md b/docs/development/component-config.md index fa9c88c..32aca6d 100644 --- a/docs/development/component-config.md +++ b/docs/development/component-config.md @@ -12,7 +12,7 @@ configuration must be exportable so a fresh instance can be created later. ```text Robot / Camera / ActionSource = runtime behavior (unchanged) -@export_config / @export_config(class_path=...) = opt into ComponentConfig export +@export_config / @export_config(class_path=..., scalar_var_kwargs=...) = opt into ComponentConfig export ComponentConfig = plain class_path + init_args data ``` @@ -23,7 +23,7 @@ other transport settings in their existing transport envelopes. Mental model: ```text -@export_config / @export_config(class_path="...") → whole component → {class_path, init_args} +@export_config / @export_config(class_path="...", scalar_var_kwargs=...) → whole component → {class_path, init_args} to_config_value() → domain arg → plain JSON inside init_args (nested @export_config) → nested component → nested {class_path, init_args} ``` @@ -96,7 +96,12 @@ class ConfigValue(Protocol): def to_config_value(self) -> JsonValue: ... -def export_config(cls: type | None = None, *, class_path: str | None = None): ... +def export_config( + cls: type | None = None, + *, + class_path: str | None = None, + scalar_var_kwargs: bool = False, +): ... def to_config(value: object) -> ComponentConfig: ... @@ -108,6 +113,12 @@ def instantiate(config: ComponentConfig) -> object: ... def is_config_exportable(value: object) -> bool: ... ``` +`scalar_var_kwargs` defaults to `False`. Pass +`@export_config(..., scalar_var_kwargs=True)` when flattened `**kwargs` must +export as JSON scalars only (`None` / `bool` / `int` / `float` / `str`); +non-scalar var-keyword values then fail at `to_config`. Path-rooted +`InferenceModel` uses this option. + Normal use stays small: ```python @@ -121,6 +132,14 @@ class SO101: def __init__(self, port: str, calibration: dict | str): ... +@export_config( + class_path="physicalai.inference.InferenceModel", + scalar_var_kwargs=True, +) +class InferenceModel: + def __init__(self, export_dir, *, backend=None, device=None, **kwargs): ... + + robot = SO101("/dev/ttyACM0", "./calibration.json") config = to_config(robot) restored = instantiate(config) @@ -228,6 +247,9 @@ class PolicySource(ActionSource): The wrapper uses `inspect.signature()` and `Signature.bind()` before invoking the constructor. It converts positional arguments to their parameter names, removes `self`, and flattens a bound `**kwargs` mapping into `init_args`. +When `scalar_var_kwargs=True`, those flattened var-keyword values are sealed +to JSON scalars: non-scalars fail later at `to_config` instead of normalizing +as nested JSON (requires a `**kwargs` parameter on the constructor). Positional-only parameters and `*args` are not replayable through `class_path` + keyword `init_args`; reject decorated constructors that declare them. @@ -285,8 +307,10 @@ override applies only to the concrete class that was decorated (owns the wrapped `__init__` in its class dict). A subclass that inherits a decorated constructor unchanged does **not** inherit that override — it exports its own `__module__.__qualname__` unless it re-decorates with its own `class_path=`. -Before emitting the spec, resolve the selected path and verify that it -identifies exactly `type(self)`. +Pass `scalar_var_kwargs=True` on the same decorator when flattened `**kwargs` +must be JSON scalars at export (default `False`; used by path-rooted +`InferenceModel`). Before emitting the spec, resolve the selected path and +verify that it identifies exactly `type(self)`. First-party public robot and camera classes must pass their stable public re-export path (for example, `physicalai.robot.SO101`) instead of emitting an @@ -671,10 +695,22 @@ Flat `robot_class` / `robot_kwargs` on the public API and CLI are removed; those keys on owner stdin remain unsupported and are rejected before import. +`SharedRobot` is `@export_config(class_path="physicalai.robot.SharedRobot")` +— a **construction recipe** only (`name`, nested `robot` +ComponentConfig, `allow_remote` / `rate_hz` / `idle_timeout` / +`connect_timeout`). `to_config` of a `RobotRuntime` (or other +container) that holds a `SharedRobot` is supported. Never capture Zenoh +session / owner / publisher / connected flags; a supplied private +`_session` live handle must fail at `to_config`, not export as an +object. + - **`SharedRobot` constructor:** accept `robot: ComponentConfig` to spawn, or `robot=None` for attach-only (prefer :meth:`SharedRobot.attach`). Prefer `SharedRobot.from_config(...)` and `from_robot(...)` when building from a trusted config or an exportable live driver. + Nested `instantiate()` may pass a live `@export_config` driver for + `robot=`; the constructor converts it to a ComponentConfig and rejects + connected drivers (same rule as `from_robot`). - **Public path normalization:** when accepting a class object or dotted path inside `robot.class_path`, normalize through the same public-path resolution used by `to_config` (decorator `class_path=` override, else @@ -696,6 +732,12 @@ before import. Mirror the SharedRobot public story so step-5 implementers do not invent three APIs: +`SharedCamera` is `@export_config(class_path="physicalai.capture.SharedCamera")` +— a **construction recipe** only (nested `camera` ComponentConfig, +`service_name`, `color_mode`, zero-copy / validation / idle knobs). +`to_config` of a runtime that holds `SharedCamera` is supported. Never +capture publisher / iceoryx2 node / frame / connected state. + - **`SharedCamera.from_config(camera, *, service_name=None, …)`** — primary API. Transport kwargs (zero-copy, validation, idle timeout, …) stay on the SharedCamera / publisher side, never inside `ComponentConfig`. @@ -708,7 +750,10 @@ three APIs: `camera=None` + `service_name` for attach-only (prefer :meth:`SharedCamera.from_publisher`). Flat `camera_type` / `camera_kwargs` are unsupported on the public API and publisher stdin - (rejected before import) — not a dual adapter API. + (rejected before import) — not a dual adapter API. Nested + `instantiate()` may pass a live `@export_config` camera for + `camera=`; convert to ComponentConfig and reject connected cameras + (same rule as `from_camera`). - **Who derives `service_name`:** `from_config` and the constructor. If `service_name` is omitted and `class_path` is a shareable built-in (`uvc` / `realsense` / `basler`), derive via the transport map + device id @@ -737,7 +782,11 @@ how a component is shared, not how the underlying component was constructed. Config export capability does not imply process sharing. Studio owns the product decision of whether a built driver should run in-process or be wrapped in `SharedRobot`. `is_config_exportable` only answers whether that sharing path -can obtain a component config. Sketch: +can obtain a component config. Once wrapped, `SharedRobot` / `SharedCamera` +themselves are `@export_config` construction recipes, so Studio can +`to_config` a full runtime that already holds Shared\* without interim +serializers (Studio drop of those serializers remains rollout step 8). +Sketch: ```python driver = await builder(robot, self) @@ -799,7 +848,9 @@ policy, but it does not replace the trusted-input rule for v1. `ActionSource`, callback, or another typed component). 2. Opt into config export with `@export_config`. When the public import path differs from the defining module, pass - `@export_config(class_path="physicalai.robot.SO101")`. + `@export_config(class_path="physicalai.robot.SO101")`. Pass + `scalar_var_kwargs=True` when flattened `**kwargs` must export as JSON + scalars only (non-scalars fail at `to_config`). 3. Ensure every captured value is JSON-normalizable and constructor-compatible (JSON-safe scalars/collections, nested `@export_config` components, or domain values with `to_config_value()`). Do not auto-call arbitrary @@ -871,7 +922,10 @@ specific subclasses only where tests or callers distinguish phases. `instantiate()` and jsonargparse (under `runtime:` where the CLI requires it). 8. Studio drops interim serializers and applies explicit sharing policy to - exportable plugin results. + exportable plugin results. Runtime already exports + `SharedRobot` / `SharedCamera` construction recipes (so a Studio + step-8 path can `to_config` a runtime that holds Shared\*); full + Studio client cutover remains this step. 9. Document component config next to `class_path` / `init_args` and state that persisted workflow versioning remains preview work. 10. In a separate inference design/change, audit manifest fixtures before diff --git a/docs/how-to/runtime/share-a-robot.md b/docs/how-to/runtime/share-a-robot.md index fa731fb..f247fef 100644 --- a/docs/how-to/runtime/share-a-robot.md +++ b/docs/how-to/runtime/share-a-robot.md @@ -4,7 +4,7 @@ while any number of other processes read its state and send actions over [Zenoh](https://zenoh.io/). It satisfies the same `Robot` protocol as a direct driver, so it is a drop-in replacement anywhere a robot is expected -(including `PolicyRuntime`). +(including `RobotRuntime`). ## Install @@ -17,21 +17,24 @@ pip install "physicalai[transport]" Every `SharedRobot` has a required, caller-chosen logical `name` — it keys the Zenoh topics directly. The first `SharedRobot` constructed for a given `name` that finds no existing owner spawns one (in a detached subprocess); -later instances (same or different process, same `name`) attach to it: +later instances (same or different process, same `name`) attach to it. + +Construction is ComponentConfig-only (`robot=` / `from_config` / `from_robot`). +Prefer `from_config` when you already have a recipe; `from_robot` is sugar +that exports a disconnected `@export_config` driver: ```python import numpy as np -from physicalai.robot import SharedRobot -from physicalai.robot.so101 import SO101 - -robot = SharedRobot( - "left-arm", - robot_class=SO101, - robot_kwargs={ - "port": "/dev/ttyUSB0", - "calibration": "~/.cache/calibration/so101.json", # a path — kwargs must be serializable - }, +from physicalai.config import to_config +from physicalai.robot import SO101, SharedRobot + +driver = SO101( + port="/dev/ttyUSB0", + calibration="~/.cache/calibration/so101.json", # path stays relative/as given ) +robot = SharedRobot.from_config(to_config(driver), name="left-arm") +# or: SharedRobot.from_robot(driver, name="left-arm") +# or: SharedRobot("left-arm", robot={"class_path": "physicalai.robot.SO101", "init_args": {...}}) robot.connect() obs = robot.get_observation() # pull latest state, non-blocking @@ -40,10 +43,9 @@ robot.send_action(np.asarray(obs.joint_positions), goal_time=0.1) robot.disconnect() # detaches; the owner keeps running ``` -`robot_class` can be the class object (normalized to its dotted import path) -or the path itself, e.g. `robot_class="physicalai.robot.so101.SO101"` — any -importable class works, including third-party plugin robots, with no -registry to update. +Any importable `@export_config` robot class works (including third-party +plugins) — pass its public `class_path` + `init_args`; there is no flat +`robot_class` / `robot_kwargs` API. ## Serve a robot in the foreground @@ -145,10 +147,12 @@ loopback port from `name`. Opt into cross-host reachability explicitly when you need it: ```python -robot = SharedRobot( - "left-arm", - robot_class=SO101, - robot_kwargs={"port": "/dev/ttyUSB0", "calibration": "calibration.json"}, +robot = SharedRobot.from_config( + { + "class_path": "physicalai.robot.SO101", + "init_args": {"port": "/dev/ttyUSB0", "calibration": "calibration.json"}, + }, + name="left-arm", allow_remote=True, ) ``` diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 65af4c6..37cb870 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -34,11 +34,13 @@ with runtime: physicalai robot serve --config examples/so101/serve.yaml ``` -The flat YAML fields are `name`, `robot_class`, optional `robot_kwargs`, optional -`allow_remote` (default `false`), and optional positive `rate_hz` (default 100 Hz). -Direct CLI arguments use the same names, including nested constructor values such as -`--robot_kwargs.port /dev/ttyACM0`. Serving stays in the foreground until SIGINT or -SIGTERM and returns nonzero for startup, loop, repeated-read, or disconnect failures. +The YAML fields are `name`, required `robot` (a ComponentConfig with +`class_path` + `init_args`), optional `allow_remote` (default `false`), and +optional positive `rate_hz` (default 100 Hz). Direct CLI arguments use the same +names, including nested robot constructor values such as +`--robot.init_args.port /dev/ttyACM0`. Flat `robot_class` / `robot_kwargs` are +unsupported. Serving stays in the foreground until SIGINT or SIGTERM and returns +nonzero for startup, loop, repeated-read, or disconnect failures. Use `--verbose` to include driver construction, lock acquisition, initial observation, endpoint declaration, and cleanup details. diff --git a/examples/runtime/runtime.yaml b/examples/runtime/runtime.yaml index d948603..097fbea 100644 --- a/examples/runtime/runtime.yaml +++ b/examples/runtime/runtime.yaml @@ -73,33 +73,10 @@ runtime: # mode: connect # connect_addr: "127.0.0.1:9876" # and start ``rerun`` separately. - # - # Cameras are declared again here so the callback can subscribe to the - # same shared-memory streams as the runtime. Keep these nested ``camera:`` - # blocks in sync with the runtime ``cameras:`` section above. + # Camera frames come from each tick's ``TickEvent.camera_frames`` (same + # values the action source saw); no separate camera wiring on this callback. - class_path: physicalai.runtime.RerunCallback init_args: - cameras: - overhead: - class_path: physicalai.capture.SharedCamera - init_args: - camera: - class_path: physicalai.capture.UVCCamera - init_args: - device: /dev/video0 # CHANGE_ME: same as overhead camera above - width: 640 - height: 480 - fps: 30 - gripper: - class_path: physicalai.capture.SharedCamera - init_args: - camera: - class_path: physicalai.capture.UVCCamera - init_args: - device: /dev/video2 # CHANGE_ME: same as gripper camera above - width: 640 - height: 480 - fps: 30 mode: spawn log_images: true image_decimation: 1 # log every Nth frame; raise to reduce viewer load diff --git a/src/physicalai/capture/transport/_shared_camera.py b/src/physicalai/capture/transport/_shared_camera.py index f232363..db6a8f4 100644 --- a/src/physicalai/capture/transport/_shared_camera.py +++ b/src/physicalai/capture/transport/_shared_camera.py @@ -17,6 +17,7 @@ import contextlib import ctypes import time +from collections.abc import Mapping from importlib import import_module from typing import TYPE_CHECKING, Any, cast @@ -24,13 +25,12 @@ from physicalai.capture.camera import Camera, ColorMode from physicalai.capture.errors import CaptureError, CaptureTimeoutError, NotConnectedError +from physicalai.config import export_config from ._header import FrameHeader, decode_header, decode_rgb from ._spec import derive_service_name, normalize_camera_config if TYPE_CHECKING: - from collections.abc import Mapping - from physicalai.capture.frame import Frame from physicalai.capture.transport._publisher import CameraPublisher from physicalai.config import ComponentConfig @@ -39,6 +39,45 @@ _SERVICE_NAME_EXPECTED_PARTS = 5 +def _coerce_camera_recipe(camera: object) -> ComponentConfig | Mapping[str, object]: + """Accept a ComponentConfig mapping or a live exportable camera recipe. + + ``instantiate()`` recursively builds nested ``class_path`` configs into live + cameras before calling this constructor; spawn still needs a JSON recipe for + the publisher subprocess. Live cameras are converted with :func:`to_config`. + Connected cameras are rejected — same rule as :meth:`SharedCamera.from_camera`. + + Args: + camera: ComponentConfig mapping or disconnected ``@export_config`` camera. + + Returns: + A mapping suitable for :func:`normalize_camera_config`. + + Raises: + TypeError: If *camera* is neither a mapping nor config-exportable. + ValueError: If *camera* is a connected live camera. + """ + if isinstance(camera, Mapping): + return camera + from physicalai.config import is_config_exportable, to_config # ruff:ignore[PLC0415] + + if not is_config_exportable(camera): + msg = ( + f"{type(camera).__module__}.{type(camera).__qualname__} is not a " + "ComponentConfig mapping or config-exportable camera; pass " + "camera={{class_path, init_args}} or an @export_config camera" + ) + raise TypeError(msg) + if bool(getattr(camera, "is_connected", False)): + msg = ( + "SharedCamera requires a disconnected camera recipe; " + "disconnect explicitly before passing a live camera, or pass " + "camera={{class_path, init_args}}" + ) + raise ValueError(msg) + return to_config(camera) + + def _probe_service(service_name: str) -> bool: """Check if a publisher is serving *service_name*. @@ -84,6 +123,7 @@ def _probe_with_retry(service_name: str, timeout: float, interval: float = 0.1) time.sleep(interval) +@export_config(class_path="physicalai.capture.SharedCamera") class SharedCamera(Camera): """Camera subscriber that reads frames from shared memory via iceoryx2. @@ -97,6 +137,11 @@ class SharedCamera(Camera): ``camera=None`` + ``service_name`` for attach-only (:meth:`from_publisher` is the explicit form). + Opted into :func:`~physicalai.config.export_config` as a **construction + recipe** only (nested ``camera`` ComponentConfig, ``service_name``, + ``color_mode``, transport knobs). Publisher / iceoryx2 session / frame + state is never part of :func:`~physicalai.config.to_config`. + The publisher subprocess owns the device exclusively. Another connected holder of the same hardware will cause open to fail; this API does not hand off an already-open device into the child. @@ -104,7 +149,10 @@ class SharedCamera(Camera): Args: camera: Trusted camera :class:`~physicalai.config.ComponentConfig` to spawn if no publisher exists yet for the derived or explicit - ``service_name``. ``None`` means attach-only. + ``service_name``. ``None`` means attach-only. A live + ``@export_config`` camera (as produced by nested + :func:`~physicalai.config.instantiate`) is accepted and converted + to a ComponentConfig; connected cameras are rejected. color_mode: Pixel format preference for this subscriber. zero_copy: If True, returned frames reference the iceoryx2 SHM buffer directly (read-only). Otherwise, frames are copied. @@ -141,7 +189,8 @@ def __init__( msg = "must provide camera ComponentConfig or service_name" raise ValueError(msg) - normalized = None if camera is None else normalize_camera_config(camera) + recipe = None if camera is None else _coerce_camera_recipe(camera) + normalized = None if recipe is None else normalize_camera_config(recipe) if normalized is not None: service_name = derive_service_name(normalized, service_name=service_name) elif service_name is None: diff --git a/src/physicalai/config/__init__.py b/src/physicalai/config/__init__.py index 57b247b..67a1f22 100644 --- a/src/physicalai/config/__init__.py +++ b/src/physicalai/config/__init__.py @@ -10,6 +10,8 @@ - ``@export_config`` / ``@export_config(class_path=...)`` — remember caller-supplied constructor args for :func:`to_config`. +- ``@export_config(..., scalar_var_kwargs=True)`` — seal flattened + ``**kwargs`` to JSON scalars (non-scalars fail at :func:`to_config`). - Domain ctor args may implement :meth:`~ConfigValue.to_config_value` to return a JSON-compatible fragment (re-normalized). diff --git a/src/physicalai/config/_export.py b/src/physicalai/config/_export.py index 632a8a5..5cec257 100644 --- a/src/physicalai/config/_export.py +++ b/src/physicalai/config/_export.py @@ -32,6 +32,31 @@ _T = TypeVar("_T", bound=type) +class _NonScalarVarKwarg: + """Poison value so ``to_config`` rejects non-scalar ``**kwargs`` entries. + + Used when ``@export_config(scalar_var_kwargs=True)`` seals flattened + var-keyword arguments to JSON scalars only. + """ + + __slots__ = ("_name",) + + def __init__(self, name: str) -> None: + self._name = name + + def __repr__(self) -> str: + return f"" + + +def _is_json_scalar(value: object) -> bool: + """Return whether *value* is a :data:`~physicalai.config.JsonScalar`.""" + if value is None or isinstance(value, (bool, str)): + return True + if isinstance(value, int) and not isinstance(value, bool): + return True + return isinstance(value, float) + + def _has_export_marker(obj: object) -> bool: return bool(getattr(obj, _EXPORT_MARKER_ATTR, False)) @@ -213,7 +238,39 @@ def _validate_replayable_signature(cls: type, signature: inspect.Signature) -> N raise TypeError(msg) -def _decorate_export_config(cls: _T, *, class_path: str | None) -> _T: +def _flatten_var_kwargs( + supplied: dict[str, object], + *, + cls_name: str, + var_kw_name: str, + scalar_var_kwargs: bool, +) -> None: + """Move flattened ``**kwargs`` entries into *supplied*; optionally seal scalars. + + Raises: + TypeError: If the var-keyword value is not a string-keyed mapping. + """ + extra = supplied.pop(var_kw_name) + if not isinstance(extra, dict): + msg = f"{cls_name}: **{var_kw_name} must be a mapping" + raise TypeError(msg) + for key, value in extra.items(): + if not isinstance(key, str): + msg = f"{cls_name}: **{var_kw_name} keys must be strings" + raise TypeError(msg) + if scalar_var_kwargs and not _is_json_scalar(value): + # Seal so normalize fails at to_config (no silent JSON nest). + supplied[key] = _NonScalarVarKwarg(key) + else: + supplied[key] = snapshot_captured_value(value, keep_by_reference=is_config_exportable) + + +def _decorate_export_config( + cls: _T, + *, + class_path: str | None, + scalar_var_kwargs: bool, +) -> _T: if not isinstance(cls, type): msg = f"@export_config expects a class, got {type(cls).__name__}" raise TypeError(msg) @@ -242,6 +299,14 @@ def _decorate_export_config(cls: _T, *, class_path: str | None) -> _T: signature = inspect.signature(original_init) _validate_replayable_signature(cls, signature) + var_kw_name = next( + (name for name, param in signature.parameters.items() if param.kind is inspect.Parameter.VAR_KEYWORD), + None, + ) + if scalar_var_kwargs and var_kw_name is None: + msg = f"@export_config on {cls.__qualname__}: scalar_var_kwargs=True requires a **kwargs parameter" + raise TypeError(msg) + @functools.wraps(original_init) def wrapped_init(self: object, *args: object, **kwargs: object) -> None: bound = signature.bind(self, *args, **kwargs) @@ -252,21 +317,13 @@ def wrapped_init(self: object, *args: object, **kwargs: object) -> None: for name, value in bound.arguments.items() if name != "self" } - # Flatten **kwargs mapping into init_args. - var_kw_name = next( - (name for name, param in signature.parameters.items() if param.kind is inspect.Parameter.VAR_KEYWORD), - None, - ) if var_kw_name is not None and var_kw_name in supplied: - extra = supplied.pop(var_kw_name) - if not isinstance(extra, dict): - msg = f"{cls.__qualname__}: **{var_kw_name} must be a mapping" - raise TypeError(msg) - for key, value in extra.items(): - if not isinstance(key, str): - msg = f"{cls.__qualname__}: **{var_kw_name} keys must be strings" - raise TypeError(msg) - supplied[key] = snapshot_captured_value(value, keep_by_reference=is_config_exportable) + _flatten_var_kwargs( + supplied, + cls_name=cls.__qualname__, + var_kw_name=var_kw_name, + scalar_var_kwargs=scalar_var_kwargs, + ) depth = getattr(self, _EXPORT_DEPTH_ATTR, 0) setattr(self, _EXPORT_DEPTH_ATTR, depth + 1) @@ -294,7 +351,11 @@ def export_config(cls: _T, /) -> _T: ... @overload -def export_config(*, class_path: str | None = None) -> Callable[[_T], _T]: ... +def export_config( + *, + class_path: str | None = None, + scalar_var_kwargs: bool = False, +) -> Callable[[_T], _T]: ... def export_config( @@ -302,6 +363,7 @@ def export_config( /, *, class_path: str | None = None, + scalar_var_kwargs: bool = False, ) -> _T | Callable[[_T], _T]: """Opt a concrete class into constructor-config export via :func:`to_config`. @@ -318,11 +380,19 @@ class MyRobot: ... @export_config(class_path="physicalai.robot.SO101") class SO101: ... + @export_config(class_path="physicalai.inference.InferenceModel", scalar_var_kwargs=True) + class InferenceModel: ... + When ``class_path`` is omitted, export uses ``type(self).__module__ + "." + type(self).__qualname__``. Pass ``class_path=`` when the public import path differs from the defining module (for example a package re-export). + Pass ``scalar_var_kwargs=True`` when flattened ``**kwargs`` must export as + JSON scalars only (``None`` / ``bool`` / ``int`` / ``float`` / ``str``). + Non-scalar var-keyword values then fail at :func:`to_config` instead of + being normalized as nested JSON. Requires a ``**kwargs`` parameter. + Nested non-component domain values (for example calibration objects) may implement :meth:`~ConfigValue.to_config_value` so they normalize to constructor-compatible JSON; that method's output is re-normalized. @@ -343,14 +413,24 @@ class SO101: ... ``__init__`` in its own class body. class_path: Optional stable public import path for export. Verified on export to resolve exactly to the decorated class. + scalar_var_kwargs: When ``True``, seal flattened ``**kwargs`` to JSON + scalars so non-scalars fail at :func:`to_config`. Returns: - The decorated class, or a decorator when ``class_path`` is passed. + The decorated class, or a decorator when keyword options are passed. """ if cls is not None: - return _decorate_export_config(cls, class_path=class_path) + return _decorate_export_config( + cls, + class_path=class_path, + scalar_var_kwargs=scalar_var_kwargs, + ) def decorator(target: _T) -> _T: - return _decorate_export_config(target, class_path=class_path) + return _decorate_export_config( + target, + class_path=class_path, + scalar_var_kwargs=scalar_var_kwargs, + ) return decorator diff --git a/src/physicalai/inference/model.py b/src/physicalai/inference/model.py index e48d2b2..596545f 100644 --- a/src/physicalai/inference/model.py +++ b/src/physicalai/inference/model.py @@ -12,6 +12,7 @@ import numpy as np +from physicalai.config import export_config from physicalai.inference.adapters import adapter_registry, get_adapter from physicalai.inference.component_factory import instantiate_component, resolve_artifact from physicalai.inference.constants import ACTION @@ -38,6 +39,7 @@ def _is_safe_policy_name(name: str) -> bool: return _SAFE_POLICY_NAME_RE.fullmatch(name) is not None +@export_config(class_path="physicalai.inference.InferenceModel", scalar_var_kwargs=True) class InferenceModel: """Unified inference interface for exported policies. diff --git a/src/physicalai/robot/transport/_shared_robot.py b/src/physicalai/robot/transport/_shared_robot.py index a903419..ff849c6 100644 --- a/src/physicalai/robot/transport/_shared_robot.py +++ b/src/physicalai/robot/transport/_shared_robot.py @@ -29,10 +29,12 @@ from __future__ import annotations import time +from collections.abc import Mapping from typing import TYPE_CHECKING, Any from loguru import logger +from physicalai.config import export_config from physicalai.robot.errors import ( RobotDeviceAlreadyOwned, RobotNameConflict, @@ -48,13 +50,52 @@ from ._session import open_session if TYPE_CHECKING: - from collections.abc import Mapping - import numpy as np from physicalai.config import ComponentConfig from physicalai.robot import Robot, RobotObservation + +def _coerce_robot_recipe(robot: object) -> ComponentConfig | Mapping[str, object]: + """Accept a ComponentConfig mapping or a live exportable driver recipe. + + ``instantiate()`` recursively builds nested ``class_path`` configs into live + drivers before calling this constructor; spawn still needs a JSON recipe for + the owner subprocess. Live drivers are converted with :func:`to_config`. + Connected drivers are rejected — same rule as :meth:`SharedRobot.from_robot`. + + Args: + robot: ComponentConfig mapping or disconnected ``@export_config`` driver. + + Returns: + A mapping suitable for :func:`normalize_robot_config`. + + Raises: + TypeError: If *robot* is neither a mapping nor config-exportable. + ValueError: If *robot* is a connected live driver. + """ + if isinstance(robot, Mapping): + return robot + from physicalai.config import is_config_exportable, to_config # ruff:ignore[PLC0415] + + if not is_config_exportable(robot): + msg = ( + f"{type(robot).__module__}.{type(robot).__qualname__} is not a " + "ComponentConfig mapping or config-exportable robot; pass " + "robot={{class_path, init_args}} or an @export_config driver" + ) + raise TypeError(msg) + is_connected = getattr(robot, "is_connected", None) + if callable(is_connected) and is_connected(): + msg = ( + "SharedRobot requires a disconnected driver recipe; " + "disconnect explicitly before passing a live robot, or pass " + "robot={{class_path, init_args}}" + ) + raise ValueError(msg) + return to_config(robot) + + _PROBE_TIMEOUT = 1.0 _RACE_RETRY_TIMEOUT = 5.0 _RETRY_INTERVAL = 0.2 @@ -174,6 +215,7 @@ def _append_replies(query_session: Any, query_timeout: float) -> None: # noqa: return list({metadata.get("name"): metadata for metadata in robots}.values()) +@export_config(class_path="physicalai.robot.SharedRobot") class SharedRobot: """Robot subscriber that attaches to (or spawns) a shared owner process. @@ -186,13 +228,21 @@ class SharedRobot: ``robot: ComponentConfig`` to spawn, or ``robot=None`` (attach-only; :meth:`attach` is the explicit form). + Opted into :func:`~physicalai.config.export_config` as a **construction + recipe** only (name, nested ``robot`` ComponentConfig, transport knobs). + Connection / Zenoh session / publisher state is never part of + :func:`~physicalai.config.to_config`. + Args: name: Required logical name — keys the Zenoh topics directly. Two instances constructed with the same *name* (anywhere reachable under the chosen transport scope) share one owner. robot: Trusted driver :class:`~physicalai.config.ComponentConfig` to spawn if no owner exists yet for *name*. ``None`` means - attach-only — use :meth:`attach` for that case. + attach-only — use :meth:`attach` for that case. A live + ``@export_config`` driver (as produced by nested + :func:`~physicalai.config.instantiate`) is accepted and converted + to a ComponentConfig; connected drivers are rejected. allow_remote: Whether this instance's own session — and, if it spawns the owner, the owner's session for its whole lifetime — is reachable beyond localhost. Defaults to the secure, @@ -216,7 +266,8 @@ def __init__( _session: object | None = None, ) -> None: self._name = validate_name(name) - self._robot = None if robot is None else normalize_robot_config(robot) + recipe = None if robot is None else _coerce_robot_recipe(robot) + self._robot = None if recipe is None else normalize_robot_config(recipe) self._allow_remote = allow_remote self._rate_hz = rate_hz self._idle_timeout = idle_timeout @@ -258,6 +309,16 @@ def from_config( A ``SharedRobot`` that stores the normalized ComponentConfig and writes only the new owner stdin shape on spawn. """ + # Omit default None so @export_config does not capture "_session": null. + if _session is None: + return cls( + name, + robot=robot_config, + allow_remote=allow_remote, + rate_hz=rate_hz, + idle_timeout=idle_timeout, + connect_timeout=connect_timeout, + ) return cls( name, robot=robot_config, @@ -316,8 +377,19 @@ def from_robot( "after releasing hardware you own" ) raise ValueError(msg) + # Omit default None so @export_config does not capture "_session": null. + recipe = to_config(robot) + if _session is None: + return cls.from_config( + recipe, + name=name, + allow_remote=allow_remote, + rate_hz=rate_hz, + idle_timeout=idle_timeout, + connect_timeout=connect_timeout, + ) return cls.from_config( - to_config(robot), + recipe, name=name, allow_remote=allow_remote, rate_hz=rate_hz, @@ -350,6 +422,9 @@ def attach( Returns: A ``SharedRobot`` that never spawns an owner. """ + # Omit default None so @export_config does not capture "_session": null. + if _session is None: + return cls(name, allow_remote=allow_remote, connect_timeout=connect_timeout) return cls(name, allow_remote=allow_remote, connect_timeout=connect_timeout, _session=_session) @property diff --git a/src/physicalai/runtime/action_sources/policy.py b/src/physicalai/runtime/action_sources/policy.py index b390f05..75ba923 100644 --- a/src/physicalai/runtime/action_sources/policy.py +++ b/src/physicalai/runtime/action_sources/policy.py @@ -10,6 +10,7 @@ import numpy as np +from physicalai.config import export_config from physicalai.inference.constants import IMAGES, STATE, TASK from physicalai.runtime.action_sources.base import ActionSource from physicalai.runtime.events import MetricsEvent @@ -30,6 +31,7 @@ _DEFAULT_LERP_FRAMES = 5 +@export_config(class_path="physicalai.runtime.PolicySource") class PolicySource(ActionSource): """Action source adapting a model + execution + action-queue policy pipeline.""" diff --git a/src/physicalai/runtime/action_sources/teleop.py b/src/physicalai/runtime/action_sources/teleop.py index 10685ed..2652e83 100644 --- a/src/physicalai/runtime/action_sources/teleop.py +++ b/src/physicalai/runtime/action_sources/teleop.py @@ -8,6 +8,7 @@ import contextlib from typing import TYPE_CHECKING +from physicalai.config import export_config from physicalai.runtime.action_sources.base import ActionSource if TYPE_CHECKING: @@ -20,6 +21,7 @@ from physicalai.runtime._callback_bus import _CallbackBus +@export_config(class_path="physicalai.runtime.TeleopSource") class TeleopSource(ActionSource): """Action source that reads a leader arm and writes to the follower. diff --git a/src/physicalai/runtime/callbacks/async_callback.py b/src/physicalai/runtime/callbacks/async_callback.py index b3c3b78..5afb2ee 100644 --- a/src/physicalai/runtime/callbacks/async_callback.py +++ b/src/physicalai/runtime/callbacks/async_callback.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any from physicalai.capture.frame import Frame +from physicalai.config import export_config if TYPE_CHECKING: from physicalai.runtime.events import InferenceEvent, LifecycleEvent, MetricsEvent, TickEvent @@ -19,6 +20,7 @@ logger = logging.getLogger(__name__) +@export_config(class_path="physicalai.runtime.AsyncCallback") class AsyncCallback: """Wraps a callback so all hooks run on a dedicated background thread. diff --git a/src/physicalai/runtime/callbacks/console.py b/src/physicalai/runtime/callbacks/console.py index eddf883..3a37564 100644 --- a/src/physicalai/runtime/callbacks/console.py +++ b/src/physicalai/runtime/callbacks/console.py @@ -8,10 +8,13 @@ import time from typing import TYPE_CHECKING +from physicalai.config import export_config + if TYPE_CHECKING: from physicalai.runtime.events import LifecycleEvent, TickEvent +@export_config(class_path="physicalai.runtime.ConsoleCallback") class ConsoleCallback: """Periodic one-line summary to stdout, throttled by step count. diff --git a/src/physicalai/runtime/callbacks/jsonl.py b/src/physicalai/runtime/callbacks/jsonl.py index 6eb3daf..1de0480 100644 --- a/src/physicalai/runtime/callbacks/jsonl.py +++ b/src/physicalai/runtime/callbacks/jsonl.py @@ -9,12 +9,15 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +from physicalai.config import export_config + if TYPE_CHECKING: import numpy as np from physicalai.runtime.events import InferenceEvent, LifecycleEvent, TickEvent +@export_config(class_path="physicalai.runtime.JsonlCallback") class JsonlCallback: """Append-only JSONL recording. Numpy arrays converted to lists.""" diff --git a/src/physicalai/runtime/callbacks/low_pass.py b/src/physicalai/runtime/callbacks/low_pass.py index c4495cb..ce76cc7 100644 --- a/src/physicalai/runtime/callbacks/low_pass.py +++ b/src/physicalai/runtime/callbacks/low_pass.py @@ -7,10 +7,13 @@ from typing import TYPE_CHECKING +from physicalai.config import export_config + if TYPE_CHECKING: import numpy as np +@export_config(class_path="physicalai.runtime.LowPassFilterCallback") class LowPassFilterCallback: """Stateful low-pass filter (Exponential Moving Average) callback for smooth actions. diff --git a/src/physicalai/runtime/callbacks/rerun.py b/src/physicalai/runtime/callbacks/rerun.py index 7aedb7f..abf5381 100644 --- a/src/physicalai/runtime/callbacks/rerun.py +++ b/src/physicalai/runtime/callbacks/rerun.py @@ -10,6 +10,8 @@ from collections import deque from typing import TYPE_CHECKING, Any, Literal +from physicalai.config import export_config + if TYPE_CHECKING: from collections.abc import Mapping @@ -21,6 +23,7 @@ logger = logging.getLogger(__name__) +@export_config(class_path="physicalai.runtime.RerunCallback") class RerunCallback: """In-process Rerun logging for runtime visualization. diff --git a/src/physicalai/runtime/core.py b/src/physicalai/runtime/core.py index 3183bd1..9a73102 100644 --- a/src/physicalai/runtime/core.py +++ b/src/physicalai/runtime/core.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Protocol, Self from physicalai.capture.errors import CaptureError +from physicalai.config import export_config from physicalai.runtime._callback_bus import _CallbackBus # noqa: PLC2701 from physicalai.runtime.events import LifecycleEvent, TickEvent from physicalai.runtime.execution.base import WorkerDiedError @@ -64,6 +65,7 @@ def on_action_sent(self, *, action: np.ndarray, step: int) -> None: ... +@export_config(class_path="physicalai.runtime.RobotRuntime") class RobotRuntime: """Generic robot runtime loop with a required, pluggable action source.""" diff --git a/src/physicalai/runtime/execution/async_execution.py b/src/physicalai/runtime/execution/async_execution.py index a768505..78fba4c 100644 --- a/src/physicalai/runtime/execution/async_execution.py +++ b/src/physicalai/runtime/execution/async_execution.py @@ -12,6 +12,7 @@ import numpy as np +from physicalai.config import export_config from physicalai.runtime.execution.base import NOT_STARTED, Execution, WorkerDiedError if TYPE_CHECKING: @@ -22,6 +23,7 @@ logger = logging.getLogger(__name__) +@export_config(class_path="physicalai.runtime.AsyncExecution") class AsyncExecution(Execution): """Async inference in a background thread with health monitoring.""" diff --git a/src/physicalai/runtime/execution/queue.py b/src/physicalai/runtime/execution/queue.py index 958e3c8..6a7a199 100644 --- a/src/physicalai/runtime/execution/queue.py +++ b/src/physicalai/runtime/execution/queue.py @@ -11,6 +11,7 @@ import numpy as np +from physicalai.config import export_config from physicalai.runtime.smoothers import ChunkSmoother, ReplaceSmoother @@ -63,6 +64,7 @@ def reset(self) -> None: ... +@export_config(class_path="physicalai.runtime.ChunkedActionQueue") class ChunkedActionQueue: """Thread-safe action queue with chunk smoothing.""" diff --git a/src/physicalai/runtime/execution/rtc.py b/src/physicalai/runtime/execution/rtc.py index 93af365..bdc504f 100644 --- a/src/physicalai/runtime/execution/rtc.py +++ b/src/physicalai/runtime/execution/rtc.py @@ -18,6 +18,7 @@ import numpy as np +from physicalai.config import export_config from physicalai.runtime.execution.base import Execution, WorkerDiedError if TYPE_CHECKING: @@ -36,6 +37,7 @@ _JOIN_TIMEOUT_S: float = 5.0 +@export_config(class_path="physicalai.runtime.RTCExecution") class RTCExecution(Execution): """Async RTC execution strategy with background inference thread. diff --git a/src/physicalai/runtime/execution/rtc_queue.py b/src/physicalai/runtime/execution/rtc_queue.py index 373f2ee..689a0d2 100644 --- a/src/physicalai/runtime/execution/rtc_queue.py +++ b/src/physicalai/runtime/execution/rtc_queue.py @@ -13,12 +13,15 @@ import threading from typing import TYPE_CHECKING +from physicalai.config import export_config + if TYPE_CHECKING: import numpy as np logger = logging.getLogger(__name__) +@export_config(class_path="physicalai.runtime.RTCActionQueue") class RTCActionQueue: """Thread-safe dual-track action queue for RTC inference. diff --git a/src/physicalai/runtime/execution/sync.py b/src/physicalai/runtime/execution/sync.py index d1ba6fa..30d3049 100644 --- a/src/physicalai/runtime/execution/sync.py +++ b/src/physicalai/runtime/execution/sync.py @@ -8,6 +8,7 @@ import time from typing import TYPE_CHECKING, cast +from physicalai.config import export_config from physicalai.runtime.execution.base import NOT_STARTED, Execution if TYPE_CHECKING: @@ -18,6 +19,7 @@ from physicalai.runtime.execution.queue import ActionQueue, ChunkedActionQueue +@export_config(class_path="physicalai.runtime.SyncExecution") class SyncExecution(Execution): """Synchronous inference in the control thread.""" diff --git a/src/physicalai/runtime/smoothers.py b/src/physicalai/runtime/smoothers.py index 0181adf..6ee6e95 100644 --- a/src/physicalai/runtime/smoothers.py +++ b/src/physicalai/runtime/smoothers.py @@ -10,6 +10,8 @@ import numpy as np from typing_extensions import override +from physicalai.config import export_config + _NDIM_2 = 2 _ERR_2D = "remaining and incoming must be 2D arrays" _ERR_ACTION_DIM = "remaining and incoming must have the same action_dim" @@ -24,9 +26,13 @@ def merge(self, remaining: np.ndarray, incoming: np.ndarray) -> np.ndarray: raise NotImplementedError +@export_config(class_path="physicalai.runtime.ReplaceSmoother") class ReplaceSmoother(ChunkSmoother): """Replace remaining actions with the incoming chunk.""" + def __init__(self) -> None: + """Create a replace smoother (no configuration).""" + @override def merge(self, remaining: np.ndarray, incoming: np.ndarray) -> np.ndarray: """Return the incoming chunk (remaining is discarded).""" @@ -34,6 +40,7 @@ def merge(self, remaining: np.ndarray, incoming: np.ndarray) -> np.ndarray: return incoming +@export_config(class_path="physicalai.runtime.LerpSmoother") class LerpSmoother(ChunkSmoother): """Blend overlapping actions and append the incoming tail.""" diff --git a/tests/unit/capture/test_camera_component_config.py b/tests/unit/capture/test_camera_component_config.py index 72979c9..5aeb6c2 100644 --- a/tests/unit/capture/test_camera_component_config.py +++ b/tests/unit/capture/test_camera_component_config.py @@ -98,3 +98,59 @@ def test_round_trip(self, mock_pypylon: None) -> None: assert wire["init_args"]["fps"] == 15 assert wire["init_args"]["width"] == 800 assert wire["init_args"]["height"] == 600 + + +# --------------------------------------------------------------------------- +# SharedCamera (construction recipe only — no publisher / SHM state) +# --------------------------------------------------------------------------- + + +class TestSharedCameraComponentConfig: + def test_spawn_recipe_round_trip(self) -> None: + from physicalai.capture import SharedCamera + + camera = SharedCamera( + camera={ + "class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": "/dev/video0", "width": 640, "height": 480, "fps": 30, "backend": "v4l2"}, + }, + color_mode=ColorMode.BGR, + zero_copy=True, + validate_on_connect=True, + idle_timeout=1.5, + ) + wire = _assert_construction_round_trip(camera) + assert wire["class_path"] == "physicalai.capture.SharedCamera" + assert wire["init_args"]["color_mode"] == "bgr" + assert wire["init_args"]["zero_copy"] is True + assert wire["init_args"]["validate_on_connect"] is True + assert wire["init_args"]["idle_timeout"] == 1.5 + # service_name was derived, not caller-supplied — omitted from recipe. + assert "service_name" not in wire["init_args"] + nested = wire["init_args"]["camera"] + assert isinstance(nested, dict) + assert nested["class_path"] == "physicalai.capture.UVCCamera" + assert nested["init_args"]["device"] == "/dev/video0" + assert "_publisher" not in wire["init_args"] + assert "_connected" not in wire["init_args"] + assert "_node" not in wire["init_args"] + + def test_attach_only_round_trip(self) -> None: + from physicalai.capture import SharedCamera + + camera = SharedCamera(camera=None, service_name="physicalai/camera/uvc/0/frame") + wire = _assert_construction_round_trip(camera) + assert wire["class_path"] == "physicalai.capture.SharedCamera" + assert wire["init_args"]["camera"] is None + assert wire["init_args"]["service_name"] == "physicalai/camera/uvc/0/frame" + + def test_from_camera_still_rejects_connected(self) -> None: + from physicalai.capture import SharedCamera + from tests.unit.capture.fake import FakeCamera + + driver = FakeCamera(width=32, height=32) + driver.connect() + assert driver.is_connected + with pytest.raises(ValueError, match="export-only sugar"): + SharedCamera.from_camera(driver, service_name="physicalai/test/x/frame") + assert driver.is_connected diff --git a/tests/unit/config/test_component_config.py b/tests/unit/config/test_component_config.py index e6a4d25..d532c92 100644 --- a/tests/unit/config/test_component_config.py +++ b/tests/unit/config/test_component_config.py @@ -113,6 +113,13 @@ def __init__(self, base: int, **kwargs: object) -> None: self.kwargs = kwargs +@export_config(scalar_var_kwargs=True) +class ScalarVarKwargs: + def __init__(self, base: int, **kwargs: object) -> None: + self.base = base + self.kwargs = kwargs + + @export_config class PathHolder: def __init__(self, path: str | Path) -> None: @@ -534,3 +541,42 @@ def test_depth_attr_cleaned_after_construction(self) -> None: point = Point(1) assert is_config_exportable(point) assert "_physicalai_export_config_depth" not in vars(point) + + +class TestScalarVarKwargs: + def test_scalar_var_kwargs_round_trip(self) -> None: + obj = ScalarVarKwargs(1, count=2, flag=True, label="x", missing=None) + config = to_config(obj) + assert config["init_args"] == { + "base": 1, + "count": 2, + "flag": True, + "label": "x", + "missing": None, + } + restored = cast(ScalarVarKwargs, instantiate(json.loads(json.dumps(config)))) + assert restored.base == 1 + assert restored.kwargs == {"count": 2, "flag": True, "label": "x", "missing": None} + + def test_non_scalar_dict_var_kwarg_fails(self) -> None: + obj = ScalarVarKwargs(1, config_blob={"a": 1}) + with pytest.raises(ComponentConfigError, match=r"init_args\.config_blob"): + to_config(obj) + + def test_non_scalar_list_var_kwarg_fails(self) -> None: + obj = ScalarVarKwargs(1, tags=["x", "y"]) + with pytest.raises(ComponentConfigError, match=r"init_args\.tags"): + to_config(obj) + + def test_named_mapping_still_exports_without_scalar_flag(self) -> None: + # Default **kwargs flattening still accepts nested JSON. + obj = WithExtras(1, nested={"a": 1}) + assert to_config(obj)["init_args"]["nested"] == {"a": 1} + + def test_scalar_var_kwargs_requires_var_keyword(self) -> None: + with pytest.raises(TypeError, match="scalar_var_kwargs=True requires"): + + @export_config(scalar_var_kwargs=True) + class NoVarKwargs: + def __init__(self, x: int) -> None: + self.x = x diff --git a/tests/unit/robot/test_robot_component_config.py b/tests/unit/robot/test_robot_component_config.py index e20615b..fc0bb31 100644 --- a/tests/unit/robot/test_robot_component_config.py +++ b/tests/unit/robot/test_robot_component_config.py @@ -207,3 +207,125 @@ def test_nested_arms_round_trip(self, mock_trossen_arm: MagicMock) -> None: assert right_cfg["class_path"] == "physicalai.robot.WidowXAI" assert left_cfg["init_args"] == {"ip": "192.168.1.10", "role": "follower"} assert right_cfg["init_args"] == {"ip": "192.168.1.11", "role": "follower"} + + +# --------------------------------------------------------------------------- +# SharedRobot (construction recipe only — no session / connection state) +# --------------------------------------------------------------------------- + + +class TestSharedRobotComponentConfig: + def test_spawn_recipe_round_trip(self) -> None: + from physicalai.robot import SharedRobot + + robot = SharedRobot( + "follower-arm", + robot={ + "class_path": "tests.unit.robot.transport.fake.FakeRobot", + "init_args": {"port": "/dev/fake-shared", "device_ids": ["fake:follower"]}, + }, + allow_remote=True, + rate_hz=50.0, + idle_timeout=2.5, + connect_timeout=3.0, + ) + wire = _assert_construction_round_trip(robot) + assert wire["class_path"] == "physicalai.robot.SharedRobot" + assert wire["init_args"]["name"] == "follower-arm" + assert wire["init_args"]["allow_remote"] is True + assert wire["init_args"]["rate_hz"] == 50.0 + assert wire["init_args"]["idle_timeout"] == 2.5 + assert wire["init_args"]["connect_timeout"] == 3.0 + nested = wire["init_args"]["robot"] + assert isinstance(nested, dict) + assert nested["class_path"] == "tests.unit.robot.transport.fake.FakeRobot" + assert nested["init_args"]["port"] == "/dev/fake-shared" + assert nested["init_args"]["device_ids"] == ["fake:follower"] + # Run-state / transport handles are never part of the recipe. + assert "_session" not in wire["init_args"] + assert "_connected" not in wire["init_args"] + assert "session" not in wire["init_args"] + + def test_attach_only_round_trip(self) -> None: + from physicalai.robot import SharedRobot + + robot = SharedRobot("leader-arm") + wire = _assert_construction_round_trip(robot) + assert wire["class_path"] == "physicalai.robot.SharedRobot" + assert wire["init_args"] == {"name": "leader-arm"} + assert "robot" not in wire["init_args"] + + def test_explicit_robot_none_round_trips(self) -> None: + from physicalai.robot import SharedRobot + + robot = SharedRobot("attach-null", robot=None) + wire = _assert_construction_round_trip(robot) + assert wire["init_args"]["name"] == "attach-null" + assert wire["init_args"]["robot"] is None + + def test_live_session_arg_fails_at_to_config(self) -> None: + from physicalai.config import ComponentConfigError + from physicalai.robot import SharedRobot + + robot = SharedRobot("sess", _session=object()) + with pytest.raises(ComponentConfigError, match=r"init_args\._session"): + to_config(robot) + + def test_from_config_omits_null_session(self) -> None: + from physicalai.robot import SharedRobot + + robot = SharedRobot.from_config( + { + "class_path": "tests.unit.robot.transport.fake.FakeRobot", + "init_args": {"port": "/dev/fake-from-config", "device_ids": ["fake:follower"]}, + }, + name="from-config-arm", + ) + wire = _assert_construction_round_trip(robot) + assert "_session" not in wire["init_args"] + + def test_from_robot_omits_null_session(self) -> None: + from physicalai.robot import SharedRobot + from tests.unit.robot.transport.fake import FakeRobot + + driver = FakeRobot(port="/dev/fake-from-robot", device_ids=["fake:follower"]) + robot = SharedRobot.from_robot(driver, name="from-robot-arm") + wire = _assert_construction_round_trip(robot) + assert "_session" not in wire["init_args"] + + def test_attach_omits_null_session(self) -> None: + from physicalai.robot import SharedRobot + + robot = SharedRobot.attach("attach-arm") + wire = _assert_construction_round_trip(robot) + assert "_session" not in wire["init_args"] + + def test_classmethod_live_session_still_fails_at_to_config(self) -> None: + from physicalai.config import ComponentConfigError + from physicalai.robot import SharedRobot + + session = object() + for robot in ( + SharedRobot.from_config( + { + "class_path": "tests.unit.robot.transport.fake.FakeRobot", + "init_args": {"port": "/dev/fake-live", "device_ids": ["fake:follower"]}, + }, + name="live-from-config", + _session=session, + ), + SharedRobot.attach("live-attach", _session=session), + ): + with pytest.raises(ComponentConfigError, match=r"init_args\._session"): + to_config(robot) + + def test_from_robot_still_rejects_connected(self) -> None: + from physicalai.robot import SharedRobot + from tests.unit.robot.transport.fake import FakeRobot + + driver = FakeRobot(port="/dev/fake-connected") + driver.connect() + assert driver.is_connected() + with pytest.raises(ValueError, match="disconnected driver"): + SharedRobot.from_robot(driver, name="left-arm") + assert driver.is_connected() From dc0606c5625d9efd6b12310c5c4304ea32aeb98b Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Thu, 23 Jul 2026 12:06:12 +0200 Subject: [PATCH 11/16] cleanup --- docs/development/component-config.md | 27 +++--- .../robot/transport/_owner_config.py | 86 ++++++++++++++----- .../unit/robot/transport/test_owner_config.py | 32 ++++++- 3 files changed, 112 insertions(+), 33 deletions(-) diff --git a/docs/development/component-config.md b/docs/development/component-config.md index 32aca6d..a4d1145 100644 --- a/docs/development/component-config.md +++ b/docs/development/component-config.md @@ -646,9 +646,11 @@ add a `config_format` field, dual-read, or shape-detection fallback. {camera: {class_path, init_args}, service_name, idle_timeout, …} ``` -Writers and readers speak only the new shape. Reject payloads that still carry -unsupported flat keys (`robot_class` / `robot_kwargs`, or `camera_type` / -`camera_kwargs`) before import or hardware access — no silent translation. +Writers and readers speak only the new shape. Validate each envelope +schema-positively (known transport keys only; required `robot` / `camera` +ComponentConfig). Unknown keys — including legacy flat `robot_class` / +`robot_kwargs` or `camera_type` / `camera_kwargs` — are rejected before +import or hardware access; no silent translation or denylist-only drops. Do not reuse `capture.transport.PROTOCOL_VERSION` or `ROBOT_TRANSPORT_PROTOCOL_VERSION` for these changes. Those version frame and @@ -692,8 +694,9 @@ types (`ip`, `genicam`) are not in that map. Public construction matches private stdin: `robot` / `--robot` only. Flat `robot_class` / `robot_kwargs` on the public API and CLI are -removed; those keys on owner stdin remain unsupported and are rejected -before import. +removed; owner stdin uses an allowlist envelope (`name`, `robot`, +`allow_remote`, `rate_hz`, `idle_timeout`) so legacy flat keys fail as +unknown keys before import. `SharedRobot` is `@export_config(class_path="physicalai.robot.SharedRobot")` — a **construction recipe** only (`name`, nested `robot` @@ -974,8 +977,9 @@ cutovers; do not describe them as schema-preserving. - `InferenceModel` non-scalar / live override args fail at `to_config` (no silent drop). - Robot owner and camera publisher subprocess handshakes remain JSON-only, - accept only the new `robot:` / `camera: ComponentConfig` shape, and reject - unsupported flat stdin (`robot_class` / `camera_type` forms) before import. + accept only the new `robot:` / `camera: ComponentConfig` shape, and + schema-positively reject unknown envelope keys (including legacy flat + `robot_class` / `camera_type` forms) before import. - Shareable SharedCamera spawn (`uvc` / `realsense` / `basler`) derives the legacy `service_name` via the transport class-path → type-token map inside `from_config` / the constructor; stub (`ip` / `genicam`) and third-party @@ -990,10 +994,11 @@ cutovers; do not describe them as schema-preserving. built-in camera registry. - Public `SharedRobot` ctor and `physicalai robot serve` accept only `robot=` / `--robot` ComponentConfig (plus `from_config` / `from_robot`); - flat `robot_class` / `robot_kwargs` are rejected as unsupported on stdin - and are not a public dual API; defining-module paths normalize to public - re-exports before store/advertise/compare; metadata `robot_class` equals - that public `robot["class_path"]`. + owner stdin allowlists envelope keys so flat `robot_class` / + `robot_kwargs` fail as unknown keys (not a public dual API); + defining-module paths normalize to public re-exports before + store/advertise/compare; metadata `robot_class` equals that public + `robot["class_path"]`. - `SharedRobot.from_robot()` is sugar over `from_config(to_config(...))`, requires exportability, and rejects connected drivers before owner spawn. - Composite bimanual robots spawn as one owner; nested arm configs round-trip diff --git a/src/physicalai/robot/transport/_owner_config.py b/src/physicalai/robot/transport/_owner_config.py index 82abf1e..aa247b0 100644 --- a/src/physicalai/robot/transport/_owner_config.py +++ b/src/physicalai/robot/transport/_owner_config.py @@ -14,10 +14,11 @@ survives that boundary — arbitrary robot types, including third-party plugins, work without any registry lookup here. -Private stdin is ``robot: ComponentConfig`` only. Flat keys -(``robot_class`` / ``robot_kwargs``) are unsupported and rejected before -import or hardware access. Public ``SharedRobot`` and ``physicalai robot -serve`` use the same ``robot`` / ``--robot`` shape. +Private stdin is ``robot: ComponentConfig`` only. The owner envelope is +validated schema-positively: required ``robot``, known transport keys, and +rejection of unknown keys (including legacy flat ``robot_class`` / +``robot_kwargs``) before import or hardware access. Public ``SharedRobot`` +and ``physicalai robot serve`` use the same ``robot`` / ``--robot`` shape. Security: ``class_path`` is trusted local application/config input, exactly like a jsonargparse ``class_path`` (``docs/development/security.md`` rules @@ -30,6 +31,7 @@ import json import math +from collections.abc import Mapping from dataclasses import dataclass from typing import TYPE_CHECKING, Any @@ -43,8 +45,6 @@ from physicalai.config.importing import import_dotted_path if TYPE_CHECKING: - from collections.abc import Mapping - from physicalai.robot.interface import Robot DEFAULT_RATE_HZ = 100.0 @@ -56,7 +56,61 @@ justify a different value for a specific robot class. """ -_UNSUPPORTED_FLAT_STDIN_KEYS = ("robot_class", "robot_kwargs") +# Allowed keys on owner stdin handshake payloads. Everything else (including +# legacy flat robot_class / robot_kwargs) is an unknown-key schema error. +# Keep this the single allowlist — :meth:`RobotOwnerConfig.from_json_dict` +# and any future reconfigure path should share :func:`validate_owner_config`. +_OWNER_ENVELOPE_KEYS = frozenset({ + "name", + "robot", + "allow_remote", + "rate_hz", + "idle_timeout", +}) + + +def validate_owner_config(data: Mapping[str, Any]) -> Mapping[str, object]: + """Validate an owner stdin payload schema-positively. + + Requires ``robot`` with a valid ComponentConfig shape. Allows only known + transport envelope keys. Unknown keys (including legacy flat + ``robot_class`` / ``robot_kwargs``) raise a clear schema error. + + Args: + data: Full owner stdin dict. + + Returns: + The validated ``robot`` ComponentConfig mapping (not yet + public-path-normalized — :class:`RobotOwnerConfig` / + :func:`normalize_robot_config` do that). + + Raises: + TypeError: If *data* is not a mapping, or ``robot`` is not a mapping. + ValueError: If required ``robot`` is missing or unknown keys are present. + """ + if not isinstance(data, Mapping): + msg = f"owner config must be a mapping, got {type(data).__name__}" + raise TypeError(msg) + + unknown = sorted(set(data) - _OWNER_ENVELOPE_KEYS) + if unknown: + msg = ( + f"unknown owner config keys {unknown}; " + "require 'robot' with class_path + init_args " + f"(allowed envelope keys: {sorted(_OWNER_ENVELOPE_KEYS)})" + ) + raise ValueError(msg) + + if "robot" not in data: + msg = "owner config missing required 'robot' ComponentConfig" + raise ValueError(msg) + + robot = data["robot"] + if not isinstance(robot, Mapping): + msg = f"owner 'robot' must be a mapping, got {type(robot).__name__}" + raise TypeError(msg) + + return validate_component_config(dict(robot), path="robot") def normalize_robot_class(robot_class: type | str) -> str: @@ -214,8 +268,9 @@ def to_json_dict(self) -> dict[str, Any]: def from_json_dict(cls, data: dict[str, Any]) -> RobotOwnerConfig: """Deserialize from a JSON dictionary. - Rejects unsupported flat stdin keys (``robot_class`` / ``robot_kwargs``) - before any import or hardware access. + Uses :func:`validate_owner_config` so the owner envelope is validated + schema-positively (required ``robot``, known transport keys, unknown + keys rejected) before any import or hardware access. Args: data: Dictionary produced by :meth:`to_json_dict`. @@ -224,25 +279,16 @@ def from_json_dict(cls, data: dict[str, Any]) -> RobotOwnerConfig: A new :class:`RobotOwnerConfig` instance. Raises: - ValueError: If flat keys are present or ``robot`` is missing / - invalid. TypeError: If ``data`` is not a mapping. """ if not isinstance(data, dict): msg = f"owner config must be a mapping, got {type(data).__name__}" raise TypeError(msg) - unsupported = [key for key in _UNSUPPORTED_FLAT_STDIN_KEYS if key in data] - if unsupported: - msg = f"unsupported owner stdin keys {unsupported}; require 'robot' with class_path + init_args" - raise ValueError(msg) - if "robot" not in data: - msg = "owner config missing required 'robot' ComponentConfig" - raise ValueError(msg) - + robot = validate_owner_config(data) return cls( name=data["name"], - robot=data["robot"], + robot=robot, allow_remote=data.get("allow_remote", False), rate_hz=data.get("rate_hz", DEFAULT_RATE_HZ), idle_timeout=data.get("idle_timeout", 10.0), diff --git a/tests/unit/robot/transport/test_owner_config.py b/tests/unit/robot/transport/test_owner_config.py index e1b5904..6edcb9b 100644 --- a/tests/unit/robot/transport/test_owner_config.py +++ b/tests/unit/robot/transport/test_owner_config.py @@ -18,6 +18,7 @@ RobotOwnerConfig, normalize_robot_class, normalize_robot_config, + validate_owner_config, ) from .conftest import FAKE_ROBOT_CLASS @@ -163,12 +164,13 @@ def test_flat_stdin_rejected_before_import(self) -> None: "robot_class": "totally.unknown.module.Cls", "robot_kwargs": {"port": "/dev/ttyUSB0"}, } - with pytest.raises(ValueError, match="unsupported owner stdin keys") as exc_info: + with pytest.raises(ValueError, match="unknown owner config keys") as exc_info: RobotOwnerConfig.from_json_dict(flat) assert "robot_class" in str(exc_info.value) + assert "robot_kwargs" in str(exc_info.value) def test_flat_robot_kwargs_alone_rejected(self) -> None: - with pytest.raises(ValueError, match="unsupported owner stdin keys"): + with pytest.raises(ValueError, match="unknown owner config keys"): RobotOwnerConfig.from_json_dict( { "name": "left-arm", @@ -177,10 +179,36 @@ def test_flat_robot_kwargs_alone_rejected(self) -> None: }, ) + def test_unknown_keys_rejected(self) -> None: + with pytest.raises(ValueError, match="unknown owner config keys") as exc_info: + RobotOwnerConfig.from_json_dict( + { + "name": "left-arm", + "robot": _fake_robot(), + "extra_field": 1, + }, + ) + assert "extra_field" in str(exc_info.value) + def test_missing_robot_rejected(self) -> None: with pytest.raises(ValueError, match="missing required 'robot'"): RobotOwnerConfig.from_json_dict({"name": "left-arm"}) + def test_validate_owner_config_shared_helper(self) -> None: + robot = validate_owner_config( + { + "name": "left-arm", + "robot": _fake_robot(port="/dev/ttyUSB0"), + "allow_remote": True, + "rate_hz": 50.0, + "idle_timeout": 2.5, + }, + ) + assert robot["class_path"] == FAKE_ROBOT_CLASS + init_args = robot["init_args"] + assert isinstance(init_args, dict) + assert init_args["port"] == "/dev/ttyUSB0" + def test_malformed_robot_rejected_before_import(self) -> None: with pytest.raises(ComponentConfigError): RobotOwnerConfig.from_json_dict( From f06ccf9e3cf7e4b06e008c11541b92d134eb3501 Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Thu, 23 Jul 2026 12:06:29 +0200 Subject: [PATCH 12/16] add tests --- .../test_inference_model_component_config.py | 193 +++++ .../runtime/test_runtime_component_config.py | 666 ++++++++++++++++++ 2 files changed, 859 insertions(+) create mode 100644 tests/unit/inference/test_inference_model_component_config.py create mode 100644 tests/unit/runtime/test_runtime_component_config.py diff --git a/tests/unit/inference/test_inference_model_component_config.py b/tests/unit/inference/test_inference_model_component_config.py new file mode 100644 index 0000000..8512900 --- /dev/null +++ b/tests/unit/inference/test_inference_model_component_config.py @@ -0,0 +1,193 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +# ruff:file-ignore[undocumented-public-module, undocumented-public-class, undocumented-public-method, undocumented-public-init, assert, magic-value-comparison] + +"""Construction round-trips for path-rooted ``InferenceModel`` ``@export_config``.""" + +from __future__ import annotations + +import json +from collections.abc import Generator +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from physicalai.config import ComponentConfigError, instantiate, is_config_exportable, to_config + + +def _make_export_dir(tmp_path: Path, *, backend: str = "openvino") -> Path: + export_dir = tmp_path / "exports" + export_dir.mkdir(exist_ok=True) + artifact = "act.xml" if backend == "openvino" else f"act.{backend}" + manifest = { + "format": "policy_package", + "version": "1.0", + "policy": { + "name": "act", + "source": {"class_path": "physicalai.policies.act.ACT"}, + }, + "model": { + "artifacts": {backend: artifact}, + "runner": {"class_path": "physicalai.inference.runners.SinglePass", "init_args": {}}, + }, + } + with (export_dir / "manifest.json").open("w") as f: + json.dump(manifest, f) + (export_dir / artifact).touch() + if artifact.endswith(".xml"): + (export_dir / artifact.replace(".xml", ".bin")).touch() + return export_dir + + +def _assert_construction_round_trip(model: object) -> dict[str, Any]: + assert is_config_exportable(model) + config = to_config(model) + wire: dict[str, Any] = json.loads(json.dumps(config)) + restored = instantiate(wire) + assert type(restored) is type(model) + assert to_config(restored) == wire + return wire + + +@pytest.fixture +def mock_adapter() -> MagicMock: + adapter = MagicMock() + adapter.input_names = [] + adapter.output_names = [] + adapter.default_device.return_value = "cpu" + return adapter + + +@pytest.fixture +def _patch_adapter(mock_adapter: MagicMock) -> Generator[MagicMock, None, None]: + with patch("physicalai.inference.model.get_adapter", return_value=mock_adapter): + yield mock_adapter + + +class TestInferenceModelComponentConfig: + def test_path_rooted_round_trip(self, tmp_path: Path, _patch_adapter: MagicMock) -> None: + from physicalai.inference import InferenceModel + + export_dir = _make_export_dir(tmp_path) + model = InferenceModel( + export_dir=export_dir, + policy_name="act", + backend="openvino", + device="cpu", + ) + wire = _assert_construction_round_trip(model) + assert wire["class_path"] == "physicalai.inference.InferenceModel" + assert wire["init_args"] == { + "export_dir": str(export_dir), + "policy_name": "act", + "backend": "openvino", + "device": "cpu", + } + + def test_relative_export_dir_as_given( + self, tmp_path: Path, _patch_adapter: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + from physicalai.inference import InferenceModel + + monkeypatch.chdir(tmp_path) + _make_export_dir(tmp_path) + rel = Path("exports") + + model = InferenceModel(export_dir=rel, backend="openvino", device="cpu") + wire = _assert_construction_round_trip(model) + assert wire["init_args"]["export_dir"] == "exports" + + def test_scalar_adapter_kwargs_round_trip(self, tmp_path: Path, _patch_adapter: MagicMock) -> None: + from physicalai.inference import InferenceModel + + model = InferenceModel( + export_dir=_make_export_dir(tmp_path), + backend="openvino", + device="cpu", + num_threads=4, + ) + wire = _assert_construction_round_trip(model) + assert wire["init_args"]["num_threads"] == 4 + + def test_omitted_overrides_stay_omitted(self, tmp_path: Path, _patch_adapter: MagicMock) -> None: + from physicalai.inference import InferenceModel + + model = InferenceModel(export_dir=_make_export_dir(tmp_path), backend="openvino", device="cpu") + wire = to_config(model) + for key in ("runner", "preprocessors", "postprocessors", "callbacks"): + assert key not in wire["init_args"] + + def test_live_runner_fails(self, tmp_path: Path, _patch_adapter: MagicMock) -> None: + from physicalai.inference import InferenceModel + from physicalai.inference.runners import SinglePass + + model = InferenceModel( + export_dir=_make_export_dir(tmp_path), + backend="openvino", + device="cpu", + runner=SinglePass(), + ) + with pytest.raises(ComponentConfigError, match=r"init_args\.runner"): + to_config(model) + + def test_live_preprocessors_fail(self, tmp_path: Path, _patch_adapter: MagicMock) -> None: + from physicalai.inference import InferenceModel + + model = InferenceModel( + export_dir=_make_export_dir(tmp_path), + backend="openvino", + device="cpu", + preprocessors=[MagicMock()], + ) + with pytest.raises(ComponentConfigError, match=r"init_args\.preprocessors"): + to_config(model) + + def test_live_postprocessors_fail(self, tmp_path: Path, _patch_adapter: MagicMock) -> None: + from physicalai.inference import InferenceModel + + model = InferenceModel( + export_dir=_make_export_dir(tmp_path), + backend="openvino", + device="cpu", + postprocessors=[MagicMock()], + ) + with pytest.raises(ComponentConfigError, match=r"init_args\.postprocessors"): + to_config(model) + + def test_live_callbacks_fail(self, tmp_path: Path, _patch_adapter: MagicMock) -> None: + from physicalai.inference import InferenceModel + + model = InferenceModel( + export_dir=_make_export_dir(tmp_path), + backend="openvino", + device="cpu", + callbacks=[MagicMock()], + ) + with pytest.raises(ComponentConfigError, match=r"init_args\.callbacks"): + to_config(model) + + def test_non_scalar_dict_adapter_kwarg_fails(self, tmp_path: Path, _patch_adapter: MagicMock) -> None: + from physicalai.inference import InferenceModel + + model = InferenceModel( + export_dir=_make_export_dir(tmp_path), + backend="openvino", + device="cpu", + config_blob={"a": 1}, + ) + with pytest.raises(ComponentConfigError, match=r"init_args\.config_blob"): + to_config(model) + + def test_non_scalar_list_adapter_kwarg_fails(self, tmp_path: Path, _patch_adapter: MagicMock) -> None: + from physicalai.inference import InferenceModel + + model = InferenceModel( + export_dir=_make_export_dir(tmp_path), + backend="openvino", + device="cpu", + tags=["x", "y"], + ) + with pytest.raises(ComponentConfigError, match=r"init_args\.tags"): + to_config(model) diff --git a/tests/unit/runtime/test_runtime_component_config.py b/tests/unit/runtime/test_runtime_component_config.py new file mode 100644 index 0000000..34d32e5 --- /dev/null +++ b/tests/unit/runtime/test_runtime_component_config.py @@ -0,0 +1,666 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +# ruff:file-ignore[undocumented-public-module, undocumented-public-class, undocumented-public-method, undocumented-public-init, assert, magic-value-comparison] + +"""Construction round-trips for runtime ``@export_config`` wiring (steps 6–7).""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Generator +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from physicalai.config import ComponentConfigError, instantiate, is_config_exportable, to_config + +# SO101 imports scservo_sdk at module load; keep unit tests hardware-free. +# Use setdefault — not patch.dict(sys.modules) — because patch.dict restores by +# clearing sys.modules and drops modules imported while the patch is active +# (e.g. jsonargparse ``build_parser``), which poisons later YAML config loads. +sys.modules.setdefault("scservo_sdk", MagicMock()) + + +def _assert_construction_round_trip(value: object) -> dict[str, Any]: + assert is_config_exportable(value) + config = to_config(value) + wire: dict[str, Any] = json.loads(json.dumps(config)) + restored = instantiate(wire) + assert type(restored) is type(value) + assert to_config(restored) == wire + return wire + + +def _make_export_dir(tmp_path: Path, *, backend: str = "openvino") -> Path: + export_dir = tmp_path / "exports" + export_dir.mkdir(exist_ok=True) + artifact = "act.xml" if backend == "openvino" else f"act.{backend}" + manifest = { + "format": "policy_package", + "version": "1.0", + "policy": { + "name": "act", + "source": {"class_path": "physicalai.policies.act.ACT"}, + }, + "model": { + "artifacts": {backend: artifact}, + "runner": {"class_path": "physicalai.inference.runners.SinglePass", "init_args": {}}, + }, + } + with (export_dir / "manifest.json").open("w") as f: + json.dump(manifest, f) + (export_dir / artifact).touch() + if artifact.endswith(".xml"): + (export_dir / artifact.replace(".xml", ".bin")).touch() + return export_dir + + +@pytest.fixture +def mock_adapter() -> MagicMock: + adapter = MagicMock() + adapter.input_names = [] + adapter.output_names = [] + adapter.default_device.return_value = "cpu" + return adapter + + +@pytest.fixture +def _patch_adapter(mock_adapter: MagicMock) -> Generator[MagicMock, None, None]: + with patch("physicalai.inference.model.get_adapter", return_value=mock_adapter): + yield mock_adapter + + +@pytest.fixture +def inference_model(tmp_path: Path, _patch_adapter: MagicMock) -> Any: + from physicalai.inference import InferenceModel + + return InferenceModel(export_dir=_make_export_dir(tmp_path), backend="openvino", device="cpu") + + +# --------------------------------------------------------------------------- +# Smoothers / queues / execution +# --------------------------------------------------------------------------- + + +class TestSmootherComponentConfig: + def test_lerp_round_trip(self) -> None: + from physicalai.runtime import LerpSmoother + + wire = _assert_construction_round_trip(LerpSmoother(duration_frames=7)) + assert wire["class_path"] == "physicalai.runtime.LerpSmoother" + assert wire["init_args"] == {"duration_frames": 7} + + def test_lerp_default_omitted(self) -> None: + from physicalai.runtime import LerpSmoother + + wire = _assert_construction_round_trip(LerpSmoother()) + assert wire["init_args"] == {} + + def test_replace_round_trip(self) -> None: + from physicalai.runtime import ReplaceSmoother + + wire = _assert_construction_round_trip(ReplaceSmoother()) + assert wire["class_path"] == "physicalai.runtime.ReplaceSmoother" + assert wire["init_args"] == {} + + +class TestActionQueueComponentConfig: + def test_chunked_bare_restores_replace_smoother(self) -> None: + from physicalai.runtime import ChunkedActionQueue, ReplaceSmoother + + queue = ChunkedActionQueue() + wire = _assert_construction_round_trip(queue) + assert wire["class_path"] == "physicalai.runtime.ChunkedActionQueue" + assert wire["init_args"] == {} + restored = instantiate(wire) + assert isinstance(restored._smoother, ReplaceSmoother) # type: ignore[attr-defined] + + def test_chunked_explicit_lerp_nested(self) -> None: + from physicalai.runtime import ChunkedActionQueue, LerpSmoother + + queue = ChunkedActionQueue(smoother=LerpSmoother(duration_frames=3)) + wire = _assert_construction_round_trip(queue) + assert wire["init_args"]["smoother"] == { + "class_path": "physicalai.runtime.LerpSmoother", + "init_args": {"duration_frames": 3}, + } + + def test_chunked_undecorated_smoother_fails(self) -> None: + from physicalai.runtime import ChunkedActionQueue + from physicalai.runtime.smoothers import ChunkSmoother + + class UndecoratedSmoother(ChunkSmoother): + def merge(self, remaining: Any, incoming: Any) -> Any: # noqa: ANN401 + return incoming + + queue = ChunkedActionQueue(smoother=UndecoratedSmoother()) + with pytest.raises(ComponentConfigError, match=r"init_args\.smoother"): + to_config(queue) + + def test_rtc_action_queue_round_trip(self) -> None: + from physicalai.runtime import RTCActionQueue + + wire = _assert_construction_round_trip(RTCActionQueue()) + assert wire["class_path"] == "physicalai.runtime.RTCActionQueue" + assert wire["init_args"] == {} + + +class TestExecutionComponentConfig: + def test_sync_default_omitted(self) -> None: + from physicalai.runtime import SyncExecution + + wire = _assert_construction_round_trip(SyncExecution()) + assert wire["class_path"] == "physicalai.runtime.SyncExecution" + assert wire["init_args"] == {} + + def test_sync_explicit_threshold(self) -> None: + from physicalai.runtime import SyncExecution + + wire = _assert_construction_round_trip(SyncExecution(request_threshold=0.25)) + assert wire["init_args"] == {"request_threshold": 0.25} + + def test_async_round_trip(self) -> None: + from physicalai.runtime import AsyncExecution + + wire = _assert_construction_round_trip( + AsyncExecution(request_threshold=0.1, watchdog_timeout_s=10.0) + ) + assert wire["class_path"] == "physicalai.runtime.AsyncExecution" + assert wire["init_args"] == {"request_threshold": 0.1, "watchdog_timeout_s": 10.0} + + def test_rtc_scalar_args_round_trip(self) -> None: + from physicalai.runtime import RTCExecution + + wire = _assert_construction_round_trip( + RTCExecution(chunk_size=50, execution_horizon=10, fps=30.0, max_guidance_weight=3.0) + ) + assert wire["class_path"] == "physicalai.runtime.RTCExecution" + assert wire["init_args"] == { + "chunk_size": 50, + "execution_horizon": 10, + "fps": 30.0, + "max_guidance_weight": 3.0, + } + + def test_rtc_live_latency_tracker_fails(self) -> None: + from physicalai.runtime import RTCExecution + + execution = RTCExecution(latency_tracker=MagicMock()) + with pytest.raises(ComponentConfigError, match=r"init_args\.latency_tracker"): + to_config(execution) + + def test_rtc_live_postprocessors_fail(self) -> None: + from physicalai.runtime import RTCExecution + + execution = RTCExecution(postprocessors=[MagicMock()]) + with pytest.raises(ComponentConfigError, match=r"init_args\.postprocessors"): + to_config(execution) + + +# --------------------------------------------------------------------------- +# PolicySource +# --------------------------------------------------------------------------- + + +class TestPolicySourceComponentConfig: + def test_model_only_omits_execution_and_queue( + self, inference_model: Any, _patch_adapter: MagicMock + ) -> None: + from physicalai.runtime import ChunkedActionQueue, LerpSmoother, PolicySource, SyncExecution + + source = PolicySource(model=inference_model, task="pick cube") + wire = _assert_construction_round_trip(source) + assert wire["class_path"] == "physicalai.runtime.PolicySource" + assert "execution" not in wire["init_args"] + assert "action_queue" not in wire["init_args"] + assert wire["init_args"]["task"] == "pick cube" + assert wire["init_args"]["model"]["class_path"] == "physicalai.inference.InferenceModel" + + restored = instantiate(wire) + assert isinstance(restored._execution, SyncExecution) # type: ignore[attr-defined] + assert isinstance(restored._action_queue, ChunkedActionQueue) # type: ignore[attr-defined] + assert isinstance(restored._action_queue._smoother, LerpSmoother) # type: ignore[attr-defined] + + def test_explicit_nested_graph(self, inference_model: Any, _patch_adapter: MagicMock) -> None: + from physicalai.runtime import ( + AsyncExecution, + ChunkedActionQueue, + PolicySource, + ReplaceSmoother, + ) + + source = PolicySource( + model=inference_model, + execution=AsyncExecution(request_threshold=0.2), + action_queue=ChunkedActionQueue(smoother=ReplaceSmoother()), + task="stack", + ) + wire = _assert_construction_round_trip(source) + assert wire["init_args"]["execution"]["class_path"] == "physicalai.runtime.AsyncExecution" + assert wire["init_args"]["action_queue"]["init_args"]["smoother"]["class_path"] == ( + "physicalai.runtime.ReplaceSmoother" + ) + assert wire["init_args"]["task"] == "stack" + + def test_rtc_policy_graph(self, inference_model: Any, _patch_adapter: MagicMock) -> None: + from physicalai.runtime import PolicySource, RTCActionQueue, RTCExecution + + source = PolicySource( + model=inference_model, + execution=RTCExecution(chunk_size=40, fps=30.0), + action_queue=RTCActionQueue(), + ) + wire = _assert_construction_round_trip(source) + assert wire["init_args"]["execution"]["class_path"] == "physicalai.runtime.RTCExecution" + assert wire["init_args"]["action_queue"]["class_path"] == "physicalai.runtime.RTCActionQueue" + + +# --------------------------------------------------------------------------- +# TeleopSource +# --------------------------------------------------------------------------- + + +class TestTeleopSourceComponentConfig: + def test_leader_nested_round_trip(self) -> None: + from physicalai.robot import SO101 + from physicalai.runtime import TeleopSource + + calibration = { + "shoulder_pan": {"id": 1, "drive_mode": 0, "homing_offset": 2048, "range_min": 707, "range_max": 3439}, + "shoulder_lift": {"id": 2, "drive_mode": 1, "homing_offset": 1024, "range_min": 669, "range_max": 3292}, + "elbow_flex": {"id": 3, "drive_mode": 0, "homing_offset": 2048, "range_min": 846, "range_max": 3069}, + "wrist_flex": {"id": 4, "drive_mode": 0, "homing_offset": 2048, "range_min": 956, "range_max": 3311}, + "wrist_roll": {"id": 5, "drive_mode": 0, "homing_offset": 2048, "range_min": 59, "range_max": 3946}, + "gripper": {"id": 6, "drive_mode": 0, "homing_offset": 2048, "range_min": 2026, "range_max": 3074}, + } + leader = SO101(port="/dev/ttyUSB0", calibration=calibration, role="leader") + source = TeleopSource(leader=leader) + wire = _assert_construction_round_trip(source) + assert wire["class_path"] == "physicalai.runtime.TeleopSource" + assert "to_action" not in wire["init_args"] + assert wire["init_args"]["leader"]["class_path"] == "physicalai.robot.SO101" + + def test_supplied_to_action_fails(self) -> None: + from physicalai.robot import SO101 + from physicalai.runtime import TeleopSource + + calibration = { + "shoulder_pan": {"id": 1, "drive_mode": 0, "homing_offset": 2048, "range_min": 707, "range_max": 3439}, + "shoulder_lift": {"id": 2, "drive_mode": 1, "homing_offset": 1024, "range_min": 669, "range_max": 3292}, + "elbow_flex": {"id": 3, "drive_mode": 0, "homing_offset": 2048, "range_min": 846, "range_max": 3069}, + "wrist_flex": {"id": 4, "drive_mode": 0, "homing_offset": 2048, "range_min": 956, "range_max": 3311}, + "wrist_roll": {"id": 5, "drive_mode": 0, "homing_offset": 2048, "range_min": 59, "range_max": 3946}, + "gripper": {"id": 6, "drive_mode": 0, "homing_offset": 2048, "range_min": 2026, "range_max": 3074}, + } + leader = SO101(port="/dev/ttyUSB0", calibration=calibration, role="leader") + source = TeleopSource(leader=leader, to_action=lambda obs: obs.joint_positions) + with pytest.raises(ComponentConfigError, match=r"init_args\.to_action"): + to_config(source) + + +# --------------------------------------------------------------------------- +# Callbacks +# --------------------------------------------------------------------------- + + +class TestCallbackComponentConfig: + def test_console_round_trip(self) -> None: + from physicalai.runtime import ConsoleCallback + + wire = _assert_construction_round_trip(ConsoleCallback(throttle_steps=15)) + assert wire["class_path"] == "physicalai.runtime.ConsoleCallback" + assert wire["init_args"] == {"throttle_steps": 15} + + def test_low_pass_round_trip(self) -> None: + from physicalai.runtime import LowPassFilterCallback + + wire = _assert_construction_round_trip(LowPassFilterCallback(alpha=0.3)) + assert wire["class_path"] == "physicalai.runtime.LowPassFilterCallback" + assert wire["init_args"] == {"alpha": 0.3} + + def test_jsonl_temp_path_round_trip(self, tmp_path: Path) -> None: + from physicalai.runtime import JsonlCallback + + path = tmp_path / "events.jsonl" + cb = JsonlCallback(path=path, record_chunks=True) + restored = None + try: + assert is_config_exportable(cb) + config = to_config(cb) + wire: dict[str, Any] = json.loads(json.dumps(config)) + restored = instantiate(wire) + assert type(restored) is type(cb) + assert to_config(restored) == wire + assert wire["class_path"] == "physicalai.runtime.JsonlCallback" + assert wire["init_args"]["path"] == str(path) + assert wire["init_args"]["record_chunks"] is True + finally: + cb.close() + if restored is not None: + restored.close() # type: ignore[attr-defined] + + def test_rerun_scalar_round_trip(self) -> None: + from physicalai.runtime import RerunCallback + + rr = MagicMock() + rr.__name__ = "rerun" + with patch.dict(sys.modules, {"rerun": rr}): + cb = RerunCallback( + mode="connect", + connect_addr="10.0.0.1:9876", + application_id="test-app", + image_decimation=5, + log_images=False, + ) + wire = _assert_construction_round_trip(cb) + assert wire["class_path"] == "physicalai.runtime.RerunCallback" + assert wire["init_args"]["mode"] == "connect" + assert wire["init_args"]["connect_addr"] == "10.0.0.1:9876" + assert wire["init_args"]["application_id"] == "test-app" + assert wire["init_args"]["image_decimation"] == 5 + assert wire["init_args"]["log_images"] is False + + def test_async_nested_console_round_trip(self) -> None: + from physicalai.runtime import AsyncCallback, ConsoleCallback + + cb = AsyncCallback(ConsoleCallback(throttle_steps=10), max_queue=64) + try: + wire = _assert_construction_round_trip(cb) + assert wire["class_path"] == "physicalai.runtime.AsyncCallback" + assert wire["init_args"]["max_queue"] == 64 + assert wire["init_args"]["inner"]["class_path"] == "physicalai.runtime.ConsoleCallback" + restored = instantiate(wire) + try: + assert isinstance(restored._inner, ConsoleCallback) # type: ignore[attr-defined] + finally: + restored.close() # type: ignore[attr-defined] + finally: + cb.close() + + def test_async_undecorated_inner_fails(self) -> None: + from physicalai.runtime import AsyncCallback + + class UndecoratedTickCallback: + def on_tick(self, event: object) -> None: + pass + + cb = AsyncCallback(UndecoratedTickCallback()) + try: + with pytest.raises(ComponentConfigError, match=r"init_args\.inner"): + to_config(cb) + finally: + cb.close() + + def test_async_rejects_action_hook_inner(self) -> None: + from physicalai.runtime import AsyncCallback, LowPassFilterCallback + + with pytest.raises(TypeError, match="action hooks"): + AsyncCallback(LowPassFilterCallback(alpha=0.5)) + + +# --------------------------------------------------------------------------- +# RobotRuntime (step 7) +# --------------------------------------------------------------------------- + + +_SAMPLE_CALIBRATION: dict[str, Any] = { + "shoulder_pan": {"id": 1, "drive_mode": 0, "homing_offset": 2048, "range_min": 707, "range_max": 3439}, + "shoulder_lift": {"id": 2, "drive_mode": 1, "homing_offset": 1024, "range_min": 669, "range_max": 3292}, + "elbow_flex": {"id": 3, "drive_mode": 0, "homing_offset": 2048, "range_min": 846, "range_max": 3069}, + "wrist_flex": {"id": 4, "drive_mode": 0, "homing_offset": 2048, "range_min": 956, "range_max": 3311}, + "wrist_roll": {"id": 5, "drive_mode": 0, "homing_offset": 2048, "range_min": 59, "range_max": 3946}, + "gripper": {"id": 6, "drive_mode": 0, "homing_offset": 2048, "range_min": 2026, "range_max": 3074}, +} + + +def _make_so101(*, port: str = "/dev/ttyACM0") -> Any: + from physicalai.robot import SO101 + + return SO101(port=port, calibration=_SAMPLE_CALIBRATION) + + +def _make_full_runtime(inference_model: Any) -> Any: + from physicalai.capture import UVCCamera + from physicalai.runtime import ConsoleCallback, PolicySource, RobotRuntime, SyncExecution + + return RobotRuntime( + robot=_make_so101(), + action_source=PolicySource( + model=inference_model, + execution=SyncExecution(request_threshold=0.25), + task="pick cube", + ), + fps=30.0, + cameras={"wrist": UVCCamera(device="/dev/video0", width=640, height=480, fps=30, backend="v4l2")}, + callbacks=[ConsoleCallback(throttle_steps=15)], + ) + + +class TestRobotRuntimeComponentConfig: + def test_complete_tree_instantiate_round_trip( + self, inference_model: Any, _patch_adapter: MagicMock + ) -> None: + runtime = _make_full_runtime(inference_model) + wire = _assert_construction_round_trip(runtime) + assert wire["class_path"] == "physicalai.runtime.RobotRuntime" + assert wire["init_args"]["fps"] == 30.0 + assert wire["init_args"]["robot"]["class_path"] == "physicalai.robot.SO101" + assert wire["init_args"]["action_source"]["class_path"] == "physicalai.runtime.PolicySource" + assert wire["init_args"]["cameras"]["wrist"]["class_path"] == "physicalai.capture.UVCCamera" + assert wire["init_args"]["callbacks"][0]["class_path"] == "physicalai.runtime.ConsoleCallback" + # Connection / session state are never part of the recipe. + assert "session_id" not in wire["init_args"] + assert "_connected" not in wire["init_args"] + from physicalai.runtime import RobotRuntime + + restored = instantiate(wire) + assert isinstance(restored, RobotRuntime) + assert restored._connected is False # noqa: SLF001 + assert restored._session_id == "" # noqa: SLF001 + + def test_complete_tree_jsonargparse_under_runtime( + self, + inference_model: Any, + _patch_adapter: MagicMock, + tmp_path: Path, + ) -> None: + from physicalai.capture import UVCCamera + from physicalai.cli.run import build_parser + from physicalai.robot import SO101 + from physicalai.runtime import ConsoleCallback, PolicySource, RobotRuntime + + runtime = _make_full_runtime(inference_model) + wire = to_config(runtime) + # CLI nests constructor args under ``runtime:`` (not the bare ComponentConfig). + # Prefer JSON for the ActionConfigFile payload — avoids PyYAML C-loader + # interaction with ``yaml.safe_dump`` that can poison later YAML parses in + # the same process. + cfg_path = tmp_path / "runtime.json" + cfg_path.write_text(json.dumps({"runtime": wire["init_args"]})) + + parser = build_parser() + ns = parser.parse_args(["--config", str(cfg_path)]) + restored = parser.instantiate(ns).runtime + assert isinstance(restored, RobotRuntime) + assert restored._connected is False # noqa: SLF001 + assert restored._session_id == "" # noqa: SLF001 + assert restored._fps == 30.0 # noqa: SLF001 + assert isinstance(restored.robot, SO101) + assert isinstance(restored.action_source, PolicySource) + assert set(restored.cameras) == {"wrist"} + assert isinstance(restored.cameras["wrist"], UVCCamera) + assert isinstance(restored._bus._callbacks[0], ConsoleCallback) # noqa: SLF001 + # jsonargparse may supply omitted ctor defaults; check public class_path shape + # after a JSON boundary (strips StrEnum identity / applied defaults noise). + restored_wire = json.loads(json.dumps(to_config(restored))) + assert restored_wire["class_path"] == "physicalai.runtime.RobotRuntime" + assert restored_wire["init_args"]["fps"] == 30.0 + assert restored_wire["init_args"]["robot"]["class_path"] == "physicalai.robot.SO101" + assert restored_wire["init_args"]["action_source"]["class_path"] == "physicalai.runtime.PolicySource" + assert restored_wire["init_args"]["cameras"]["wrist"]["class_path"] == "physicalai.capture.UVCCamera" + assert restored_wire["init_args"]["callbacks"][0]["class_path"] == "physicalai.runtime.ConsoleCallback" + + via_from_config = RobotRuntime.from_config(cfg_path) + assert isinstance(via_from_config, RobotRuntime) + assert via_from_config._connected is False # noqa: SLF001 + assert via_from_config._fps == 30.0 # noqa: SLF001 + + def test_omitted_cameras_and_callbacks( + self, inference_model: Any, _patch_adapter: MagicMock + ) -> None: + from physicalai.runtime import PolicySource, RobotRuntime + + runtime = RobotRuntime( + robot=_make_so101(), + action_source=PolicySource(model=inference_model), + fps=20.0, + ) + wire = _assert_construction_round_trip(runtime) + assert "cameras" not in wire["init_args"] + assert "callbacks" not in wire["init_args"] + + def test_empty_callbacks_stay_empty( + self, inference_model: Any, _patch_adapter: MagicMock + ) -> None: + from physicalai.runtime import PolicySource, RobotRuntime + + runtime = RobotRuntime( + robot=_make_so101(), + action_source=PolicySource(model=inference_model), + fps=20.0, + callbacks=[], + ) + wire = _assert_construction_round_trip(runtime) + assert wire["init_args"]["callbacks"] == [] + + def test_undecorated_robot_fails( + self, inference_model: Any, _patch_adapter: MagicMock + ) -> None: + from physicalai.runtime import PolicySource, RobotRuntime + + class UndecoratedRobot: + def __init__(self) -> None: + pass + + runtime = RobotRuntime( + robot=UndecoratedRobot(), # type: ignore[arg-type] + action_source=PolicySource(model=inference_model), + fps=10.0, + ) + with pytest.raises(ComponentConfigError, match=r"init_args\.robot"): + to_config(runtime) + + def test_undecorated_action_source_fails(self) -> None: + from physicalai.runtime import RobotRuntime + + class UndecoratedSource: + def connect(self, **_kwargs: object) -> None: ... + def disconnect(self) -> None: ... + def update(self, *_args: object, **_kwargs: object) -> object: + return None + + runtime = RobotRuntime( + robot=_make_so101(), + action_source=UndecoratedSource(), # type: ignore[arg-type] + fps=10.0, + ) + with pytest.raises(ComponentConfigError, match=r"init_args\.action_source"): + to_config(runtime) + + def test_undecorated_camera_fails( + self, inference_model: Any, _patch_adapter: MagicMock + ) -> None: + from physicalai.runtime import PolicySource, RobotRuntime + + class UndecoratedCamera: + def __init__(self) -> None: + pass + + runtime = RobotRuntime( + robot=_make_so101(), + action_source=PolicySource(model=inference_model), + fps=10.0, + cameras={"wrist": UndecoratedCamera()}, # type: ignore[dict-item] + ) + with pytest.raises(ComponentConfigError, match=r"init_args\.cameras\.wrist"): + to_config(runtime) + + def test_undecorated_callback_fails( + self, inference_model: Any, _patch_adapter: MagicMock + ) -> None: + from physicalai.runtime import PolicySource, RobotRuntime + + class UndecoratedCallback: + def on_tick(self, event: object) -> None: + pass + + runtime = RobotRuntime( + robot=_make_so101(), + action_source=PolicySource(model=inference_model), + fps=10.0, + callbacks=[UndecoratedCallback()], + ) + with pytest.raises(ComponentConfigError, match=r"init_args\.callbacks\[0\]"): + to_config(runtime) + + def test_jsonargparse_sees_original_ctor_signature(self) -> None: + import inspect + + from physicalai.runtime import RobotRuntime + + params = list(inspect.signature(RobotRuntime.__init__).parameters) + assert params == ["self", "robot", "action_source", "fps", "cameras", "callbacks"] + + def test_nested_shared_robot_and_shared_camera( + self, inference_model: Any, _patch_adapter: MagicMock + ) -> None: + from physicalai.capture import SharedCamera + from physicalai.robot import SharedRobot + from physicalai.runtime import PolicySource, RobotRuntime, SyncExecution + + runtime = RobotRuntime( + robot=SharedRobot( + "follower", + robot={ + "class_path": "tests.unit.robot.transport.fake.FakeRobot", + "init_args": {"port": "/dev/fake-rt", "device_ids": ["fake:follower"]}, + }, + ), + action_source=PolicySource( + model=inference_model, + execution=SyncExecution(), + ), + fps=15.0, + cameras={ + "wrist": SharedCamera( + camera={ + "class_path": "physicalai.capture.UVCCamera", + "init_args": { + "device": "/dev/video2", + "width": 320, + "height": 240, + "fps": 15, + "backend": "v4l2", + }, + }, + ), + }, + ) + wire = _assert_construction_round_trip(runtime) + assert wire["init_args"]["robot"]["class_path"] == "physicalai.robot.SharedRobot" + assert wire["init_args"]["robot"]["init_args"]["name"] == "follower" + nested_robot = wire["init_args"]["robot"]["init_args"]["robot"] + assert nested_robot["class_path"] == "tests.unit.robot.transport.fake.FakeRobot" + assert wire["init_args"]["cameras"]["wrist"]["class_path"] == "physicalai.capture.SharedCamera" + nested_cam = wire["init_args"]["cameras"]["wrist"]["init_args"]["camera"] + assert nested_cam["class_path"] == "physicalai.capture.UVCCamera" + restored = instantiate(wire) + assert isinstance(restored, RobotRuntime) + assert isinstance(restored.robot, SharedRobot) + assert isinstance(restored.cameras["wrist"], SharedCamera) + assert restored._connected is False # noqa: SLF001 + assert not restored.robot.is_connected() + assert not restored.cameras["wrist"].is_connected From ce5e22b4a19517016cce27be54b4de9dfa298188 Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Thu, 23 Jul 2026 13:09:11 +0200 Subject: [PATCH 13/16] docs: keep one canonical component-config note Drop the report/brief duplicates of the design note, fold the rollout status into the spec, and track the wire-decision record the spec links to. Co-authored-by: Cursor --- docs/development/README.md | 4 +- docs/development/component-config-brief.md | 76 ---- docs/development/component-config-report.md | 374 ------------------ docs/development/component-config.md | 5 + .../shared-construction-wire-decision.md | 16 + 5 files changed, 22 insertions(+), 453 deletions(-) delete mode 100644 docs/development/component-config-brief.md delete mode 100644 docs/development/component-config-report.md create mode 100644 docs/development/shared-construction-wire-decision.md diff --git a/docs/development/README.md b/docs/development/README.md index 67a3364..9e61e7e 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -4,6 +4,4 @@ Repository-wide contributor guidance. - [Coding Standards](coding-standards.md) — coding, writing, and testing standards for all contributors and agents. - [Security Rules](security.md) — security requirements for `src/physicalai/`. -- [Component config (brief)](component-config-brief.md) — 3–5 min reviewer overview for opinions. -- [Component config (report)](component-config-report.md) — longer design map with diagrams. -- [Component config (spec)](component-config.md) — canonical accepted design note. +- [Component config](component-config.md) — canonical accepted design note for captured component configuration. diff --git a/docs/development/component-config-brief.md b/docs/development/component-config-brief.md deleted file mode 100644 index ddbb2ca..0000000 --- a/docs/development/component-config-brief.md +++ /dev/null @@ -1,76 +0,0 @@ -# Component config — reviewer brief - -**Read time:** ~3–5 minutes -**Status:** Accepted — implement per rollout -**Full map:** [component-config-report.md](component-config-report.md) -**Canonical spec:** [component-config.md](component-config.md) -**Context:** Follow-up to [Studio SharedRobot #818](https://github.com/open-edge-platform/physical-ai-studio/pull/818) - ---- - -## Problem - -jsonargparse can build a component tree from YAML, but nothing recovers constructor -args from a **live** plugin/Studio object. SharedRobot/SharedCamera need that recipe -to spawn an owner/publisher child. Today Studio either special-cases serializers or -cannot share third-party drivers cleanly. - -## Proposal - -```text -@export_config / @export_config(class_path=..., scalar_var_kwargs=...) → remember supplied __init__ args -to_config(live) → {class_path, init_args} # ComponentConfig -instantiate(cfg)→ fresh disconnected instance -to_config_value() → domain arg → plain JSON inside init_args -# scalar_var_kwargs=True: flattened **kwargs must be JSON scalars (InferenceModel) -``` - -Same shape as existing jsonargparse configs. Protocols (`Robot`, `Camera`, …) stay -behavior-only. Transport settings (`name`, `service_name`, rates) stay in envelopes. -Studio needs only `is_config_exportable` + `to_config`. - -```mermaid -flowchart LR - Live["Live @export_config component"] --> TC[to_config] - TC --> CC[ComponentConfig] - CC --> Inst[instantiate] - CC --> Stdin[Owner / publisher stdin] -``` - -## Decisions we want opinions on - -| Decision | Locked choice | Why | -| ------------------------------------------ | --------------------------------------------------------------- | -------------------------------------------------------------- | -| Opt-in decorator vs `to_dict` on protocols | `@export_config` in `physicalai.config` | Keeps runtime protocols clean; plugins stay transport-agnostic | -| Export ≠ share | Studio decides whether to wrap; Runtime only exports the recipe | Decorating for CLI must not force SharedRobot | -| Paths | Relative as given; child inherits parent cwd | Fits “export a folder”; no absolutizer API | -| Inference factory | **Out of v1, keep inference as is** | `ComponentSpec` differs (defaults, extras, registry) | - -## What v1 covers (sketch) - -- Robots (SO101, WidowX, bimanual as one owner), cameras, PolicySource graph, - path-rooted `InferenceModel`, RobotRuntime + listed callbacks -- SharedCamera: ComponentConfig-only (`camera=` / `from_config` / - `from_camera`); shareable built-in `service_name` derived in transport - (`uvc` / `realsense` / `basler`); stubs (`ip` / `genicam`) and third-party - must pass an explicit name; flat `camera_type` / `camera_kwargs` rejected -- `SharedRobot` / `SharedCamera` are `@export_config` **construction - recipes** (no run-state snapshot); `to_config` of a runtime that holds - Shared\* is supported — enables Studio step-8; Studio client drop of - interim serializers remains step 8 -- Trust: `instantiate` only on local / parent→child config — never network metadata - -## Out of scope (v1) - -Inference `ComponentSpec` unification · portable bundles · callable -`to_action` · per-arm SharedRobot · persisted workflow document versioning - -## Where to dig deeper - -| Need | Doc | -| ------------------------------------------- | ------------------------------------ | -| Diagrams, rollout, plugin checklist | [Report](component-config-report.md) | -| Inheritance, serialization, tests, security | [Spec](component-config.md) | -| Security rules for `src/physicalai/` | [security.md](security.md) | - ---- diff --git a/docs/development/component-config-report.md b/docs/development/component-config-report.md deleted file mode 100644 index a55b114..0000000 --- a/docs/development/component-config-report.md +++ /dev/null @@ -1,374 +0,0 @@ -# Captured component configuration — design report - -**Audience:** Runtime developers -**Status:** Accepted — implement per rollout -**Short brief (reviewers):** [component-config-brief.md](component-config-brief.md) -**Canonical spec:** [component-config.md](component-config.md) -**Context:** Follow-up to [Studio SharedRobot #818](https://github.com/open-edge-platform/physical-ai-studio/pull/818) - -This report is the human-readable map of the design. Prefer the [brief](component-config-brief.md) for a quick overview. When behavior is ambiguous, the canonical design note wins. - -![Architecture: behavior, construction, and transport layers](captured-component-config-architecture.png) - ---- - -## One-sentence summary - -Opt-in `@export_config` keeps constructor args as JSON-safe `class_path` + `init_args`, so Studio and SharedRobot/SharedCamera can spawn fresh components **without** special-case serializers or transport imports in plugins. - ---- - -## Why it exists - -```mermaid -flowchart LR - subgraph today [Today] - JA[jsonargparse YAML] -->|instantiate| Live1[Live component] - Live2[Live driver] -.->|cannot recover args| Gap[Gap] - Gap --> Studio[Studio / owner spawn] - end - - subgraph after [After this design] - Live3[Live @export_config component] - Live3 -->|to_config| CC[ComponentConfig] - CC -->|instantiate| Live4[Fresh disconnected component] - CC -->|ComponentConfig stdin| Owner[Owner / publisher child] - end -``` - -| Pain | Fix | -| --------------------------------------------------------------- | ------------------------------------------------ | -| jsonargparse builds trees but cannot reverse a live object | `@export_config` + `to_config` | -| SharedRobot needs class + JSON kwargs across a process boundary | Same `ComponentConfig` in the owner envelope | -| Studio serializers per robot/camera type | Plugins opt in; Studio only checks exportability | -| Putting `to_dict` on `Robot` / `Camera` | Keep protocols behavior-only | - ---- - -## Three-layer mental model - -```mermaid -flowchart TB - subgraph behavior [Runtime behavior — unchanged] - R[Robot] - C[Camera] - A[ActionSource] - end - - subgraph construction [Construction — new] - DI["@export_config"] - TC[to_config / instantiate] - CFG["ComponentConfig
class_path + init_args"] - DI --> TC --> CFG - end - - subgraph transport [Transport envelopes — consume config] - RO[RobotOwnerConfig] - CP[Camera publisher config] - SR[SharedRobot] - SC[SharedCamera] - RO --> SR - CP --> SC - end - - behavior -.->|opt-in only| construction - CFG -->|robot: / camera:| transport -``` - -**Remember:** - -- **Construction** = how to build a fresh instance -- **Transport** = name, rate, `service_name`, timeouts, sharing -- **Exportable ≠ shared** — Studio decides whether to wrap in `SharedRobot`; `@export_config` only enables config export - ---- - -## Public API (`physicalai.config`) - -```python -ComponentConfig = {"class_path": str, "init_args": dict[str, JsonValue]} - -@export_config # opt-in on concrete classes -@export_config(class_path="...") # when public import ≠ defining module -@export_config(..., scalar_var_kwargs=True) # seal flattened **kwargs to JSON scalars -to_config(value) # live → ComponentConfig -instantiate(config) # trusted ComponentConfig → fresh object -is_config_exportable # @export_config marker only -# Domain args: to_config_value() → plain JSON (ConfigValue Protocol) -``` - -```mermaid -sequenceDiagram - participant App - participant Export as @export_config - participant API as physicalai.config - participant Child as Owner/publisher - - App->>Export: SO101(port, calibration) - Export-->>App: live robot + stored init_args - App->>API: to_config(robot) - API-->>App: ComponentConfig - App->>Child: ComponentConfig stdin JSON (same cwd) - Child->>API: instantiate(robot) - API-->>Child: fresh SO101 (not connected) -``` - -Library code always uses module-level `to_config(value)` (type-checker friendly). An injected instance method is optional sugar only. - ---- - -## End-to-end data flow - -```mermaid -flowchart LR - subgraph parent [Parent process] - B[Builder / CLI / Studio] - T[to_config] - Env[Transport envelope
robot/camera ComponentConfig] - B --> T --> Env - end - - subgraph child [Child process same cwd] - Val[Validate new shape] - Inst[instantiate] - Proto[Check Robot/Camera] - Conn[connect later] - Val --> Inst --> Proto --> Conn - end - - Env -->|trusted stdin JSON| Val -``` - -**Trust rule:** `instantiate()` is for trusted local / parent→child config only. Never run it on Zenoh metadata, camera metadata, or peer payloads. - ---- - -## What gets exported (v1) - -```mermaid -flowchart TB - RR[RobotRuntime] - RR --> Robot - RR --> AS[ActionSource] - RR --> Cams[cameras map] - RR --> Cbs[callbacks] - - Robot --> SO101 - Robot --> WidowXAI - Robot --> Bimanual["BimanualWidowXAI
left + right nested"] - - AS --> PS[PolicySource] - AS --> TS[TeleopSource] - - PS --> IM[InferenceModel
path-rooted] - PS --> Ex[Sync / Async / RTC Execution] - PS --> Q[Chunked / RTC ActionQueue] - Q --> Sm[Lerp / Replace Smoother] - - Cams --> UVC[UVC / RealSense / …] - Cbs --> CB[Console / LPF / Jsonl / Rerun / Async] -``` - -| Area | v1 rule | -| ------------------------ | ----------------------------------------------------------------------------------------- | -| Defaults | Capture only supplied args — omit ⇒ current ctor defaults on replay | -| `PolicySource` | Default queue uses **LerpSmoother**; bare `ChunkedActionQueue()` uses **ReplaceSmoother** | -| `InferenceModel` | Path-rooted (`export_dir`, scalars); live runner/processor overrides fail `to_config` | -| `TeleopSource.to_action` | Replayable only when omitted | -| Bimanual | One SharedRobot owner for the composite | -| Callbacks | Exact first-party set; observers with post-hoc `session_id` out of scope | - ---- - -## Transport migration (private wire) - -Do **not** bump `ROBOT_TRANSPORT_PROTOCOL_VERSION` or camera frame `PROTOCOL_VERSION` for this. Those are network/frame protocols. Startup stdin is a same-package `Popen` handshake — **hard cutover** to `ComponentConfig` with no `config_format` and no dual-read (recorded in the [spec](component-config.md#private-startup-envelopes-hard-cutover)). - -```text -# Robot owner — after -{name, robot: {class_path, init_args}, allow_remote, rate_hz, idle_timeout, …} - -# Camera publisher — after -{camera: {class_path, init_args}, service_name, idle_timeout, …} -``` - -Writers and readers speak only the new shape. Legacy flat stdin (`robot_class` / `camera_type`) is rejected before import. - -```mermaid -flowchart TB - subgraph robot_stdin [Robot owner stdin] - R2[name, rate_hz, …] - R3["robot: {class_path, init_args}"] - end - - subgraph cam_stdin [Camera publisher stdin] - C2[service_name + transport fields] - C3["camera: {class_path, init_args}"] - end -``` - -### SharedCamera naming - -| Mode | `service_name` | -| ------------------------ | -------------------------------------------------------------------------------------------------- | -| Shareable built-in spawn | Derived in transport (`builtin.py`): `class_path` → `uvc` / `realsense` / `basler` + device id | -| Stub / third-party spawn | **Required** explicit name (`ip` / `genicam` not in the map; same rule as third-party; no hashing) | -| Attach-only | Required; no construction config | - -`service_name` lives **beside** `camera: ComponentConfig`, never inside `init_args`. - -### Public API - -| Surface | Accept | Preferred | -| ---------------------- | ------------------------------------------------------------------------------------- | ----------------------------- | -| `SharedRobot` | `robot=` ComponentConfig (or `None` / `attach`); `@export_config` construction recipe | `from_config` / `from_robot` | -| `SharedCamera` | `camera=` ComponentConfig (or `None` / attach); `@export_config` construction recipe | `from_config` / `from_camera` | -| CLI `physicalai robot` | `--robot` ComponentConfig (required) | `--robot` | -| Metadata | Keep key `robot_class` | Value = public `class_path` | - -Flat `robot_class` / `robot_kwargs` on SharedRobot, serve CLI, and owner stdin -are unsupported (rejected on stdin; removed from the public API). Flat -`camera_type` / `camera_kwargs` on SharedCamera and publisher stdin are -likewise unsupported — ComponentConfig-only, matching SharedRobot. - -`from_robot` / `from_camera`: require exportable, **reject if connected**, never disconnect implicitly. - ---- - -## Paths: relative-first, cwd at open - -```mermaid -flowchart LR - subgraph export [to_config] - P1["Path → str(path) as given"] - P2["str unchanged
e.g. ./calibration.json"] - end - - subgraph resolve [instantiate / ctor open] - P3[Resolve against process cwd] - P4[Child inherits parent cwd at Popen] - P3 --> P4 - end -``` - -- Prefer relative paths for project- or folder-local configs (portable export directories). -- Do not absolutize in `to_config` or IPC writers. -- Unsupported: `chdir` between export and spawn when configs still contain relatives. -- Future bundle/export-folder root may pin resolution without absolutizing — not v1. - ---- - -## Studio consumption - -```mermaid -flowchart TD - Builder[Plugin builder] --> Driver[Ordinary Robot/Camera] - Driver --> Share{Studio share policy?} - Share -->|no| Direct[In-process driver] - Share -->|yes| Exp{is_config_exportable?} - Exp -->|no| Fail[Fail loud] - Exp -->|yes| Conn{connected?} - Conn -->|yes| Fail2[Fail — caller must disconnect] - Conn -->|no| SR[SharedRobot.from_config] -``` - -Plugins never import Zenoh, iceoryx2, `SharedRobot`, or `SharedCamera`. - ---- - -## Security (developer checklist) - -| Do | Don't | -| ----------------------------------------------------- | ----------------------------------------------------- | -| Treat `class_path` as executable trusted config | Instantiate configs from `/metadata` or network peers | -| Validate malformed input before import | Assume validation = sandbox | -| Carry config parent→child stdin only | Accept component configs from subscribers | -| Keep nesting depth bounded (`_MAX_CONFIG_DEPTH = 10`) | Unbounded recursive instantiate | - -See also [security.md](security.md) rules 4, 5, 11, 12. - ---- - -## Out of scope for v1 - -- Inference `ComponentSpec` / `instantiate_component` unification (different defaults, extras, registry) -- Self-contained portable bundles / `to_config(..., profile="self_contained")` / explicit bundle root -- `__component_path_keys__` / public path absolutizers -- Callable references for `TeleopSource.to_action` -- Per-arm SharedRobot for bimanual nests -- Persisted workflow document versioning - ---- - -## Implementation rollout - -```mermaid -gantt - title Suggested implementation order - dateFormat X - axisFormat %s - - section Core - physicalai.config engine :a1, 0, 1 - export_config + to_config :a2, 1, 2 - SO101 / WidowX / Bimanual :a3, 2, 3 - - section Transport - SharedRobot + owner hard cutover :b1, 3, 4 - SharedCamera + publisher cutover :b2, 4, 5 - - section Runtime tree - PolicySource graph + InferenceModel :c1, 5, 6 - RobotRuntime + callbacks :c2, 6, 7 - - section Downstream - Studio share policy :d1, 7, 8 - Docs / preview versioning note :d2, 8, 9 -``` - -| Step | Deliverable | Status | -| ---: | -------------------------------------------------------------------------------------------- | -------- | -| 1 | `ComponentConfig`, instantiate, depth/cycles, shared importer (no inference behavior change) | **done** | -| 2 | `@export_config`, `to_config`, `is_config_exportable` | **done** | -| 3 | SO101, WidowXAI, BimanualWidowXAI round-trips | **done** | -| 4 | SharedRobot + owner stdin + public/CLI ComponentConfig-only (cwd inherit for relatives) | **done** | -| 5 | SharedCamera + publisher stdin hard cutover + `service_name` rules | **done** | -| 6 | PolicySource graph, TeleopSource, path-rooted InferenceModel, v1 callbacks | **done** | -| 7 | RobotRuntime -> `instantiate` + jsonargparse | **done** | -| 7b | SharedRobot / SharedCamera `@export_config` construction recipes (runtime `to_config` path) | **done** | -| 8 | Studio drops interim serializers (enabled by 7b; Studio client still this step) | | -| 9 | User-facing docs | | -| 10 | Separate: inference factory follow-up (optional) | | - -Implement **one step at a time**. Keep the design note open as the contract. - ---- - -## Plugin checklist - -1. Implement `Robot` / `Camera` / `ActionSource` / callback protocol -2. `@export_config` — use `@export_config(class_path="…")` when re-exported; - `scalar_var_kwargs=True` when flattened `**kwargs` must be JSON scalars -3. JSON-normalizable args (JSON / nested `@export_config` / `to_config_value()`); - constructors accept normalized forms -4. Relative path args OK; keep cwd stable through Shared\* spawn (or use absolute/`Path` as given) -5. Stable public import path (decorator `class_path=` when it differs from defining module) -6. Round-trip test: `to_config` → `json` → `instantiate` → `to_config` equal - -## Studio: `is_config_exportable` + `to_config` only - -## Quick reference - -| Symbol | Role | -| ------------------------------ | ------------------------------------------------------------------- | -| `@export_config` | Opt into `ComponentConfig` export (stores supplied `__init__` args) | -| `@export_config(class_path=…)` | Public re-export path when ≠ defining module | -| `scalar_var_kwargs=True` | Seal flattened `**kwargs` to JSON scalars (default `False`) | -| `to_config_value()` | Domain arg → plain JSON inside `init_args` (`ConfigValue`) | -| `ComponentConfig` | Wire shape shared with jsonargparse | -| `to_config` / `instantiate` | Export / trusted rebuild | -| `is_config_exportable` | Can we get a recipe? (decorator marker) | -| Owner/publisher stdin | Hard cutover to `robot:` / `camera: ComponentConfig` | -| `service_name` | Camera transport identity (not construction) | - -**Full rules and required tests:** [component-config.md](component-config.md) diff --git a/docs/development/component-config.md b/docs/development/component-config.md index a4d1145..25e4d2b 100644 --- a/docs/development/component-config.md +++ b/docs/development/component-config.md @@ -891,6 +891,11 @@ specific subclasses only where tests or callers distinguish phases. ## Suggested rollout +Status: steps 1–7 are implemented in Runtime, including the +`SharedRobot` / `SharedCamera` `@export_config` construction recipes that +enable step 8. Steps 8–10 (Studio client cutover, user-facing docs, inference +follow-up) remain open. + 1. Add `ComponentConfig`, bounded normalization/instantiation, cycle checks, and one shared dotted-path resolver to `physicalai.config`. Consolidate the duplicated inference and robot importers behind that resolver diff --git a/docs/development/shared-construction-wire-decision.md b/docs/development/shared-construction-wire-decision.md new file mode 100644 index 0000000..2b36568 --- /dev/null +++ b/docs/development/shared-construction-wire-decision.md @@ -0,0 +1,16 @@ +# Shared construction wire decision + +**Status:** Accepted (recorded in the component-config design) + +Private robot-owner and camera-publisher startup stdin is a same-package +ephemeral `Popen` handshake, not a persisted peer protocol. Change the +construction payload to `robot: ComponentConfig` / `camera: ComponentConfig` +with a **hard cutover**: rewrite writers, readers, and fixtures in the same +PR as the Shared\* spawn path. Do **not** add `config_format`, dual-read, or +shape-detection fallback. + +Do not bump `ROBOT_TRANSPORT_PROTOCOL_VERSION` or camera frame +`PROTOCOL_VERSION` for this change; those version network/frame payloads, not +startup envelopes. + +Canonical detail: [component-config.md — Private startup envelopes](component-config.md#private-startup-envelopes-hard-cutover). From c73dc1abd952d2723a9b2f8c766023633188daf6 Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Thu, 23 Jul 2026 13:13:40 +0200 Subject: [PATCH 14/16] refactor: share transport envelope validation in physicalai.config Deduplicate the copy-pasted owner/publisher envelope validators and class-path normalizers into one parameterized module, and replace the lazy physicalai.config __getattr__ with plain imports. Co-authored-by: Cursor --- pyproject.toml | 2 - src/physicalai/capture/transport/_spec.py | 100 ++---------- src/physicalai/config/__init__.py | 93 +++-------- src/physicalai/config/_envelope.py | 152 ++++++++++++++++++ .../robot/transport/_owner_config.py | 113 +++---------- 5 files changed, 211 insertions(+), 249 deletions(-) create mode 100644 src/physicalai/config/_envelope.py diff --git a/pyproject.toml b/pyproject.toml index 62af33b..d2f5dde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,8 +153,6 @@ unfixable = [] "src/physicalai/capture/transport/_header.py" = ["RUF012"] "src/physicalai/capture/transport/_publisher.py" = ["S603"] "src/physicalai/robot/transport/_owner.py" = ["S603"] -# Lazy __getattr__ public API so physicalai.config.importing stays lightweight. -"src/physicalai/config/__init__.py" = ["RUF067"] [tool.ruff.lint.mccabe] max-complexity = 15 diff --git a/src/physicalai/capture/transport/_spec.py b/src/physicalai/capture/transport/_spec.py index 90d28c7..4ef0832 100644 --- a/src/physicalai/capture/transport/_spec.py +++ b/src/physicalai/capture/transport/_spec.py @@ -16,7 +16,6 @@ from __future__ import annotations -import json from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path @@ -24,12 +23,11 @@ from physicalai.config import ( ComponentConfig, - ComponentConfigError, instantiate, - resolve_public_class_path, - validate_component_config, + normalize_class_reference, + normalize_component_config, + validate_envelope, ) -from physicalai.config.importing import import_dotted_path from .builtin import builtin_type_for_class_path @@ -52,104 +50,38 @@ def validate_publisher_config(data: Mapping[str, Any]) -> Mapping[str, object]: """Validate a publisher stdin or reconfigure payload schema-positively. - Requires ``camera`` with a valid ComponentConfig shape. Allows only known - transport envelope keys. Unknown keys (including legacy flat - ``camera_type`` / ``camera_kwargs``) raise a clear schema error. - - Args: - data: Full publisher stdin dict or a reconfigure ``spec`` fragment. - Returns: The validated ``camera`` ComponentConfig mapping (not yet - public-path-normalized — :class:`CameraSpec` / ``normalize_camera_config`` - do that). - - Raises: - TypeError: If *data* is not a mapping, or ``camera`` is not a mapping. - ValueError: If required ``camera`` is missing or unknown keys are present. + public-path-normalized — :func:`normalize_camera_config` does that). """ - if not isinstance(data, Mapping): - msg = f"publisher config must be a mapping, got {type(data).__name__}" - raise TypeError(msg) - - unknown = sorted(set(data) - _PUBLISHER_ENVELOPE_KEYS) - if unknown: - msg = ( - f"unknown publisher config keys {unknown}; " - "require 'camera' with class_path + init_args " - f"(allowed envelope keys: {sorted(_PUBLISHER_ENVELOPE_KEYS)})" - ) - raise ValueError(msg) - - if "camera" not in data: - msg = "publisher config missing required 'camera' ComponentConfig" - raise ValueError(msg) - - camera = data["camera"] - if not isinstance(camera, Mapping): - msg = f"publisher 'camera' must be a mapping, got {type(camera).__name__}" - raise TypeError(msg) - - return validate_component_config(dict(camera), path="camera") + return validate_envelope( + data, + component_key="camera", + allowed_keys=_PUBLISHER_ENVELOPE_KEYS, + envelope_name="publisher", + ) def normalize_camera_class(camera_class: type | str) -> str: """Normalize a camera class reference to its public import path. - Args: - camera_class: A class object, or its dotted import path as a string. - Returns: The normalized public dotted path. - - Raises: - TypeError: If *camera_class* is neither a string nor a class, or a - string path does not resolve to a class. - ValueError: If a class object is a local class, or the public path - cannot be resolved. """ - if isinstance(camera_class, str): - try: - resolved = import_dotted_path(camera_class) - except (ValueError, ImportError, AttributeError) as exc: - msg = f"could not import camera class_path {camera_class!r}: {exc}" - raise ValueError(msg) from exc - if not isinstance(resolved, type): - msg = f"camera class_path {camera_class!r} does not resolve to a class (got {type(resolved).__name__})" - raise TypeError(msg) - camera_class = resolved - if not isinstance(camera_class, type): - msg = f"camera class_path must be a class or a dotted path string, got {type(camera_class).__name__}" - raise TypeError(msg) - - try: - return resolve_public_class_path(camera_class) - except ComponentConfigError as exc: - raise ValueError(str(exc)) from exc + return normalize_class_reference(camera_class, label="camera class_path") def normalize_camera_config(camera: Mapping[str, object]) -> ComponentConfig: """Validate a camera ComponentConfig and normalize ``class_path``. - Args: - camera: Candidate ``class_path`` + ``init_args`` mapping. - Returns: A validated config whose ``class_path`` is the public import path. - - Raises: - ValueError: If ``class_path`` cannot be imported or is not JSON-safe - together with ``init_args``. """ - validated = validate_component_config(dict(camera), path="camera") - class_path = normalize_camera_class(validated["class_path"]) - init_args = validated["init_args"] - try: - json.dumps({"class_path": class_path, "init_args": init_args}) - except TypeError as exc: - msg = f"camera.init_args must be JSON-serializable: {exc}" - raise ValueError(msg) from exc - return {"class_path": class_path, "init_args": dict(init_args)} + return normalize_component_config( + camera, + component_key="camera", + class_label="camera class_path", + ) def derive_service_name( diff --git a/src/physicalai/config/__init__.py b/src/physicalai/config/__init__.py index 67a1f22..a5a3cef 100644 --- a/src/physicalai/config/__init__.py +++ b/src/physicalai/config/__init__.py @@ -15,39 +15,28 @@ - Domain ctor args may implement :meth:`~ConfigValue.to_config_value` to return a JSON-compatible fragment (re-normalized). -Also exported for transport and other callers (no private ``physicalai.config._*`` -imports): - -- :func:`resolve_public_class_path` — decorator ``class_path=`` override or - ``__module__.__qualname__``, verified to import back to the class. -- :func:`validate_component_config` — shape-check ``class_path`` + ``init_args``. - -Studio needs only :func:`is_config_exportable` and :func:`to_config`, then -domain ``from_config`` helpers (for example ``SharedRobot.from_config``). - -Public names are resolved lazily so ``physicalai.config.importing`` can be -imported without loading export/normalize/instantiate. +Transport and other callers import public names from here (no private +``physicalai.config._*`` imports). Studio needs only +:func:`is_config_exportable` and :func:`to_config`, then domain +``from_config`` helpers (for example ``SharedRobot.from_config``). """ -from __future__ import annotations - -from importlib import import_module -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from ._errors import ComponentConfigError as ComponentConfigError - from ._errors import ComponentImportError as ComponentImportError - from ._export import export_config as export_config - from ._export import is_config_exportable as is_config_exportable - from ._export import resolve_public_class_path as resolve_public_class_path - from ._export import to_config as to_config - from ._instantiate import instantiate as instantiate - from ._normalize import validate_component_config as validate_component_config - from ._types import ComponentConfig as ComponentConfig - from ._types import ConfigValue as ConfigValue - from ._types import JsonScalar as JsonScalar - from ._types import JsonValue as JsonValue - from .importing import import_dotted_path as import_dotted_path +from ._envelope import ( + normalize_class_reference, + normalize_component_config, + validate_envelope, +) +from ._errors import ComponentConfigError, ComponentImportError +from ._export import ( + export_config, + is_config_exportable, + resolve_public_class_path, + to_config, +) +from ._instantiate import instantiate +from ._normalize import validate_component_config +from ._types import ComponentConfig, ConfigValue, JsonScalar, JsonValue +from .importing import import_dotted_path __all__ = [ "ComponentConfig", @@ -60,46 +49,10 @@ "import_dotted_path", "instantiate", "is_config_exportable", + "normalize_class_reference", + "normalize_component_config", "resolve_public_class_path", "to_config", "validate_component_config", + "validate_envelope", ] - -_LAZY_ATTRS: dict[str, tuple[str, str]] = { - "ComponentConfig": ("._types", "ComponentConfig"), - "ConfigValue": ("._types", "ConfigValue"), - "JsonScalar": ("._types", "JsonScalar"), - "JsonValue": ("._types", "JsonValue"), - "ComponentConfigError": ("._errors", "ComponentConfigError"), - "ComponentImportError": ("._errors", "ComponentImportError"), - "import_dotted_path": (".importing", "import_dotted_path"), - "export_config": ("._export", "export_config"), - "is_config_exportable": ("._export", "is_config_exportable"), - "resolve_public_class_path": ("._export", "resolve_public_class_path"), - "to_config": ("._export", "to_config"), - "instantiate": ("._instantiate", "instantiate"), - "validate_component_config": ("._normalize", "validate_component_config"), -} - - -def __getattr__(name: str) -> object: - """Lazily resolve public API attributes. - - Returns: - The requested public attribute. - - Raises: - AttributeError: If *name* is not part of the public API. - """ - try: - module_name, attr = _LAZY_ATTRS[name] - except KeyError as exc: - msg = f"module {__name__!r} has no attribute {name!r}" - raise AttributeError(msg) from exc - value = getattr(import_module(module_name, __name__), attr) - globals()[name] = value - return value - - -def __dir__() -> list[str]: - return sorted({*globals(), *__all__}) diff --git a/src/physicalai/config/_envelope.py b/src/physicalai/config/_envelope.py new file mode 100644 index 0000000..a525126 --- /dev/null +++ b/src/physicalai/config/_envelope.py @@ -0,0 +1,152 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Shared validation for transport construction envelopes. + +Robot-owner and camera-publisher stdin envelopes carry one nested +:class:`ComponentConfig` plus transport-only keys. These helpers keep the +schema-positive validation and public-path normalization in one place; +transports supply their key names and allowlists. +""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +from ._errors import ComponentConfigError +from ._export import resolve_public_class_path +from ._normalize import validate_component_config +from .importing import import_dotted_path + +if TYPE_CHECKING: + from ._types import ComponentConfig + + +def validate_envelope( + data: Mapping[str, Any], + *, + component_key: str, + allowed_keys: frozenset[str], + envelope_name: str, +) -> Mapping[str, object]: + """Validate a transport stdin envelope schema-positively. + + Requires *component_key* with a valid ComponentConfig shape and allows + only *allowed_keys*. Unknown keys (including legacy flat shapes) raise a + clear schema error before any import or hardware access. + + Args: + data: Full stdin envelope dict. + component_key: Envelope key holding the nested ComponentConfig + (for example ``"robot"`` or ``"camera"``). + allowed_keys: Complete allowlist of envelope keys. + envelope_name: Short envelope label for error messages + (for example ``"owner"`` or ``"publisher"``). + + Returns: + The validated ComponentConfig mapping (not yet public-path-normalized + — :func:`normalize_component_config` does that). + + Raises: + TypeError: If *data* or the component value is not a mapping. + ValueError: If the component key is missing or unknown keys are present. + """ + if not isinstance(data, Mapping): + msg = f"{envelope_name} config must be a mapping, got {type(data).__name__}" + raise TypeError(msg) + + unknown = sorted(set(data) - allowed_keys) + if unknown: + msg = ( + f"unknown {envelope_name} config keys {unknown}; " + f"require {component_key!r} with class_path + init_args " + f"(allowed envelope keys: {sorted(allowed_keys)})" + ) + raise ValueError(msg) + + if component_key not in data: + msg = f"{envelope_name} config missing required {component_key!r} ComponentConfig" + raise ValueError(msg) + + component = data[component_key] + if not isinstance(component, Mapping): + msg = f"{envelope_name} {component_key!r} must be a mapping, got {type(component).__name__}" + raise TypeError(msg) + + return validate_component_config(dict(component), path=component_key) + + +def normalize_class_reference(ref: type | str, *, label: str) -> str: + """Normalize a class object or dotted path to its verified public path. + + Matches :func:`to_config` path selection: decorator ``class_path=`` + override when present, otherwise ``__module__.__qualname__``. String + paths are imported first so a defining-module path becomes the public + re-export before store, metadata advertise, and conflict compare. + + Args: + ref: A class object, or its dotted import path as a string. + label: Argument label for error messages (for example ``"robot_class"``). + + Returns: + The normalized public dotted path. + + Raises: + TypeError: If *ref* is neither a string nor a class, or a string path + does not resolve to a class. + ValueError: If the path cannot be imported, or the public path cannot + be resolved (for example a local class). + """ + if isinstance(ref, str): + try: + resolved = import_dotted_path(ref) + except (ValueError, ImportError, AttributeError) as exc: + msg = f"could not import {label} {ref!r}: {exc}" + raise ValueError(msg) from exc + if not isinstance(resolved, type): + msg = f"{label} {ref!r} does not resolve to a class (got {type(resolved).__name__})" + raise TypeError(msg) + ref = resolved + if not isinstance(ref, type): + msg = f"{label} must be a class or a dotted path string, got {type(ref).__name__}" + raise TypeError(msg) + + try: + return resolve_public_class_path(ref) + except ComponentConfigError as exc: + raise ValueError(str(exc)) from exc + + +def normalize_component_config( + config: Mapping[str, object], + *, + component_key: str, + class_label: str, + json_hint: str = "", +) -> ComponentConfig: + """Validate a ComponentConfig and normalize its ``class_path`` to the public path. + + Args: + config: Candidate ``class_path`` + ``init_args`` mapping. + component_key: Path prefix for validation errors (``"robot"`` / ``"camera"``). + class_label: Argument label for class-reference errors. + json_hint: Optional suffix appended to the JSON-serializability error. + + Returns: + A validated config whose ``class_path`` is the public import path. + + Raises: + ValueError: If ``class_path`` cannot be imported or ``init_args`` is + not JSON-serializable. + """ + validated = validate_component_config(dict(config), path=component_key) + class_path = normalize_class_reference(validated["class_path"], label=class_label) + init_args = validated["init_args"] + try: + json.dumps({"class_path": class_path, "init_args": init_args}) + except TypeError as exc: + msg = f"{component_key}.init_args must be JSON-serializable{json_hint}: {exc}" + raise ValueError(msg) from exc + return {"class_path": class_path, "init_args": dict(init_args)} diff --git a/src/physicalai/robot/transport/_owner_config.py b/src/physicalai/robot/transport/_owner_config.py index aa247b0..0348de2 100644 --- a/src/physicalai/robot/transport/_owner_config.py +++ b/src/physicalai/robot/transport/_owner_config.py @@ -29,22 +29,21 @@ from __future__ import annotations -import json import math -from collections.abc import Mapping from dataclasses import dataclass from typing import TYPE_CHECKING, Any from physicalai.config import ( ComponentConfig, - ComponentConfigError, instantiate, - resolve_public_class_path, - validate_component_config, + normalize_class_reference, + normalize_component_config, + validate_envelope, ) -from physicalai.config.importing import import_dotted_path if TYPE_CHECKING: + from collections.abc import Mapping + from physicalai.robot.interface import Robot DEFAULT_RATE_HZ = 100.0 @@ -72,111 +71,39 @@ def validate_owner_config(data: Mapping[str, Any]) -> Mapping[str, object]: """Validate an owner stdin payload schema-positively. - Requires ``robot`` with a valid ComponentConfig shape. Allows only known - transport envelope keys. Unknown keys (including legacy flat - ``robot_class`` / ``robot_kwargs``) raise a clear schema error. - - Args: - data: Full owner stdin dict. - Returns: The validated ``robot`` ComponentConfig mapping (not yet - public-path-normalized — :class:`RobotOwnerConfig` / - :func:`normalize_robot_config` do that). - - Raises: - TypeError: If *data* is not a mapping, or ``robot`` is not a mapping. - ValueError: If required ``robot`` is missing or unknown keys are present. + public-path-normalized — :func:`normalize_robot_config` does that). """ - if not isinstance(data, Mapping): - msg = f"owner config must be a mapping, got {type(data).__name__}" - raise TypeError(msg) - - unknown = sorted(set(data) - _OWNER_ENVELOPE_KEYS) - if unknown: - msg = ( - f"unknown owner config keys {unknown}; " - "require 'robot' with class_path + init_args " - f"(allowed envelope keys: {sorted(_OWNER_ENVELOPE_KEYS)})" - ) - raise ValueError(msg) - - if "robot" not in data: - msg = "owner config missing required 'robot' ComponentConfig" - raise ValueError(msg) - - robot = data["robot"] - if not isinstance(robot, Mapping): - msg = f"owner 'robot' must be a mapping, got {type(robot).__name__}" - raise TypeError(msg) - - return validate_component_config(dict(robot), path="robot") + return validate_envelope( + data, + component_key="robot", + allowed_keys=_OWNER_ENVELOPE_KEYS, + envelope_name="owner", + ) def normalize_robot_class(robot_class: type | str) -> str: """Normalize a robot class reference to its public import path. - Matches :func:`physicalai.config.to_config` path selection: decorator - ``class_path=`` override when present, otherwise - ``__module__.__qualname__``. String paths are imported first so a - defining-module path (for example ``physicalai.robot.so101.so101.SO101``) - becomes the public re-export (``physicalai.robot.SO101``) before store, - metadata advertise, and conflict compare. - - Args: - robot_class: A class object, or its dotted import path as a string. - Returns: The normalized public dotted path. - - Raises: - TypeError: If *robot_class* is neither a string nor a class, or a - string path does not resolve to a class. - ValueError: If a class object is a local class, or the public path - cannot be resolved. """ - if isinstance(robot_class, str): - try: - resolved = import_dotted_path(robot_class) - except (ValueError, ImportError, AttributeError) as exc: - msg = f"could not import robot_class {robot_class!r}: {exc}" - raise ValueError(msg) from exc - if not isinstance(resolved, type): - msg = f"robot_class {robot_class!r} does not resolve to a class (got {type(resolved).__name__})" - raise TypeError(msg) - robot_class = resolved - if not isinstance(robot_class, type): - msg = f"robot_class must be a class or a dotted path string, got {type(robot_class).__name__}" - raise TypeError(msg) - - try: - return resolve_public_class_path(robot_class) - except ComponentConfigError as exc: - raise ValueError(str(exc)) from exc + return normalize_class_reference(robot_class, label="robot_class") def normalize_robot_config(robot: Mapping[str, object]) -> ComponentConfig: - """Validate a robot :class:`~physicalai.config.ComponentConfig` and normalize ``class_path``. - - Args: - robot: Candidate ``class_path`` + ``init_args`` mapping. + """Validate a robot ComponentConfig and normalize ``class_path``. Returns: A validated config whose ``class_path`` is the public import path. - - Raises: - ValueError: If ``class_path`` cannot be imported or is not JSON-safe - together with ``init_args``. """ - validated = validate_component_config(dict(robot), path="robot") - class_path = normalize_robot_class(validated["class_path"]) - init_args = validated["init_args"] - try: - json.dumps({"class_path": class_path, "init_args": init_args}) - except TypeError as exc: - msg = f"robot.init_args must be JSON-serializable (e.g. paths as str, not objects): {exc}" - raise ValueError(msg) from exc - return {"class_path": class_path, "init_args": dict(init_args)} + return normalize_component_config( + robot, + component_key="robot", + class_label="robot_class", + json_hint=" (e.g. paths as str, not objects)", + ) @dataclass(frozen=True) From 96df5fee3197fd6569fa3e6a17672c183e0c175e Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Thu, 23 Jul 2026 13:16:41 +0200 Subject: [PATCH 15/16] refactor: remove dead code and collapse duplication in config/transport Drop the unused _resolve_public_class_path alias and no-op keep_by_reference parameter, collapse the _session kwargs duplication in SharedRobot, and trim design-note prose restated on private helpers. Co-authored-by: Cursor --- .../capture/transport/_shared_camera.py | 10 +---- src/physicalai/config/_export.py | 33 +++++---------- src/physicalai/config/_normalize.py | 21 ++++------ .../robot/transport/_shared_robot.py | 41 ++++--------------- 4 files changed, 25 insertions(+), 80 deletions(-) diff --git a/src/physicalai/capture/transport/_shared_camera.py b/src/physicalai/capture/transport/_shared_camera.py index db6a8f4..6179a46 100644 --- a/src/physicalai/capture/transport/_shared_camera.py +++ b/src/physicalai/capture/transport/_shared_camera.py @@ -40,15 +40,7 @@ def _coerce_camera_recipe(camera: object) -> ComponentConfig | Mapping[str, object]: - """Accept a ComponentConfig mapping or a live exportable camera recipe. - - ``instantiate()`` recursively builds nested ``class_path`` configs into live - cameras before calling this constructor; spawn still needs a JSON recipe for - the publisher subprocess. Live cameras are converted with :func:`to_config`. - Connected cameras are rejected — same rule as :meth:`SharedCamera.from_camera`. - - Args: - camera: ComponentConfig mapping or disconnected ``@export_config`` camera. + """Accept a ComponentConfig mapping or a disconnected ``@export_config`` camera. Returns: A mapping suitable for :func:`normalize_camera_config`. diff --git a/src/physicalai/config/_export.py b/src/physicalai/config/_export.py index 5cec257..b554c25 100644 --- a/src/physicalai/config/_export.py +++ b/src/physicalai/config/_export.py @@ -64,15 +64,10 @@ def _has_export_marker(obj: object) -> bool: def _encode_domain_value(value: object) -> tuple[JsonValue] | None: """Encode a domain value via ``to_config_value()`` if present. - The returned payload is further normalized by :func:`normalize_value` - (non-finite floats, reserved ``class_path`` maps, depth/cycles, nested - codecs). Absence of a callable ``to_config_value`` means *no codec*. - Returning JSON ``null`` (``None``) from the method is a real payload — - wrap it in a 1-tuple here so it is distinct from “no codec”. - Returns: - ``(payload,)`` when a codec applies (``payload`` may be ``None``), or - ``None`` when the value has no domain encoder. + ``(payload,)`` when a codec applies — the 1-tuple keeps a real JSON + ``null`` payload distinct from "no codec" — or ``None`` when the value + has no domain encoder. The payload is re-normalized by the caller. """ encode = getattr(value, "to_config_value", None) if not callable(encode): @@ -91,11 +86,11 @@ def is_config_exportable(value: object) -> bool: def _class_path_override(cls: type) -> str | None: - """Return ``class_path=`` only when *cls* owns the decorated ``__init__``. + """Return the decorator ``class_path=`` only when *cls* owns the decorated ``__init__``. - Subclasses that inherit a decorated constructor unchanged must not inherit - the base's public-path override; they export ``__module__.__qualname__`` - unless they re-decorate with their own ``class_path=``. + Returns: + The override path, or ``None`` for inherited constructors (subclasses + export ``__module__.__qualname__`` unless they re-decorate). """ owned_init = cls.__dict__.get("__init__") if owned_init is None: @@ -140,10 +135,6 @@ def resolve_public_class_path(cls: type) -> str: return path -# Private alias kept for call sites that predate the public name. -_resolve_public_class_path = resolve_public_class_path - - def _component_path_prefix(value: object) -> str: cls = type(value) return _class_path_override(cls) or f"{cls.__module__}.{cls.__qualname__}" @@ -199,7 +190,7 @@ def to_config( ) raise ComponentConfigError(msg) - class_path = _resolve_public_class_path(type(value)) + class_path = resolve_public_class_path(type(value)) init_args: dict[str, JsonValue] = {} for key, item in captured.items(): init_args[key] = normalize_value( @@ -262,7 +253,7 @@ def _flatten_var_kwargs( # Seal so normalize fails at to_config (no silent JSON nest). supplied[key] = _NonScalarVarKwarg(key) else: - supplied[key] = snapshot_captured_value(value, keep_by_reference=is_config_exportable) + supplied[key] = snapshot_captured_value(value) def _decorate_export_config( @@ -312,11 +303,7 @@ def wrapped_init(self: object, *args: object, **kwargs: object) -> None: bound = signature.bind(self, *args, **kwargs) # Do not apply_defaults — omit unsupplied arguments so reconstruction # uses current constructor defaults. - supplied = { - name: snapshot_captured_value(value, keep_by_reference=is_config_exportable) - for name, value in bound.arguments.items() - if name != "self" - } + supplied = {name: snapshot_captured_value(value) for name, value in bound.arguments.items() if name != "self"} if var_kw_name is not None and var_kw_name in supplied: _flatten_var_kwargs( supplied, diff --git a/src/physicalai/config/_normalize.py b/src/physicalai/config/_normalize.py index 95da902..f4ee4a0 100644 --- a/src/physicalai/config/_normalize.py +++ b/src/physicalai/config/_normalize.py @@ -357,14 +357,13 @@ def normalize_value( def snapshot_captured_value( value: object, *, - keep_by_reference: Callable[[object], bool] | None = None, memo: dict[int, object] | None = None, ) -> object: - """Deep-snapshot built-in mutable containers; keep selected values by reference. + """Deep-snapshot built-in mutable containers; keep other values by reference. - Nested exportable components (and other values matching - *keep_by_reference*) are retained by identity so their own conversion - remains authoritative. Cyclic containers are preserved via *memo* so + Non-container values — including nested exportable components and domain + values — are retained by identity so their own conversion remains + authoritative. Cyclic containers are preserved via *memo* so :func:`~physicalai.config.to_config` can reject them during normalization. Returns: @@ -381,23 +380,17 @@ def snapshot_captured_value( result: dict[object, object] = {} memo[obj_id] = result for key, item in value.items(): - result[key] = snapshot_captured_value(item, keep_by_reference=keep_by_reference, memo=memo) + result[key] = snapshot_captured_value(item, memo=memo) snap: object = result elif isinstance(value, list): result_list: list[object] = [] memo[obj_id] = result_list - result_list.extend( - snapshot_captured_value(item, keep_by_reference=keep_by_reference, memo=memo) for item in value - ) + result_list.extend(snapshot_captured_value(item, memo=memo) for item in value) snap = result_list else: memo[obj_id] = value - snap = tuple( - snapshot_captured_value(item, keep_by_reference=keep_by_reference, memo=memo) for item in value - ) + snap = tuple(snapshot_captured_value(item, memo=memo) for item in value) memo[obj_id] = snap return snap - if keep_by_reference is not None and keep_by_reference(value): - return value return value diff --git a/src/physicalai/robot/transport/_shared_robot.py b/src/physicalai/robot/transport/_shared_robot.py index ff849c6..8c522f7 100644 --- a/src/physicalai/robot/transport/_shared_robot.py +++ b/src/physicalai/robot/transport/_shared_robot.py @@ -57,15 +57,7 @@ def _coerce_robot_recipe(robot: object) -> ComponentConfig | Mapping[str, object]: - """Accept a ComponentConfig mapping or a live exportable driver recipe. - - ``instantiate()`` recursively builds nested ``class_path`` configs into live - drivers before calling this constructor; spawn still needs a JSON recipe for - the owner subprocess. Live drivers are converted with :func:`to_config`. - Connected drivers are rejected — same rule as :meth:`SharedRobot.from_robot`. - - Args: - robot: ComponentConfig mapping or disconnected ``@export_config`` driver. + """Accept a ComponentConfig mapping or a disconnected ``@export_config`` driver. Returns: A mapping suitable for :func:`normalize_robot_config`. @@ -310,15 +302,6 @@ def from_config( writes only the new owner stdin shape on spawn. """ # Omit default None so @export_config does not capture "_session": null. - if _session is None: - return cls( - name, - robot=robot_config, - allow_remote=allow_remote, - rate_hz=rate_hz, - idle_timeout=idle_timeout, - connect_timeout=connect_timeout, - ) return cls( name, robot=robot_config, @@ -326,7 +309,7 @@ def from_config( rate_hz=rate_hz, idle_timeout=idle_timeout, connect_timeout=connect_timeout, - _session=_session, + **({} if _session is None else {"_session": _session}), ) @classmethod @@ -378,24 +361,14 @@ def from_robot( ) raise ValueError(msg) # Omit default None so @export_config does not capture "_session": null. - recipe = to_config(robot) - if _session is None: - return cls.from_config( - recipe, - name=name, - allow_remote=allow_remote, - rate_hz=rate_hz, - idle_timeout=idle_timeout, - connect_timeout=connect_timeout, - ) return cls.from_config( - recipe, + to_config(robot), name=name, allow_remote=allow_remote, rate_hz=rate_hz, idle_timeout=idle_timeout, connect_timeout=connect_timeout, - _session=_session, + **({} if _session is None else {"_session": _session}), ) @classmethod @@ -423,9 +396,9 @@ def attach( A ``SharedRobot`` that never spawns an owner. """ # Omit default None so @export_config does not capture "_session": null. - if _session is None: - return cls(name, allow_remote=allow_remote, connect_timeout=connect_timeout) - return cls(name, allow_remote=allow_remote, connect_timeout=connect_timeout, _session=_session) + if _session is not None: + return cls(name, allow_remote=allow_remote, connect_timeout=connect_timeout, _session=_session) + return cls(name, allow_remote=allow_remote, connect_timeout=connect_timeout) @property def _robot_class(self) -> str | None: From 589a8b616f3705dba5827e7e337a5be6089d283b Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Thu, 23 Jul 2026 13:19:22 +0200 Subject: [PATCH 16/16] fix: fail loudly when a shared component lacks is_connected A live robot/camera recipe missing its protocol's is_connected surface now raises TypeError instead of silently passing the disconnected check; also type the robot CLI test namespaces so pyrefly stops flagging SimpleNamespace. Co-authored-by: Cursor --- docs/development/component-config.md | 5 +++++ .../capture/transport/_shared_camera.py | 9 +++++++- .../robot/transport/_shared_robot.py | 9 +++++++- tests/unit/cli/test_robot_cli.py | 21 +++++++++++++------ 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/docs/development/component-config.md b/docs/development/component-config.md index 25e4d2b..b1767e2 100644 --- a/docs/development/component-config.md +++ b/docs/development/component-config.md @@ -623,6 +623,11 @@ builders must return disconnected drivers for wrapping, or Studio must explicitly release a driver it owns before calling `from_robot()`. Prefer `from_config()` when no live instance is otherwise needed. +The connected-state check follows each protocol's surface — `is_connected()` +is a method on robots and a property on cameras — and a live component that +does not expose it fails with `TypeError` instead of passing the check +silently. + ### Private startup envelopes (hard cutover) Changing `RobotOwnerConfig` from `robot_class` + `robot_kwargs` to diff --git a/src/physicalai/capture/transport/_shared_camera.py b/src/physicalai/capture/transport/_shared_camera.py index 6179a46..8aa9d6b 100644 --- a/src/physicalai/capture/transport/_shared_camera.py +++ b/src/physicalai/capture/transport/_shared_camera.py @@ -60,7 +60,14 @@ def _coerce_camera_recipe(camera: object) -> ComponentConfig | Mapping[str, obje "camera={{class_path, init_args}} or an @export_config camera" ) raise TypeError(msg) - if bool(getattr(camera, "is_connected", False)): + if not hasattr(camera, "is_connected"): + msg = ( + f"{type(camera).__module__}.{type(camera).__qualname__} does not expose " + "the Camera protocol's is_connected property; cannot verify it is " + "disconnected before sharing" + ) + raise TypeError(msg) + if bool(camera.is_connected): msg = ( "SharedCamera requires a disconnected camera recipe; " "disconnect explicitly before passing a live camera, or pass " diff --git a/src/physicalai/robot/transport/_shared_robot.py b/src/physicalai/robot/transport/_shared_robot.py index 8c522f7..cff051a 100644 --- a/src/physicalai/robot/transport/_shared_robot.py +++ b/src/physicalai/robot/transport/_shared_robot.py @@ -78,7 +78,14 @@ def _coerce_robot_recipe(robot: object) -> ComponentConfig | Mapping[str, object ) raise TypeError(msg) is_connected = getattr(robot, "is_connected", None) - if callable(is_connected) and is_connected(): + if not callable(is_connected): + msg = ( + f"{type(robot).__module__}.{type(robot).__qualname__} does not expose " + "the Robot protocol's is_connected() method; cannot verify it is " + "disconnected before sharing" + ) + raise TypeError(msg) + if is_connected(): msg = ( "SharedRobot requires a disconnected driver recipe; " "disconnect explicitly before passing a live robot, or pass " diff --git a/tests/unit/cli/test_robot_cli.py b/tests/unit/cli/test_robot_cli.py index 31e747a..cec5a7c 100644 --- a/tests/unit/cli/test_robot_cli.py +++ b/tests/unit/cli/test_robot_cli.py @@ -13,6 +13,7 @@ import uuid from pathlib import Path from types import SimpleNamespace +from typing import TYPE_CHECKING, cast from unittest.mock import patch from loguru import logger @@ -24,8 +25,16 @@ from tests.unit.robot.transport.conftest import requires_zenoh +if TYPE_CHECKING: + from jsonargparse import Namespace -def _serve_cfg(**overrides: object) -> SimpleNamespace: + +def _ns(**values: object) -> Namespace: + # Duck-typed stand-in for the jsonargparse Namespace the CLI handlers accept. + return cast("Namespace", SimpleNamespace(**values)) + + +def _serve_cfg(**overrides: object) -> Namespace: values: dict[str, object] = { "name": "left-arm", "robot": { @@ -37,7 +46,7 @@ def _serve_cfg(**overrides: object) -> SimpleNamespace: "verbose": False, } values.update(overrides) - return SimpleNamespace(**values) + return _ns(**values) def test_serve_runs_owner_in_foreground_with_persistent_timeout(capsys: object) -> None: @@ -135,7 +144,7 @@ def test_discovery_json_is_sorted_and_clean(capsys: object) -> None: {"name": "z-arm", "host": "b", "robot_class": "untrusted.Z", "num_joints": 7}, {"name": "a-arm", "host": "a", "robot_class": "untrusted.A", "num_joints": 6}, ] - cfg = SimpleNamespace(timeout=1.0, allow_remote=False, json=True) + cfg = _ns(timeout=1.0, allow_remote=False, json=True) with patch.object(robot_module, "discover_robots", return_value=records): assert robot_module.discover(cfg) == 0 @@ -149,7 +158,7 @@ def test_discovery_human_output_is_table(capsys: object) -> None: {"name": "z-arm", "host": "b", "robot_class": "untrusted.Z", "num_joints": 7}, {"name": "a-arm", "host": "a", "robot_class": "untrusted.A", "num_joints": 6}, ] - cfg = SimpleNamespace(timeout=1.0, allow_remote=False, json=False) + cfg = _ns(timeout=1.0, allow_remote=False, json=False) with patch.object(robot_module, "discover_robots", return_value=records): assert robot_module.discover(cfg) == 0 @@ -207,7 +216,7 @@ def _run_owner( # noqa: ARG001 def test_empty_discovery_json_is_array(capsys: object) -> None: - cfg = SimpleNamespace(timeout=1.0, allow_remote=False, json=True) + cfg = _ns(timeout=1.0, allow_remote=False, json=True) with patch.object(robot_module, "discover_robots", return_value=[]): assert robot_module.discover(cfg) == 0 assert capsys.readouterr().out == "[]\n" # type: ignore[attr-defined] @@ -249,7 +258,7 @@ def test_parser_accepts_robot_component_config() -> None: def test_discover_rejects_invalid_timeout(capsys: object) -> None: - cfg = SimpleNamespace(timeout=float("nan"), allow_remote=False, json=False) + cfg = _ns(timeout=float("nan"), allow_remote=False, json=False) with patch.object(robot_module, "discover_robots") as discover: assert robot_module.discover(cfg) == 1 discover.assert_not_called()