From a95979cbed997321f276532666957d92acd7319d Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Wed, 22 Jul 2026 13:36:58 +0200 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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() From ececc2ea47cfa61ab2d2710f34c655475cd5472a Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Fri, 24 Jul 2026 15:04:09 +0200 Subject: [PATCH 17/21] refactor(component-config): simplify transport and fix config round-trip Simplify the component-config transport integration and close config round-trip gaps found while making the branch review-ready. - transport: remove SharedRobot.from_robot / SharedCamera.from_camera sugar; the constructor coercion path already covers live-instance recipes. Convert the sugar tests to constructor-path tests so the connected / config-exportable / disconnected validation stays covered. - cli: accept both a runtime:-rooted document and a bare exported RobotRuntime ComponentConfig in `physicalai run --config`, via a small ActionConfigFile subclass that unwraps a top-level class_path on load. This makes to_config(runtime) + save_yaml round-trip with no separate export API; a foreign class_path is rejected via parser.error. - capture: rename transport CameraSpec to CameraPublisherConfig to avoid colliding with inference.manifest.CameraSpec. - robot(so101): make allow_uncalibrated public and default calibration to None so an uncalibrated export round-trips through jsonargparse; the runtime guard still requires explicit opt-in. - docs: update the component-config spec (CLI both-ways, save_yaml round-trip, coercion contract, uncalibrated note); drop the from_robot / from_camera / CameraSpec mandates and the scratch issues note. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/development/component-config.md | 105 ++++++++++-------- src/physicalai/capture/transport/__init__.py | 3 +- .../capture/transport/_publisher.py | 4 +- .../capture/transport/_publisher_worker.py | 8 +- .../capture/transport/_shared_camera.py | 81 ++------------ src/physicalai/capture/transport/_spec.py | 6 +- src/physicalai/cli/run.py | 64 ++++++++++- src/physicalai/config/__init__.py | 4 + src/physicalai/config/_yaml.py | 79 +++++++++++++ src/physicalai/robot/so101/so101.py | 13 ++- .../robot/transport/_shared_robot.py | 74 ++---------- src/physicalai/runtime/core.py | 49 +++++++- tests/unit/capture/conftest.py | 10 +- .../capture/test_camera_component_config.py | 6 +- tests/unit/capture/test_transport.py | 50 ++++----- tests/unit/cli/test_cli.py | 35 ++++++ tests/unit/config/test_yaml.py | 86 ++++++++++++++ .../unit/robot/test_robot_component_config.py | 25 +++-- .../unit/robot/transport/test_shared_robot.py | 16 +-- .../runtime/test_runtime_component_config.py | 36 ++++++ 20 files changed, 502 insertions(+), 252 deletions(-) create mode 100644 src/physicalai/config/_yaml.py create mode 100644 tests/unit/config/test_yaml.py diff --git a/docs/development/component-config.md b/docs/development/component-config.md index b1767e2..187dc39 100644 --- a/docs/development/component-config.md +++ b/docs/development/component-config.md @@ -62,8 +62,9 @@ construction with runtime behavior. 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:`. + without translation. A root config may sit under a CLI wrapper key such as + `runtime:`, or be a bare `RobotRuntime` export that `physicalai run --config` + unwraps into `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. @@ -473,9 +474,14 @@ init_args: 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()`. +This example is a bare `RobotRuntime` export. `physicalai run --config` accepts +it directly — it unwraps the top-level `RobotRuntime` `class_path` into the +parser's `runtime:` section — as well as a `runtime:`-rooted CLI document. +Trusted local code may pass the bare fragment to `instantiate()` or +`RobotRuntime.from_config()`. Nested `class_path` + `init_args` fragments are +native jsonargparse and need no translation. So `to_config(runtime)` → +`save_yaml(...)` → `physicalai run --config` round-trips with one canonical +shape; no separate export API (`save_config` / `to_run_document`) is needed. ## Component-specific decisions @@ -488,11 +494,14 @@ 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 -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. +argument, capture it. `SO101.uncalibrated()` calls the constructor with +`allow_uncalibrated=True` — a **public** keyword-only parameter (default +`False`) that `@export_config` captures. `calibration` defaults to `None`, so +the uncalibrated recipe (`calibration: null`, `allow_uncalibrated: true`) loads +through jsonargparse and `physicalai run --config` (a required, unset +`calibration` would reject `null`). The constructor still raises unless +`allow_uncalibrated=True` accompanies a `None` calibration, so uncalibrated +bringup stays explicit and cannot be reached by accident. Bimanual robots need no special serialization once each arm uses `@export_config`: `left` and `right` are nested `@export_config` components @@ -611,17 +620,25 @@ for `RobotRuntime` capture in v1. Keep construction and transport ownership separate: ```python -SharedRobot.from_robot(robot, name="follower") -SharedRobot.from_config(to_config(robot), name="follower") +SharedRobot(name="follower", robot=driver) # live disconnected driver +SharedRobot.from_config(to_config(robot), name="follower") # trusted recipe (Studio API) ``` -`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. +The `SharedRobot` constructor (`robot=`) and `SharedRobot.from_config()` are the +two entry points; there is no `from_robot()` sugar. `from_config()` is the +named, Studio-facing API for a trusted `ComponentConfig`. The constructor +additionally accepts a live, **disconnected** `@export_config` driver and +converts it via `to_config()`. It requires `is_config_exportable(robot)`, +rejects a connected driver via `robot.is_connected()`, and never disconnects a +caller-owned live driver implicitly. Studio builders must return disconnected +drivers for wrapping, or explicitly release a driver they own first. + +**Coercion contract.** The live-driver path exists because `instantiate()` is +domain-agnostic: for a nested `robot={class_path, init_args}` it builds the +inner driver into a **live** instance first, then calls +`SharedRobot(robot=)`, which re-serializes it with `to_config()`. +This build-then-unbuild round-trip is intentional and harmless — construction +never opens the hardware (construction ≠ `connect()`), so no device is touched. 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 @@ -714,11 +731,10 @@ 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. + Prefer `SharedRobot.from_config(...)` when building from a trusted config. 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`). + connected drivers. - **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 @@ -749,19 +765,15 @@ 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`. -- **`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:** 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. Nested - `instantiate()` may pass a live `@export_config` camera for - `camera=`; convert to ComponentConfig and reject connected cameras - (same rule as `from_camera`). + `instantiate()` may pass a live **disconnected** `@export_config` camera for + `camera=`; convert to ComponentConfig and reject connected cameras. There is + no `from_camera()` sugar — the constructor's coercion covers the + live-instance case (same build-then-unbuild contract as `SharedRobot`). - **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 @@ -828,8 +840,9 @@ The component-config layer is responsible for: - 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:`. + jsonargparse without translation. A document root may use a CLI wrapper key + such as `runtime:`, or be a bare `RobotRuntime` export that + `physicalai run --config` unwraps into `runtime:`. Do not depend on jsonargparse internals to remember live-object construction. The explicit construction contract is also needed in subprocesses and Studio, @@ -910,10 +923,10 @@ follow-up) remain open. 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 +4. Add `SharedRobot.from_config()`; hard-cutover `RobotOwnerConfig` stdin to `robot: ComponentConfig` (rewrite writers, readers, fixtures; no `config_format`, no dual-read). Public ctor and - CLI accept only `robot=` / `--robot` (plus `from_config` / `from_robot`); + CLI accept only `robot=` / `--robot` (plus `from_config`); 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 @@ -921,9 +934,9 @@ follow-up) remain open. (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()`; public ctor accepts only `camera=` / `from_config` / - `from_camera` (flat `camera_type` / `camera_kwargs` unsupported on public + rewrite fixtures; no dual-read). Add `SharedCamera.from_config()`; + public ctor accepts only `camera=` / `from_config` + (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 @@ -996,21 +1009,23 @@ cutovers; do not describe them as schema-preserving. 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 and publisher stdin reject - flat `camera_type` / `camera_kwargs` as unsupported (not a dual API). +- The `SharedCamera` constructor coerces a live **disconnected** `@export_config` + camera via `to_config(...)`, requires exportability, and rejects connected + cameras before publisher spawn (no `from_camera()` sugar); 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 - `robot=` / `--robot` ComponentConfig (plus `from_config` / `from_robot`); + `robot=` / `--robot` ComponentConfig (plus `from_config`); 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. +- The `SharedRobot` constructor coerces a live **disconnected** `@export_config` + driver via `to_config(...)`, requires exportability, and rejects connected + drivers before owner spawn (no `from_robot()` sugar). - Composite bimanual robots spawn as one owner; nested arm configs round-trip under the composite. - The exact PolicySource nested graph (including smoothers) round-trips; @@ -1052,6 +1067,6 @@ cutovers; do not describe them as schema-preserving. - 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` +- Camera publisher envelope: `physicalai.capture.transport._spec.CameraPublisherConfig` - Review context: Mark's comments on Studio PR #818 (factory wrap; plugins should not care about `SharedRobot`) diff --git a/src/physicalai/capture/transport/__init__.py b/src/physicalai/capture/transport/__init__.py index 3549e0f..503c5f3 100644 --- a/src/physicalai/capture/transport/__init__.py +++ b/src/physicalai/capture/transport/__init__.py @@ -5,8 +5,7 @@ Provides :class:`SharedCamera` as the public entry point for 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 +(or YAML), 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. diff --git a/src/physicalai/capture/transport/_publisher.py b/src/physicalai/capture/transport/_publisher.py index 3f7aa38..d964245 100644 --- a/src/physicalai/capture/transport/_publisher.py +++ b/src/physicalai/capture/transport/_publisher.py @@ -14,7 +14,7 @@ from physicalai.capture.errors import CaptureError if TYPE_CHECKING: - from physicalai.capture.transport._spec import CameraSpec + from physicalai.capture.transport._spec import CameraPublisherConfig class CameraPublisher: @@ -33,7 +33,7 @@ class CameraPublisher: def __init__( self, - spec: CameraSpec, + spec: CameraPublisherConfig, service_name: str, *, idle_timeout: float = 5, diff --git a/src/physicalai/capture/transport/_publisher_worker.py b/src/physicalai/capture/transport/_publisher_worker.py index 2452c48..9f2e20a 100644 --- a/src/physicalai/capture/transport/_publisher_worker.py +++ b/src/physicalai/capture/transport/_publisher_worker.py @@ -140,10 +140,10 @@ def build_camera(config: dict) -> Camera: """ factory_override = config.get("_factory_override") if factory_override: - from physicalai.capture.transport._spec import CameraSpec # noqa: PLC0415 + from physicalai.capture.transport._spec import CameraPublisherConfig # noqa: PLC0415 # Validate / reject flat keys even on the factory-override path. - spec = CameraSpec.from_json_dict(config) + spec = CameraPublisherConfig.from_json_dict(config) module_path, _, attr = factory_override.rpartition(":") mod = importlib.import_module(module_path) factory = getattr(mod, attr) @@ -152,9 +152,9 @@ def build_camera(config: dict) -> Camera: init_args = {} return factory(**init_args) - from physicalai.capture.transport._spec import CameraSpec # noqa: PLC0415, PLC2701 + from physicalai.capture.transport._spec import CameraPublisherConfig # noqa: PLC0415, PLC2701 - spec = CameraSpec.from_json_dict(config) + spec = CameraPublisherConfig.from_json_dict(config) return spec.build() diff --git a/src/physicalai/capture/transport/_shared_camera.py b/src/physicalai/capture/transport/_shared_camera.py index 8aa9d6b..7fb8a9c 100644 --- a/src/physicalai/capture/transport/_shared_camera.py +++ b/src/physicalai/capture/transport/_shared_camera.py @@ -4,9 +4,9 @@ """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 +``camera=`` or :meth:`SharedCamera.from_config`; a disconnected +``@export_config`` camera instance is also accepted and converted via +:func:`~physicalai.config.to_config`. 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. @@ -130,9 +130,9 @@ 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 + Prefer :meth:`from_config` when sharing; never keep a direct camera open + while sharing. The constructor takes ``camera: ComponentConfig`` (or a + disconnected ``@export_config`` camera) to spawn, or ``camera=None`` + ``service_name`` for attach-only (:meth:`from_publisher` is the explicit form). @@ -250,71 +250,6 @@ def from_config( 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, @@ -358,9 +293,9 @@ def connect(self, timeout: float = 5.0) -> None: 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 + from ._spec import CameraPublisherConfig # noqa: PLC0415 - spec = CameraSpec(camera=self._camera) + spec = CameraPublisherConfig(camera=self._camera) publisher = CameraPublisher(spec, self._service_name, idle_timeout=self._idle_timeout) try: publisher.start() diff --git a/src/physicalai/capture/transport/_spec.py b/src/physicalai/capture/transport/_spec.py index 4ef0832..a788f3c 100644 --- a/src/physicalai/capture/transport/_spec.py +++ b/src/physicalai/capture/transport/_spec.py @@ -131,7 +131,7 @@ class paths (including stub types without a shared registry entry) require @dataclass(frozen=True) -class CameraSpec: +class CameraPublisherConfig: """Config payload describing how to construct a camera instance. Attributes: @@ -166,7 +166,7 @@ def to_json_dict(self) -> dict[str, Any]: } @classmethod - def from_json_dict(cls, data: dict[str, Any]) -> CameraSpec: + def from_json_dict(cls, data: dict[str, Any]) -> CameraPublisherConfig: """Deserialize from a JSON dictionary (full publisher envelope or fragment). Uses :func:`validate_publisher_config` so stdin and reconfigure share @@ -178,7 +178,7 @@ def from_json_dict(cls, data: dict[str, Any]) -> CameraSpec: stdin envelope containing ``camera``. Returns: - A new :class:`CameraSpec` instance. + A new :class:`CameraPublisherConfig` instance. Raises: TypeError: If ``data`` is not a mapping. diff --git a/src/physicalai/cli/run.py b/src/physicalai/cli/run.py index 264110d..d425546 100644 --- a/src/physicalai/cli/run.py +++ b/src/physicalai/cli/run.py @@ -5,9 +5,12 @@ from __future__ import annotations +import json import logging +from pathlib import Path from typing import TYPE_CHECKING +import yaml from jsonargparse import ActionConfigFile, ArgumentParser from physicalai.cli._spec import SubcommandSpec # noqa: PLC2701 @@ -43,11 +46,68 @@ """ +def _reshape_config_value(parser: ArgumentParser, value: object) -> object: + """Return CLI-shaped config content for a bare exported runtime document. + + A ``--config`` value that points to a document with a top-level + ``class_path`` (as produced by ``to_config(runtime)`` / ``save_yaml``) is + loaded and unwrapped to the ``runtime:`` mapping the parser expects, + returned as JSON content (valid YAML that ``ActionConfigFile`` accepts + inline). Any other value — a non-path, an unreadable file, or a + ``runtime:`` CLI document — is returned unchanged. + + Returns: + The original value, or unwrapped config content for a bare document. + """ + if not isinstance(value, str): + return value + try: + if not Path(value).is_file(): + return value + document = yaml.safe_load(Path(value).read_text(encoding="utf-8")) + except (OSError, ValueError, yaml.YAMLError): + return value + if not isinstance(document, dict) or "class_path" not in document: + return value + + from physicalai.config import ComponentConfigError # noqa: PLC0415 + from physicalai.runtime import RobotRuntime # noqa: PLC0415 + from physicalai.runtime.core import _unwrap_runtime_document # noqa: PLC0415, PLC2701 + + try: + return json.dumps(_unwrap_runtime_document(document, target=RobotRuntime)) + except ComponentConfigError as exc: + parser.error(str(exc)) + + +class _RuntimeConfigFile(ActionConfigFile): + """``--config`` action accepting both the CLI document and a bare export. + + Reshapes a bare exported ``RobotRuntime`` ComponentConfig (a document with a + top-level ``class_path``) into the ``runtime:`` document jsonargparse + expects before applying it; a ``runtime:`` / ``run:`` CLI document is passed + through unchanged. This keeps ``to_config(runtime)`` → ``save_yaml`` → + ``physicalai run --config`` a single-shape round-trip. + """ + + def __call__( + self, + parser: ArgumentParser, + cfg: Namespace, + values: object, + option_string: str | None = None, + ) -> None: + """Reshape a bare exported runtime document, then delegate to the base action.""" + super().__call__(parser, cfg, _reshape_config_value(parser, values), option_string) + + def build_parser() -> ArgumentParser: """Build the ``run`` subcommand parser. One path: ``RobotRuntime`` constructor arguments (``action_source:`` - always explicit) plus ``run()`` method arguments. + always explicit) plus ``run()`` method arguments. ``--config`` accepts + both the CLI document (``runtime:`` / ``run:``) and a bare exported + ComponentConfig produced by ``to_config(runtime)``. Returns: Parser for the ``physicalai run`` subcommand. @@ -55,7 +115,7 @@ def build_parser() -> ArgumentParser: from physicalai.runtime import RobotRuntime # noqa: PLC0415 parser = ArgumentParser(prog="physicalai run", description=HELP) - parser.add_argument("--config", action=ActionConfigFile, help="YAML/JSON config file.") + parser.add_argument("--config", action=_RuntimeConfigFile, help="YAML/JSON config file.") parser.add_class_arguments(RobotRuntime, "runtime") parser.add_method_arguments(RobotRuntime, "run", "run") return parser diff --git a/src/physicalai/config/__init__.py b/src/physicalai/config/__init__.py index a5a3cef..d79c2c3 100644 --- a/src/physicalai/config/__init__.py +++ b/src/physicalai/config/__init__.py @@ -36,6 +36,7 @@ from ._instantiate import instantiate from ._normalize import validate_component_config from ._types import ComponentConfig, ConfigValue, JsonScalar, JsonValue +from ._yaml import load_yaml, save_yaml, to_yaml from .importing import import_dotted_path __all__ = [ @@ -49,10 +50,13 @@ "import_dotted_path", "instantiate", "is_config_exportable", + "load_yaml", "normalize_class_reference", "normalize_component_config", "resolve_public_class_path", + "save_yaml", "to_config", + "to_yaml", "validate_component_config", "validate_envelope", ] diff --git a/src/physicalai/config/_yaml.py b/src/physicalai/config/_yaml.py new file mode 100644 index 0000000..80120af --- /dev/null +++ b/src/physicalai/config/_yaml.py @@ -0,0 +1,79 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""YAML round-trip helpers for component configs. + +``to_yaml`` / ``save_yaml`` serialize a live ``@export_config`` component (or +an existing ``class_path`` + ``init_args`` mapping) to a YAML document that +``load_yaml`` + :func:`~physicalai.config.instantiate` can rebuild. The same +document is accepted by ``physicalai run --config`` when the top-level +component is a ``RobotRuntime``. + +Trusted local configs only — never feed network-received YAML to +:func:`~physicalai.config.instantiate`. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import TYPE_CHECKING + +import yaml + +from ._errors import ComponentConfigError +from ._export import to_config +from ._normalize import validate_component_config + +if TYPE_CHECKING: + from ._types import ComponentConfig, JsonValue + + +def to_yaml(component: object) -> str: + """Serialize a component to a YAML ``class_path`` + ``init_args`` document. + + Args: + component: A live ``@export_config`` instance, or an existing + :class:`ComponentConfig` mapping (validated, not re-exported). + + Returns: + YAML text of the validated :class:`ComponentConfig`. + """ + config: ComponentConfig + config = validate_component_config(dict(component)) if isinstance(component, Mapping) else to_config(component) + return yaml.safe_dump(dict(config), sort_keys=False, default_flow_style=False) + + +def save_yaml(component: object, path: str | Path) -> None: + """Write :func:`to_yaml` output to *path* (parent directories must exist). + + Args: + component: A live ``@export_config`` instance or a + :class:`ComponentConfig` mapping. + path: Destination file path. + """ + Path(path).write_text(to_yaml(component), encoding="utf-8") + + +def load_yaml(path: str | Path) -> dict[str, JsonValue]: + """Load a YAML document as a mapping, ready for :func:`~physicalai.config.instantiate`. + + The document is parsed with ``yaml.safe_load`` and only shape-checked to + be a mapping — full component validation happens in + :func:`~physicalai.config.instantiate`. + + Args: + path: YAML file previously written by :func:`save_yaml` (or + hand-authored in the same shape). + + Returns: + The loaded top-level mapping. + + Raises: + ComponentConfigError: If the document is not a mapping. + """ + loaded = yaml.safe_load(Path(path).read_text(encoding="utf-8")) + if not isinstance(loaded, dict): + msg = f"{str(path)!r}: YAML config must be a mapping, got {type(loaded).__name__}" + raise ComponentConfigError(msg) + return loaded diff --git a/src/physicalai/robot/so101/so101.py b/src/physicalai/robot/so101/so101.py index ad0d63b..67f0716 100644 --- a/src/physicalai/robot/so101/so101.py +++ b/src/physicalai/robot/so101/so101.py @@ -121,12 +121,14 @@ class SO101(Robot): def __init__( self, port: str, - calibration: SO101Calibration | str | Path | dict | None, + # Defaulted so the required-arg is jsonargparse-loadable when None + # (uncalibrated export); the guard below still enforces explicit intent. + calibration: SO101Calibration | str | Path | dict | None = None, baudrate: int = 1_000_000, role: Literal["leader", "follower"] = "follower", unit: SO101Unit = "normalized", *, - _allow_uncalibrated: bool = False, # must be passed by keyword + allow_uncalibrated: bool = False, ) -> None: """Initialize the SO-101 driver (does not open the connection). @@ -134,7 +136,8 @@ def __init__( * ``SO101Calibration`` — use an already loaded calibration object. * ``str | Path`` — load LeRobot calibration JSON from disk. - * ``None`` — only allowed via :meth:`SO101.uncalibrated` for raw ticks. + * ``None`` — raw-ticks mode; requires ``allow_uncalibrated=True`` + (see :meth:`SO101.uncalibrated`). Raises: ValueError: If ``role`` is not ``"leader"`` or ``"follower"``. @@ -150,7 +153,7 @@ def __init__( self._unit: SO101Unit = unit # Calibration ------------------------------------------------------- - if calibration is None and not _allow_uncalibrated: + if calibration is None and not allow_uncalibrated: msg = ( "calibration is required for SO101. " "Pass a calibration object/path, or use SO101.uncalibrated(...) " @@ -204,7 +207,7 @@ def uncalibrated( baudrate=baudrate, role=role, unit=unit, - _allow_uncalibrated=True, + allow_uncalibrated=True, ) @property diff --git a/src/physicalai/robot/transport/_shared_robot.py b/src/physicalai/robot/transport/_shared_robot.py index cff051a..91a603b 100644 --- a/src/physicalai/robot/transport/_shared_robot.py +++ b/src/physicalai/robot/transport/_shared_robot.py @@ -10,8 +10,10 @@ *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. +``robot=`` or :meth:`from_config`; a disconnected ``@export_config`` driver +instance is also accepted and converted via +:func:`~physicalai.config.to_config`. 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 @@ -53,7 +55,7 @@ import numpy as np from physicalai.config import ComponentConfig - from physicalai.robot import Robot, RobotObservation + from physicalai.robot import RobotObservation def _coerce_robot_recipe(robot: object) -> ComponentConfig | Mapping[str, object]: @@ -223,9 +225,10 @@ 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). + Prefer :meth:`from_config`. The constructor takes + ``robot: ComponentConfig`` (or a disconnected ``@export_config`` driver) + 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). @@ -319,65 +322,6 @@ def from_config( **({} if _session is None else {"_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) - # Omit default None so @export_config does not capture "_session": null. - 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, - **({} if _session is None else {"_session": _session}), - ) - @classmethod def attach( cls, diff --git a/src/physicalai/runtime/core.py b/src/physicalai/runtime/core.py index 9a73102..bb34aef 100644 --- a/src/physicalai/runtime/core.py +++ b/src/physicalai/runtime/core.py @@ -37,6 +37,42 @@ _GOAL_TIME_TICKS = 3 +def _unwrap_runtime_document(document: dict[str, Any], *, target: type) -> dict[str, Any]: + """Rewrite a bare exported ComponentConfig document to the CLI ``runtime:`` shape. + + A document without a top-level ``class_path`` is returned unchanged (it is + already a CLI document). Otherwise it must be a valid + :class:`~physicalai.config.ComponentConfig` whose ``class_path`` resolves + to *target* (or a subclass), and its ``init_args`` become the ``runtime:`` + section — making ``to_config(runtime)`` → YAML → CLI round-trip. + + Returns: + A CLI-shaped document with constructor args under ``runtime:``. + + Raises: + ComponentConfigError: If ``class_path`` does not resolve to *target*. + """ + if "class_path" not in document: + return document + + from physicalai.config import ( # noqa: PLC0415 + ComponentConfigError, + import_dotted_path, + validate_component_config, + ) + + config = validate_component_config(document) + resolved = import_dotted_path(config["class_path"]) + if not (isinstance(resolved, type) and issubclass(resolved, target)): + msg = ( + f"config class_path {config['class_path']!r} does not resolve to " + f"{target.__module__}.{target.__qualname__} (or a subclass); " + "expected a runtime config exported via to_config(runtime)" + ) + raise ComponentConfigError(msg) + return {"runtime": dict(config["init_args"])} + + class RuntimeCallback(Protocol): """Optional action-transform hooks a callback may implement. @@ -179,19 +215,26 @@ def __exit__(self, *exc_info: object) -> None: # noqa: D105 def from_config(cls, config: str | Path) -> Self: """Build runtime from YAML/JSON config file. + Accepts two document shapes: the CLI document (constructor args under + ``runtime:``, optional ``run:``) and a bare exported + :class:`~physicalai.config.ComponentConfig` as produced by + :func:`~physicalai.config.to_config` / :func:`~physicalai.config.save_yaml` + (top-level ``class_path`` resolving to this class). ``action_source:`` is always required and explicit — one schema, no flat/legacy shorthand. Returns: Instantiated runtime object. """ - from jsonargparse import ActionConfigFile, ArgumentParser # noqa: PLC0415 + from jsonargparse import ArgumentParser # noqa: PLC0415 + + from physicalai.config import load_yaml # noqa: PLC0415 + document = _unwrap_runtime_document(load_yaml(config), target=cls) parser = ArgumentParser() - parser.add_argument("--config", action=ActionConfigFile) parser.add_class_arguments(cls, "runtime") parser.add_method_arguments(cls, "run", "run") - ns = parser.parse_args(["--config", str(config)]) + ns = parser.parse_object(document) return parser.instantiate(ns).runtime def run(self, *, duration_s: float | None = None) -> int: diff --git a/tests/unit/capture/conftest.py b/tests/unit/capture/conftest.py index f60d5c3..86aec9f 100644 --- a/tests/unit/capture/conftest.py +++ b/tests/unit/capture/conftest.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from collections.abc import Generator - from physicalai.capture.transport._spec import CameraSpec + from physicalai.capture.transport._spec import CameraPublisherConfig HAS_ICEORYX2 = importlib.util.find_spec("iceoryx2") is not None @@ -21,16 +21,16 @@ @pytest.fixture -def fake_camera_spec() -> CameraSpec: - from physicalai.capture.transport._spec import CameraSpec # noqa: PLC0415 +def fake_camera_spec() -> CameraPublisherConfig: + from physicalai.capture.transport._spec import CameraPublisherConfig # noqa: PLC0415 - return CameraSpec( + return CameraPublisherConfig( camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {}}, ) @pytest.fixture -def publisher_service(fake_camera_spec: CameraSpec) -> Generator[str, None, None]: +def publisher_service(fake_camera_spec: CameraPublisherConfig) -> Generator[str, None, None]: from uuid import uuid4 # noqa: PLC0415 from physicalai.capture.transport._publisher import CameraPublisher # noqa: PLC0415 diff --git a/tests/unit/capture/test_camera_component_config.py b/tests/unit/capture/test_camera_component_config.py index 5aeb6c2..dde0d42 100644 --- a/tests/unit/capture/test_camera_component_config.py +++ b/tests/unit/capture/test_camera_component_config.py @@ -144,13 +144,13 @@ def test_attach_only_round_trip(self) -> None: 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: + def test_constructor_recipe_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") + with pytest.raises(ValueError, match="disconnected camera recipe"): + SharedCamera(camera=driver, service_name="physicalai/test/x/frame") assert driver.is_connected diff --git a/tests/unit/capture/test_transport.py b/tests/unit/capture/test_transport.py index c1e014a..7627ce0 100644 --- a/tests/unit/capture/test_transport.py +++ b/tests/unit/capture/test_transport.py @@ -26,7 +26,7 @@ encode_frame, ) from physicalai.capture.transport._shared_camera import SharedCamera -from physicalai.capture.transport._spec import CameraSpec, derive_service_name +from physicalai.capture.transport._spec import CameraPublisherConfig, derive_service_name from .conftest import FAKE_CAMERA_CLASS @@ -39,9 +39,9 @@ def _service_name() -> str: return f"physicalai/test/{uuid4().hex[:8]}/frame" -class TestCameraSpec: +class TestCameraPublisherConfig: def test_picklable(self) -> None: - spec = CameraSpec( + spec = CameraPublisherConfig( camera={ "class_path": "physicalai.capture.UVCCamera", "init_args": {"device": 0, "width": 640}, @@ -53,7 +53,7 @@ def test_picklable(self) -> None: assert restored.camera == spec.camera def test_build_uses_instantiate(self) -> None: - spec = CameraSpec( + spec = CameraPublisherConfig( camera={ "class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 320, "height": 240}, @@ -66,13 +66,13 @@ def test_build_uses_instantiate(self) -> None: def test_from_json_dict_rejects_flat_keys(self) -> None: with pytest.raises(ValueError, match="unknown publisher config keys"): - CameraSpec.from_json_dict( + CameraPublisherConfig.from_json_dict( {"camera_type": "uvc", "camera_kwargs": {"device": 0}, "service_name": "x"}, ) def test_from_json_dict_rejects_unknown_keys(self) -> None: with pytest.raises(ValueError, match="unknown publisher config keys"): - CameraSpec.from_json_dict( + CameraPublisherConfig.from_json_dict( { "camera": {"class_path": FAKE_CAMERA_CLASS, "init_args": {}}, "service_name": "x", @@ -82,7 +82,7 @@ def test_from_json_dict_rejects_unknown_keys(self) -> None: def test_from_json_dict_requires_camera(self) -> None: with pytest.raises(ValueError, match="missing required 'camera'"): - CameraSpec.from_json_dict({"service_name": "x"}) + CameraPublisherConfig.from_json_dict({"service_name": "x"}) def test_validate_publisher_config_shared_helper(self) -> None: from physicalai.capture.transport._spec import validate_publisher_config @@ -100,7 +100,7 @@ def test_validate_publisher_config_shared_helper(self) -> None: assert init_args["width"] == 8 def test_to_json_dict_camera_shape(self) -> None: - spec = CameraSpec( + spec = CameraPublisherConfig( camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {"device_name": "d0"}}, ) payload = spec.to_json_dict() @@ -221,7 +221,7 @@ def test_fps_defaults_to_zero(self) -> None: class TestSharedCameraConstruction: - """Unit tests for SharedCamera constructor / from_config / from_camera.""" + """Unit tests for SharedCamera constructor / from_config.""" def test_from_config_derives_builtin_service_name(self) -> None: cam = SharedCamera.from_config( @@ -297,36 +297,36 @@ def test_envelope_service_name_not_in_init_args(self) -> 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: + def test_constructor_recipe_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] + with pytest.raises(TypeError, match="config-exportable camera"): + SharedCamera(camera=_Bare()) # type: ignore[arg-type] - def test_from_camera_rejects_connected_without_disconnect(self) -> None: + def test_constructor_recipe_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") + with pytest.raises(ValueError, match="disconnected camera recipe"): + SharedCamera(camera=driver, service_name="physicalai/test/x/frame") assert driver.is_connected - def test_from_camera_exports_disconnected(self) -> None: + def test_constructor_recipe_accepts_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") + cam = SharedCamera(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( + spec = CameraPublisherConfig( camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 16, "height": 16}}, ) with patch("physicalai.capture.factory.create_camera") as mock_create: @@ -359,7 +359,7 @@ def kill(self) -> None: def wait(self, timeout: float | None = None) -> int: return 0 - spec = CameraSpec( + spec = CameraPublisherConfig( camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 8}}, ) publisher = CameraPublisher(spec, "physicalai/test/svc/frame") @@ -908,7 +908,7 @@ def test_reconfigure_only_attempted_once( @requires_iceoryx2 class TestCameraPublisher: - def test_start_stop_lifecycle(self, fake_camera_spec: CameraSpec) -> None: + def test_start_stop_lifecycle(self, fake_camera_spec: CameraPublisherConfig) -> None: from physicalai.capture.transport._publisher import CameraPublisher publisher = CameraPublisher( @@ -921,7 +921,7 @@ def test_start_stop_lifecycle(self, fake_camera_spec: CameraSpec) -> None: publisher.stop() assert not publisher.is_alive - def test_context_manager(self, fake_camera_spec: CameraSpec) -> None: + def test_context_manager(self, fake_camera_spec: CameraPublisherConfig) -> None: from physicalai.capture.transport._publisher import CameraPublisher with CameraPublisher( @@ -936,7 +936,7 @@ def test_start_failure_propagates(self) -> None: from physicalai.capture.transport._publisher import CameraPublisher publisher = CameraPublisher( - CameraSpec(camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {}}), + CameraPublisherConfig(camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {}}), _service_name(), _factory_override="tests.unit.capture.fake:DoesNotExist", ) @@ -1029,7 +1029,7 @@ 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( + spec = CameraPublisherConfig( camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 320, "height": 240}}, ) publisher = CameraPublisher( @@ -1069,7 +1069,7 @@ 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( + spec = CameraPublisherConfig( camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 320, "height": 240}}, ) publisher = CameraPublisher( @@ -1118,7 +1118,7 @@ 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( + spec = CameraPublisherConfig( camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 320, "height": 240}}, ) publisher = CameraPublisher( diff --git a/tests/unit/cli/test_cli.py b/tests/unit/cli/test_cli.py index 6656846..c4e0b72 100644 --- a/tests/unit/cli/test_cli.py +++ b/tests/unit/cli/test_cli.py @@ -249,6 +249,41 @@ def test_config_file_accepts_shared_robots(self, tmp_path: Path) -> None: assert cfg.runtime.robot.class_path == "physicalai.robot.SharedRobot" assert cfg.runtime.action_source.init_args.leader.class_path == "physicalai.robot.SharedRobot" + def test_config_file_accepts_bare_component_export(self, tmp_path: Path) -> None: + cfg_file = tmp_path / "export.yaml" + cfg_file.write_text( + "class_path: physicalai.runtime.RobotRuntime\n" + "init_args:\n" + " fps: 30\n" + " robot:\n" + f" class_path: {_FAKE_ROBOT}\n" + " init_args:\n" + " port: /dev/null\n" + " action_source:\n" + f" class_path: {_POLICY_SOURCE}\n" + " init_args:\n" + " model:\n" + f" class_path: {_REAL_MODEL}\n" + " init_args:\n" + " export_dir: /tmp/fake\n" + " execution:\n" + f" class_path: {_REAL_SYNC}\n", + ) + for argv in ([f"--config={cfg_file}"], ["--config", str(cfg_file)]): + parser = run_module.build_parser() + cfg = parser.parse_args(argv) + assert cfg.runtime.fps == 30 + assert cfg.runtime.robot.class_path == _FAKE_ROBOT + assert cfg.runtime.action_source.class_path == _POLICY_SOURCE + + def test_bare_export_foreign_class_path_errors(self, tmp_path: Path) -> None: + cfg_file = tmp_path / "foreign.yaml" + cfg_file.write_text(f"class_path: {_REAL_SYNC}\ninit_args: {{}}\n") + parser = run_module.build_parser() + with pytest.raises(SystemExit) as exc: + parser.parse_args([f"--config={cfg_file}"]) + assert exc.value.code != 0 + def test_cli_overrides_config_file(self, tmp_path: Path) -> None: cfg_file = tmp_path / "runtime.yaml" cfg_file.write_text( diff --git a/tests/unit/config/test_yaml.py b/tests/unit/config/test_yaml.py new file mode 100644 index 0000000..cea53dc --- /dev/null +++ b/tests/unit/config/test_yaml.py @@ -0,0 +1,86 @@ +# 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, magic-value-comparison, no-self-use, assert] + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import yaml + +from physicalai.config import ( + ComponentConfigError, + export_config, + instantiate, + load_yaml, + save_yaml, + to_config, + to_yaml, +) + +if TYPE_CHECKING: + from pathlib import Path + + +@export_config +class Gadget: + def __init__(self, size: int, label: str = "gadget") -> None: + self.size = size + self.label = label + + +@export_config +class Holder: + def __init__(self, gadget: Gadget, note: str | None = None) -> None: + self.gadget = gadget + self.note = note + + +class TestToYaml: + def test_live_component_round_trips_through_yaml(self) -> None: + holder = Holder(Gadget(3, label="inner"), note="hi") + + text = to_yaml(holder) + rebuilt = instantiate(yaml.safe_load(text)) + + assert isinstance(rebuilt, Holder) + assert rebuilt.note == "hi" + assert rebuilt.gadget.size == 3 + assert rebuilt.gadget.label == "inner" + + def test_accepts_existing_component_config_mapping(self) -> None: + config = to_config(Gadget(7)) + + text = to_yaml(config) + loaded = yaml.safe_load(text) + + assert loaded == {"class_path": f"{__name__}.Gadget", "init_args": {"size": 7}} + + def test_rejects_malformed_mapping(self) -> None: + with pytest.raises(ComponentConfigError, match="class_path"): + to_yaml({"init_args": {"size": 1}}) + + def test_rejects_non_exportable_object(self) -> None: + with pytest.raises(ComponentConfigError): + to_yaml(object()) + + +class TestSaveLoadYaml: + def test_save_then_load_then_instantiate(self, tmp_path: Path) -> None: + target = tmp_path / "gadget.yaml" + save_yaml(Gadget(5), target) + + loaded = load_yaml(target) + rebuilt = instantiate(loaded) + + assert isinstance(rebuilt, Gadget) + assert rebuilt.size == 5 + assert rebuilt.label == "gadget" + + def test_load_rejects_non_mapping_document(self, tmp_path: Path) -> None: + target = tmp_path / "list.yaml" + target.write_text("- 1\n- 2\n", encoding="utf-8") + + with pytest.raises(ComponentConfigError, match="must be a mapping"): + load_yaml(target) diff --git a/tests/unit/robot/test_robot_component_config.py b/tests/unit/robot/test_robot_component_config.py index fc0bb31..6c0a6b0 100644 --- a/tests/unit/robot/test_robot_component_config.py +++ b/tests/unit/robot/test_robot_component_config.py @@ -116,15 +116,26 @@ def test_absolute_path_calibration_stays_absolute( 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: + def test_uncalibrated_export_round_trips(self, mock_scservo_sdk: MagicMock) -> None: + from jsonargparse import ArgumentParser + 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"]["allow_uncalibrated"] is True assert wire["init_args"]["unit"] == "ticks" + # Public allow_uncalibrated + defaulted calibration make the export + # loadable by jsonargparse (the physicalai run --config path); a + # required calibration with no default rejects calibration: null. + parser = ArgumentParser() + parser.add_class_arguments(SO101, "robot") + ns = parser.parse_object({"robot": wire["init_args"]}) + assert ns.robot.calibration is None + assert ns.robot.allow_uncalibrated is True + def test_protocol_still_holds(self, mock_scservo_sdk: MagicMock) -> None: from physicalai.robot import SO101 @@ -284,12 +295,12 @@ def test_from_config_omits_null_session(self) -> None: wire = _assert_construction_round_trip(robot) assert "_session" not in wire["init_args"] - def test_from_robot_omits_null_session(self) -> None: + def test_constructor_recipe_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") + robot = SharedRobot("from-robot-arm", robot=driver) wire = _assert_construction_round_trip(robot) assert "_session" not in wire["init_args"] @@ -319,13 +330,13 @@ def test_classmethod_live_session_still_fails_at_to_config(self) -> None: with pytest.raises(ComponentConfigError, match=r"init_args\._session"): to_config(robot) - def test_from_robot_still_rejects_connected(self) -> None: + def test_constructor_recipe_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") + with pytest.raises(ValueError, match="disconnected driver recipe"): + SharedRobot("left-arm", robot=driver) assert driver.is_connected() diff --git a/tests/unit/robot/transport/test_shared_robot.py b/tests/unit/robot/transport/test_shared_robot.py index 8b05350..b918f07 100644 --- a/tests/unit/robot/transport/test_shared_robot.py +++ b/tests/unit/robot/transport/test_shared_robot.py @@ -100,26 +100,26 @@ def test_from_config_stores_component_config(self) -> None: ) assert robot._robot == {"class_path": FAKE_ROBOT_CLASS, "init_args": {"port": "/dev/fake0"}} - def test_from_robot_requires_exportable(self) -> None: + def test_constructor_recipe_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] + with pytest.raises(TypeError, match="config-exportable robot"): + SharedRobot("left-arm", robot=_Bare()) # type: ignore[arg-type] - def test_from_robot_rejects_connected_without_disconnect(self) -> None: + def test_constructor_recipe_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") + with pytest.raises(ValueError, match="disconnected driver recipe"): + SharedRobot("left-arm", robot=driver) assert driver.is_connected() assert not driver.disconnect_called - def test_from_robot_exports_disconnected_driver(self) -> None: + def test_constructor_recipe_accepts_disconnected_driver(self) -> None: driver = FakeRobot(port="/dev/fake0", device_ids=("fake:/dev/fake0",)) - robot = SharedRobot.from_robot(driver, name="left-arm") + robot = SharedRobot("left-arm", robot=driver) assert robot._robot is not None assert robot._robot["class_path"] == FAKE_ROBOT_CLASS assert robot._robot["init_args"]["port"] == "/dev/fake0" diff --git a/tests/unit/runtime/test_runtime_component_config.py b/tests/unit/runtime/test_runtime_component_config.py index 34d32e5..795183f 100644 --- a/tests/unit/runtime/test_runtime_component_config.py +++ b/tests/unit/runtime/test_runtime_component_config.py @@ -508,6 +508,42 @@ def test_complete_tree_jsonargparse_under_runtime( assert via_from_config._connected is False # noqa: SLF001 assert via_from_config._fps == 30.0 # noqa: SLF001 + def test_bare_component_export_round_trips( + self, + inference_model: Any, + _patch_adapter: MagicMock, + tmp_path: Path, + ) -> None: + from physicalai.cli.run import build_parser + from physicalai.runtime import RobotRuntime + + runtime = _make_full_runtime(inference_model) + # The bare to_config shape (top-level class_path) — no manual rewrite + # to ``runtime:`` needed on either load path. + cfg_path = tmp_path / "runtime_export.json" + cfg_path.write_text(json.dumps(to_config(runtime))) + + parser = build_parser() + ns = parser.parse_args(["--config", str(cfg_path)]) + restored = parser.instantiate(ns).runtime + assert isinstance(restored, RobotRuntime) + assert restored._fps == 30.0 # noqa: SLF001 + assert restored._connected is False # noqa: SLF001 + + via_from_config = RobotRuntime.from_config(cfg_path) + assert isinstance(via_from_config, RobotRuntime) + assert via_from_config._fps == 30.0 # noqa: SLF001 + + def test_bare_export_foreign_class_path_rejected(self, tmp_path: Path) -> None: + from physicalai.config import ComponentConfigError + from physicalai.runtime import RobotRuntime + + cfg_path = tmp_path / "not_runtime.json" + cfg_path.write_text(json.dumps({"class_path": "physicalai.runtime.SyncExecution", "init_args": {}})) + + with pytest.raises(ComponentConfigError, match="does not resolve to"): + RobotRuntime.from_config(cfg_path) + def test_omitted_cameras_and_callbacks( self, inference_model: Any, _patch_adapter: MagicMock ) -> None: From 818b430618395c039f0fbefc04cfd0b4c8143473 Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Sat, 25 Jul 2026 22:10:50 +0200 Subject: [PATCH 18/21] fixes --- docs/development/component-config.md | 18 ++- docs/development/security.md | 5 +- docs/explanation/cameras.md | 14 +- docs/explanation/configuration.md | 89 +++++-------- docs/getting-started/run-a-policy.md | 3 +- docs/how-to/config/instantiate-components.md | 82 ++++++------ docs/how-to/config/write-inference-config.md | 7 +- docs/how-to/config/write-runtime-config.md | 3 +- docs/how-to/runtime/run-policy-on-robot.md | 3 +- docs/how-to/runtime/share-a-robot.md | 8 +- docs/reference/config-schema.md | 105 ++++++--------- docs/reference/inference-api.md | 5 +- .../capture/transport/_publisher_worker.py | 35 +++-- .../capture/transport/_shared_camera.py | 48 +++++-- src/physicalai/capture/transport/_spec.py | 67 ++++++++-- src/physicalai/config/_export.py | 7 + src/physicalai/config/_instantiate.py | 123 ++++++++++++++++++ src/physicalai/config/_types.py | 1 + .../robot/transport/_shared_robot.py | 6 + .../capture/test_camera_component_config.py | 14 ++ tests/unit/capture/test_transport.py | 122 ++++++++++++++++- tests/unit/config/test_component_config.py | 86 ++++++++++++ .../unit/robot/test_robot_component_config.py | 18 +++ 23 files changed, 640 insertions(+), 229 deletions(-) diff --git a/docs/development/component-config.md b/docs/development/component-config.md index 187dc39..5aa9730 100644 --- a/docs/development/component-config.md +++ b/docs/development/component-config.md @@ -522,6 +522,12 @@ 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. +The publisher's runtime control channel is a separate, untrusted peer +boundary. A `RECONFIGURE` request carries only allowlisted scalar capture +settings (`width`, `height`, `fps`). The publisher patches those values onto +the trusted startup recipe; a subscriber can never replace `class_path`, +device identity, backend, or other constructor arguments. + #### SharedCamera service naming Transport owns naming; `ComponentConfig` never embeds `service_name`. @@ -680,6 +686,10 @@ 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. +This hard cutover applies only to trusted parent→child startup stdin. Camera +reconfigure messages arrive from subscribers and therefore never carry a +ComponentConfig or enter `instantiate()`. + Decision record: [shared construction wire decision](shared-construction-wire-decision.md). ### Paths and cwd for local replay / IPC @@ -858,7 +868,9 @@ 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. +configs from network subscribers. In particular, camera control requests may +patch only allowlisted scalar settings onto the publisher's trusted startup +recipe; they cannot select a class, device, or backend. 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. @@ -1003,6 +1015,10 @@ cutovers; do not describe them as schema-preserving. 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. +- Camera reconfigure control requests accept only positive integer + `width` / `height` / `fps` settings, reject ComponentConfig and unknown-key + payloads before side effects, and preserve the trusted startup class, + device, backend, and remaining constructor arguments. - 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 diff --git a/docs/development/security.md b/docs/development/security.md index b4bf1c4..f7e15f6 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -21,7 +21,10 @@ These rules apply when writing, editing, or reviewing code under `src/physicalai 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. + metadata, Zenoh payloads, shared-memory control requests, or other + untrusted peer data into it. Camera reconfigure requests may carry only + explicitly allowlisted scalar settings; the publisher must merge them into + its trusted startup recipe without accepting a peer-selected `class_path`. 5. Enforce component nesting limits. `_MAX_COMPONENT_DEPTH` in `component_factory.py` caps recursive manifest/YAML instantiation; diff --git a/docs/explanation/cameras.md b/docs/explanation/cameras.md index 7599dbb..e6d2682 100644 --- a/docs/explanation/cameras.md +++ b/docs/explanation/cameras.md @@ -32,12 +32,12 @@ Camera instances are not thread-safe. Use one thread per camera instance or add 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. +Construction uses `camera=` or `from_config()`, matching `SharedRobot`. Prefer +`from_config()` (or YAML) when sharing. A disconnected `@export_config` camera +can be passed directly to `camera=`; this exports its recipe rather than +handing 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 @@ -52,6 +52,8 @@ shared = SharedCamera.from_config( ) # Equivalent after to_config(disconnected_driver): # shared = SharedCamera.from_config(to_config(driver)) +# Or let the constructor export the disconnected driver: +# shared = SharedCamera(camera=driver) shared.connect() # Safe to read from multiple threads/processes diff --git a/docs/explanation/configuration.md b/docs/explanation/configuration.md index 23f64d5..65931aa 100644 --- a/docs/explanation/configuration.md +++ b/docs/explanation/configuration.md @@ -1,33 +1,11 @@ # Configuration -The config system is intended to make Python, YAML, CLI, and Studio payloads use the same workflow shape. +PhysicalAI uses constructor-shaped data so Python, YAML, the CLI, and Studio +can describe the same runtime graph. -```bash -physicalai run --config runtime.yaml -``` - -## Layers - -```text -Config - typed constructor args for one class - -ComponentSpec - target + args for one instantiable component - -Workflow config - user-authored workflow before execution - -Manifest - exported package metadata after build/export - -Orchestrator - live object that executes the workflow -``` +## Construction Recipes -## ComponentSpec - -Direct class mode: +A `ComponentConfig` names one class and the arguments needed to create it. ```yaml class_path: physicalai.capture.UVCCamera @@ -37,47 +15,40 @@ init_args: height: 480 ``` -> **Tip:** Use stable device paths (`/dev/v4l/by-id/...`) in config files. Index-based paths like `/dev/video0` can change after reboot. - -Registry mode: +Classes opt in to exporting this recipe with `@export_config`. `to_config()` +captures construction intent rather than mutable runtime state: supplied +arguments are preserved, omitted defaults remain omitted, and connections or +open handles are never exported. -```yaml -type: uvc -device: /dev/v4l/by-id/usb-Example_Camera-video-index0 -width: 640 -height: 480 +```text +live opted-in component -> to_config() -> JSON/YAML -> instantiate() -> new component ``` -If both `class_path` and `type` are present, `class_path` takes precedence. +`instantiate()` calls constructors only. The application remains responsible +for lifecycle methods such as `connect()` and `run()`. -## Typed Config +## Workflow Documents -Typed configs are useful when constructor validation and IDE support matter. +A workflow document adds command-level structure around components. For +example, `physicalai run` expects runtime constructor arguments under +`runtime:` and optional run-method arguments under `run:`. A bare exported +`RobotRuntime` ComponentConfig is also accepted and reshaped by the loader. -```python -@dataclass -class Pi05Config(Config): - chunk_size: int = 50 - n_action_steps: int = 50 +Nested components retain the same `class_path` + `init_args` form, so a robot, +action source, model, camera map, and callbacks form one recursive recipe. - def __post_init__(self) -> None: - if self.n_action_steps > self.chunk_size: - raise ValueError("n_action_steps must be <= chunk_size") -``` - -Typed configs do not decide which class to instantiate. They only validate and carry constructor arguments. +## Manifests -```python -cfg = Pi05Config(chunk_size=50) -policy = instantiate_obj(cfg, target_cls=Pi05) -``` +An inference manifest describes an exported policy package: artifacts, +features, processors, runners, and compatibility metadata. Its +`ComponentSpec` supports registry aliases and artifact handling and remains +separate from strict captured `ComponentConfig` data. -## Execution Boundary +Use workflow configuration for deployment intent. Use manifests for package +metadata produced during export. -Configuration objects remain passive data. Orchestrators are responsible for creating live objects and executing workflows. +## Trust -```python -config = RuntimeConfig.load("runtime.yaml") -runtime = RobotRuntime.from_config(config) -runtime.run() -``` +Resolving `class_path` imports and executes local Python code. Only trusted +application or user-authored configuration belongs at the instantiation +boundary; peer metadata and transport control messages do not. diff --git a/docs/getting-started/run-a-policy.md b/docs/getting-started/run-a-policy.md index c35df40..b21b591 100644 --- a/docs/getting-started/run-a-policy.md +++ b/docs/getting-started/run-a-policy.md @@ -36,9 +36,10 @@ The minimal runtime configuration looks like this. ```yaml runtime: robot: - class_path: physicalai.robot.so101.SO101 + class_path: physicalai.robot.SO101 init_args: port: /dev/ttyACM0 + calibration: ./calibration.json action_source: class_path: physicalai.runtime.PolicySource init_args: diff --git a/docs/how-to/config/instantiate-components.md b/docs/how-to/config/instantiate-components.md index 34e1d19..5885308 100644 --- a/docs/how-to/config/instantiate-components.md +++ b/docs/how-to/config/instantiate-components.md @@ -1,10 +1,7 @@ # Instantiate Components -> **Preview:** The config system (`physicalai.config`) is a planned API. The examples below document the target design. Currently, `ComponentSpec` lives in `physicalai.inference.manifest`. - -A component spec describes one instantiable object. - -The most explicit form uses a class path. +A `ComponentConfig` is a construction recipe with one importable class and +its supplied constructor arguments. ```yaml class_path: physicalai.capture.UVCCamera @@ -14,52 +11,51 @@ init_args: height: 480 ``` -The shorter form uses a registry name. +Use `instantiate()` to construct trusted local configuration. It calls the +constructor but does not call lifecycle methods such as `connect()`, `run()`, +or `start()`. -```yaml -type: uvc -device: /dev/video0 -width: 640 -height: 480 +```python +from physicalai.config import instantiate + +camera = instantiate({ + "class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": "/dev/video0", "width": 640, "height": 480}, +}) +camera.connect() ``` -You can construct and instantiate the same spec from Python. +## Export a live component -```python -from physicalai.inference.manifest import ComponentSpec -from physicalai.inference.component_factory import instantiate_component +Classes opt in with `@export_config`. The decorator remembers only arguments +the caller supplied, so omitted constructor defaults remain omitted. -spec = ComponentSpec( - class_path="physicalai.capture.UVCCamera", - init_args={"device": "/dev/video0", "width": 640, "height": 480}, -) +```python +from physicalai.capture import UVCCamera +from physicalai.config import to_config -camera = instantiate_component(spec) +camera = UVCCamera(device="/dev/video0", width=640, height=480) +config = to_config(camera) ``` -Nested component specs are instantiated recursively. +Nested opted-in components use the same shape recursively. Configs contain +JSON values only; paths become strings, tuples become lists during export, +and non-finite floats are rejected. -```yaml -class_path: physicalai.runtime.RobotRuntime -init_args: - robot: - class_path: physicalai.robot.so101.SO101 - init_args: - port: /dev/ttyACM0 - action_source: - class_path: physicalai.runtime.PolicySource - init_args: - model: - class_path: physicalai.inference.InferenceModel - init_args: - export_dir: ./exports/act_policy - execution: - class_path: physicalai.runtime.SyncExecution - cameras: - wrist: - class_path: physicalai.capture.UVCCamera - init_args: - device: /dev/video0 +```python +from physicalai.config import save_yaml + +save_yaml(camera, "camera.yaml") ``` -`ComponentSpec` describes what should be built. Instantiation is the separate step that creates the live object. +## Trust boundary + +`class_path` selects Python code to import and execute. Pass only trusted +application or user-authored configuration to `instantiate()`. Do not +instantiate robot metadata, camera metadata, shared-memory messages, or other +peer-controlled payloads. + +Inference manifest `ComponentSpec` is a separate compatibility schema with +registry aliases and artifact handling. Use `physicalai.config` for captured +construction recipes; use the manifest APIs when loading exported policy +metadata. diff --git a/docs/how-to/config/write-inference-config.md b/docs/how-to/config/write-inference-config.md index 7ba5f67..13fe34c 100644 --- a/docs/how-to/config/write-inference-config.md +++ b/docs/how-to/config/write-inference-config.md @@ -1,7 +1,5 @@ # Write Inference Config -> **Preview:** The config system (`physicalai.config`) is a planned API. The examples below document the target design. - Use an inference config when you need to author an inference pipeline outside an exported manifest. ```yaml @@ -21,3 +19,8 @@ action = model.select_action(observation) ``` Use workflow config to express user-authored intent. Use a manifest to describe exported package metadata. + +To export a model's supplied constructor arguments from Python, call +`physicalai.config.to_config(model)`. Rebuild that trusted local recipe with +`physicalai.config.instantiate(config)`; construction loads the model package +and can allocate substantial resources. diff --git a/docs/how-to/config/write-runtime-config.md b/docs/how-to/config/write-runtime-config.md index 9cc22b6..67df021 100644 --- a/docs/how-to/config/write-runtime-config.md +++ b/docs/how-to/config/write-runtime-config.md @@ -6,9 +6,10 @@ A runtime config describes a robot control workflow before execution starts. # runtime.yaml runtime: robot: - class_path: physicalai.robot.so101.SO101 + class_path: physicalai.robot.SO101 init_args: port: /dev/ttyACM0 + calibration: ./calibration.json action_source: class_path: physicalai.runtime.PolicySource init_args: diff --git a/docs/how-to/runtime/run-policy-on-robot.md b/docs/how-to/runtime/run-policy-on-robot.md index 7eb2dbd..6b90409 100644 --- a/docs/how-to/runtime/run-policy-on-robot.md +++ b/docs/how-to/runtime/run-policy-on-robot.md @@ -31,9 +31,10 @@ Write a runtime configuration file. # runtime.yaml runtime: robot: - class_path: physicalai.robot.so101.SO101 + class_path: physicalai.robot.SO101 init_args: port: /dev/ttyACM0 + calibration: ./calibration.json action_source: class_path: physicalai.runtime.PolicySource init_args: diff --git a/docs/how-to/runtime/share-a-robot.md b/docs/how-to/runtime/share-a-robot.md index f247fef..3f6b11b 100644 --- a/docs/how-to/runtime/share-a-robot.md +++ b/docs/how-to/runtime/share-a-robot.md @@ -19,9 +19,9 @@ 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. -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: +Construction uses `robot=` or `from_config()`. Prefer `from_config()` when you +already have a recipe. A disconnected `@export_config` driver can be passed +directly to the constructor or exported explicitly: ```python import numpy as np @@ -33,7 +33,7 @@ driver = SO101( 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=driver) # or: SharedRobot("left-arm", robot={"class_path": "physicalai.robot.SO101", "init_args": {...}}) robot.connect() diff --git a/docs/reference/config-schema.md b/docs/reference/config-schema.md index bfc3f4a..4fb9938 100644 --- a/docs/reference/config-schema.md +++ b/docs/reference/config-schema.md @@ -1,12 +1,9 @@ # Config Schema Reference -> **Preview:** The config system is a planned API. The schemas below document the target config design. +## ComponentConfig -Config files use `class_path` and `init_args` to describe explicit component construction. - -## ComponentSpec - -Direct class mode: +`physicalai.config.ComponentConfig` uses the jsonargparse-compatible +`class_path` + `init_args` shape. ```yaml class_path: package.module.ClassName @@ -14,83 +11,61 @@ init_args: key: value ``` -Registry mode: - -```yaml -type: registered_name -key: value -``` +| Field | Required | Type | Description | +| ------------ | -------- | ------ | ------------------------------------------ | +| `class_path` | yes | string | Non-empty import path resolving to a class | +| `init_args` | no | object | String-keyed constructor arguments | -The `ComponentSpec` fields are listed below. +Omitted `init_args` means an empty mapping. No other top-level keys are +accepted. -| Field | Type | Description | -| ------------ | ------ | --------------------------------------- | -| `class_path` | string | Fully qualified import path | -| `init_args` | object | Constructor keyword arguments | -| `type` | string | Registered short name | -| extra fields | any | Flat constructor args for registry mode | +Values may be `null`, booleans, integers, finite floats, strings, lists, or +string-keyed mappings. Nested components use another `ComponentConfig`. +Every mapping containing `class_path` is reserved as a nested component and +must contain only `class_path` and optional `init_args`. Nesting through +components, lists, and mappings is limited to 10 levels. -The core rules are straightforward. +Export converts `Path` to its unchanged string form, `Enum` to its JSON-safe +value, and tuples to lists. `instantiate()` accepts the JSON representation, +not those original Python objects. -- A component spec must include either `class_path` or `type`. -- If both fields are present, `class_path` takes precedence. -- Nested component specs are instantiated recursively. +## Runtime Documents -## RuntimeConfig +A CLI runtime document places constructor arguments under `runtime:` and may +include method arguments under `run:`. ```yaml runtime: robot: - class_path: physicalai.robot.so101.SO101 + class_path: physicalai.robot.SO101 init_args: port: /dev/ttyACM0 + calibration: ./calibration.json action_source: - class_path: physicalai.runtime.PolicySource + class_path: physicalai.runtime.TeleopSource init_args: - model: - class_path: physicalai.inference.InferenceModel + leader: + class_path: physicalai.robot.SharedRobot init_args: - export_dir: ./exports/act_policy - execution: - class_path: physicalai.runtime.SyncExecution + name: leader-arm fps: 30 +run: + duration_s: 60 ``` -The most common runtime fields are listed below. - -| Field | Type | Description | -| ----------------------- | --------------- | -------------------------------------------------------------------- | -| `runtime.robot` | `ComponentSpec` | Robot implementation | -| `runtime.action_source` | `ComponentSpec` | Action source — always explicit, e.g. `PolicySource`, `TeleopSource` | -| `runtime.fps` | number | Control loop frequency | -| `runtime.cameras` | mapping | Optional camera components | -| `runtime.callbacks` | list | Optional runtime callbacks | - -`action_source.init_args` depends on the chosen class — `PolicySource` takes `model` and `execution` (and optionally `task`, `action_queue`); `TeleopSource` takes `leader`. - -## InferenceConfig - -```yaml -model: - class_path: physicalai.inference.InferenceModel - init_args: - export_dir: ./exports/act_policy - backend: openvino - device: CPU -``` +`physicalai run --config` and `RobotRuntime.from_config()` also accept a bare +exported `RobotRuntime` ComponentConfig whose top-level `class_path` is +`physicalai.runtime.RobotRuntime`. -The most common inference fields are listed below. +## Manifest ComponentSpec -| Field | Type | Description | -| ---------------------------- | --------------- | -------------------------- | -| `model` | `ComponentSpec` | Inference model component | -| `model.init_args.export_dir` | string | Exported package directory | -| `model.init_args.backend` | string | Backend name or `auto` | -| `model.init_args.device` | string | Backend device or `auto` | +Inference manifests retain their separate `ComponentSpec` compatibility +model. It supports registry `type` aliases, artifact resolution, and existing +extra-field behavior. It is not the strict captured `ComponentConfig` schema; +manifest unification is a separate design decision. -## Config vs Manifest +## Security -| Schema | Use | -| --------------- | -------------------------------------------------------- | -| Workflow config | A workflow config describes a workflow before execution. | -| Manifest | A manifest describes an exported package after export. | +`class_path` is executable local configuration. `instantiate()` is for +trusted application and user-authored configs only, never metadata or control +messages received from peers. diff --git a/docs/reference/inference-api.md b/docs/reference/inference-api.md index 329be9b..18fc631 100644 --- a/docs/reference/inference-api.md +++ b/docs/reference/inference-api.md @@ -16,7 +16,8 @@ InferenceModel( ) ``` -The model can be constructed directly from an export directory or loaded from config. +The model can be constructed directly from an export directory or nested in a +trusted workflow ComponentConfig. ## Constructors @@ -38,8 +39,6 @@ This downloads the policy package snapshot from the Hugging Face Hub and loads it like a local export. Additional keyword arguments are forwarded to the constructor (e.g. `backend`, `device`). -> **Note:** `InferenceModel.from_config()` is a planned API. - ## Methods ### `select_action` diff --git a/src/physicalai/capture/transport/_publisher_worker.py b/src/physicalai/capture/transport/_publisher_worker.py index 9f2e20a..b4d2852 100644 --- a/src/physicalai/capture/transport/_publisher_worker.py +++ b/src/physicalai/capture/transport/_publisher_worker.py @@ -264,37 +264,32 @@ def _handle_reconfigure(state: _PublisherState, request: dict, service_name: str Args: state: Shared publisher state. - request: Parsed JSON request with ``spec`` key. + request: Parsed JSON request with allowlisted scalar ``settings``. service_name: For logging. Returns: Response dict with ``ok`` and optional ``error``. """ - spec_data = request.get("spec") - if not spec_data or not isinstance(spec_data, dict): - return {"ok": False, "error": "missing or invalid 'spec' in request"} + from ._spec import validate_reconfigure_request # noqa: PLC0415 + + try: + settings = validate_reconfigure_request(request) + except (TypeError, ValueError) as exc: + return {"ok": False, "error": f"invalid reconfigure request: {exc}"} with state.lock: old_config = state.config.copy() old_camera = state.camera old_fps = state.camera_fps - 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, + trusted_camera = old_config["camera"] + trusted_init_args = trusted_camera["init_args"] + new_config = old_config.copy() + new_config["camera"] = { + "class_path": trusted_camera["class_path"], + "init_args": {**trusted_init_args, **settings}, } - # Preserve factory override if present in original config - if "_factory_override" in old_config: - new_config["_factory_override"] = old_config["_factory_override"] + new_config["service_name"] = service_name try: old_camera.disconnect() @@ -325,7 +320,7 @@ def _handle_reconfigure(state: _PublisherState, request: dict, service_name: str state.camera = new_camera state.config = new_config state.camera_fps = new_fps - logger.info(f"Reconfigured publisher for {service_name} with {spec_data}") + logger.info(f"Reconfigured publisher for {service_name} with settings {settings}") return {"ok": True} diff --git a/src/physicalai/capture/transport/_shared_camera.py b/src/physicalai/capture/transport/_shared_camera.py index 7fb8a9c..28d3e50 100644 --- a/src/physicalai/capture/transport/_shared_camera.py +++ b/src/physicalai/capture/transport/_shared_camera.py @@ -28,7 +28,7 @@ from physicalai.config import export_config from ._header import FrameHeader, decode_header, decode_rgb -from ._spec import derive_service_name, normalize_camera_config +from ._spec import derive_service_name, normalize_camera_config, validate_reconfigure_request if TYPE_CHECKING: from physicalai.capture.frame import Frame @@ -213,6 +213,12 @@ def __init__( self._subscriber: Any | None = None self._listener: Any | None = None + @classmethod + def _physicalai_normalize_captured_init_args(cls, supplied: dict[str, object]) -> None: + camera = supplied.get("camera") + if isinstance(camera, Mapping): + supplied["camera"] = normalize_camera_config(camera) + @classmethod def from_config( cls, @@ -461,6 +467,32 @@ def _do_disconnect(self) -> None: self._latest = None self._last_header = None + def _build_reconfigure_request(self) -> dict[str, object]: + """Build and validate the settings-only peer control request. + + Returns: + A validated `RECONFIGURE` request. + + Raises: + CaptureError: If this camera has no valid reconfigurable settings. + """ + if self._camera is None: + msg = "reconfigure requires a camera ComponentConfig (attach-only SharedCamera has none)" + raise CaptureError(msg) + + init_args = self._camera["init_args"] + settings = {key: value for key in ("width", "height", "fps") if (value := init_args.get(key)) is not None} + request: dict[str, object] = { + "kind": "RECONFIGURE", + "settings": settings, + } + try: + validate_reconfigure_request(request) + except (TypeError, ValueError) as exc: + msg = f"camera config has no valid reconfigure settings: {exc}" + raise CaptureError(msg) from exc + return request + def _request_reconfigure(self, timeout: float = 5.0) -> dict: """Send a RECONFIGURE request to the publisher's control channel. @@ -480,9 +512,7 @@ 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) + request = self._build_reconfigure_request() iox2 = cast("Any", import_module("iceoryx2")) control_name = f"{self._service_name}/control" @@ -500,15 +530,7 @@ 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 - request_payload = json.dumps({ - "kind": "RECONFIGURE", - "spec": { - "camera": { - "class_path": self._camera["class_path"], - "init_args": dict(self._camera["init_args"]), # type: ignore[arg-type] - }, - }, - }).encode() + request_payload = json.dumps(request).encode() try: sample = client.loan_slice_uninit(len(request_payload)) diff --git a/src/physicalai/capture/transport/_spec.py b/src/physicalai/capture/transport/_spec.py index a788f3c..08f639e 100644 --- a/src/physicalai/capture/transport/_spec.py +++ b/src/physicalai/capture/transport/_spec.py @@ -34,10 +34,9 @@ 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`. +# Allowed keys on the trusted publisher stdin handshake. Everything else +# (including legacy flat camera_type / camera_kwargs) is an unknown-key +# schema error. _PUBLISHER_ENVELOPE_KEYS = frozenset({ "camera", "service_name", @@ -45,10 +44,12 @@ "max_subscribers", "_factory_override", }) +_RECONFIGURE_REQUEST_KEYS = frozenset({"kind", "settings"}) +_RECONFIGURABLE_SETTINGS = frozenset({"width", "height", "fps"}) def validate_publisher_config(data: Mapping[str, Any]) -> Mapping[str, object]: - """Validate a publisher stdin or reconfigure payload schema-positively. + """Validate a trusted publisher stdin payload schema-positively. Returns: The validated ``camera`` ComponentConfig mapping (not yet @@ -62,6 +63,56 @@ def validate_publisher_config(data: Mapping[str, Any]) -> Mapping[str, object]: ) +def validate_reconfigure_request(request: Mapping[str, Any]) -> dict[str, int]: + """Validate an untrusted camera reconfigure control request. + + The peer may change only scalar capture settings. The publisher keeps the + trusted startup ``class_path`` and all other constructor arguments. + + Returns: + Validated settings to patch into the trusted camera recipe. + + Raises: + TypeError: If the request or a setting has the wrong type. + ValueError: If required fields are missing, unknown, or out of range. + """ + if not isinstance(request, Mapping): + msg = f"reconfigure request must be a mapping, got {type(request).__name__}" + raise TypeError(msg) + + unknown_request_keys = sorted(set(request) - _RECONFIGURE_REQUEST_KEYS) + if unknown_request_keys: + msg = f"unknown reconfigure request keys {unknown_request_keys}" + raise ValueError(msg) + if request.get("kind") != "RECONFIGURE": + msg = "reconfigure request kind must be 'RECONFIGURE'" + raise ValueError(msg) + + settings = request.get("settings") + if not isinstance(settings, Mapping): + msg = f"reconfigure 'settings' must be a mapping, got {type(settings).__name__}" + raise TypeError(msg) + if not settings: + msg = "reconfigure 'settings' must not be empty" + raise ValueError(msg) + + unknown_settings = sorted(set(settings) - _RECONFIGURABLE_SETTINGS) + if unknown_settings: + msg = f"unknown reconfigure settings {unknown_settings}; allowed: {sorted(_RECONFIGURABLE_SETTINGS)}" + raise ValueError(msg) + + validated: dict[str, int] = {} + for key, value in settings.items(): + if isinstance(value, bool) or not isinstance(value, int): + msg = f"reconfigure setting {key!r} must be an integer, got {type(value).__name__}" + raise TypeError(msg) + if value <= 0: + msg = f"reconfigure setting {key!r} must be greater than zero, got {value}" + raise ValueError(msg) + validated[key] = value + return validated + + def normalize_camera_class(camera_class: type | str) -> str: """Normalize a camera class reference to its public import path. @@ -169,9 +220,9 @@ def to_json_dict(self) -> dict[str, Any]: def from_json_dict(cls, data: dict[str, Any]) -> CameraPublisherConfig: """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). + Uses :func:`validate_publisher_config` so publisher stdin requires + ``camera``, accepts only known transport keys, and rejects unknown + keys before construction. Args: data: Dictionary produced by :meth:`to_json_dict` or a publisher diff --git a/src/physicalai/config/_export.py b/src/physicalai/config/_export.py index b554c25..2067bad 100644 --- a/src/physicalai/config/_export.py +++ b/src/physicalai/config/_export.py @@ -21,6 +21,7 @@ _EXPORT_DEPTH_ATTR, _EXPORT_MARKER_ATTR, _MAX_CONFIG_DEPTH, + _NORMALIZE_CAPTURED_INIT_ARGS_ATTR, ComponentConfig, JsonValue, ) @@ -318,6 +319,12 @@ def wrapped_init(self: object, *args: object, **kwargs: object) -> None: original_init(self, *args, **kwargs) # Only the outermost successful decorated constructor commits. if getattr(self, _EXPORT_DEPTH_ATTR, 0) == 1: + normalize_captured = getattr(self, _NORMALIZE_CAPTURED_INIT_ARGS_ATTR, None) + if normalize_captured is not None: + if not callable(normalize_captured): + msg = f"{cls.__qualname__}: {_NORMALIZE_CAPTURED_INIT_ARGS_ATTR} must be callable" + raise TypeError(msg) + normalize_captured(supplied) setattr(self, _CAPTURED_INIT_ARGS_ATTR, supplied) finally: setattr(self, _EXPORT_DEPTH_ATTR, depth) diff --git a/src/physicalai/config/_instantiate.py b/src/physicalai/config/_instantiate.py index 8d8e683..70f5be9 100644 --- a/src/physicalai/config/_instantiate.py +++ b/src/physicalai/config/_instantiate.py @@ -5,7 +5,9 @@ from __future__ import annotations +import math from collections.abc import Mapping +from enum import Enum from typing import cast from ._errors import ComponentConfigError, ComponentImportError @@ -19,6 +21,126 @@ def _is_nested_config(value: object) -> bool: return isinstance(value, Mapping) and "class_path" in value +def _check_preflight_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 _preflight_config( + config: object, + *, + path: str, + depth: int, + seen: set[int], +) -> None: + """Validate a complete component subtree before any class import. + + Raises: + ComponentConfigError: If the config tree is malformed. + """ + _check_preflight_depth(path, depth) + + validated = validate_component_config(config, path=path) + config_id = id(config) + if config_id in seen: + msg = f"{format_path(path)}: cyclic component config is not instantiable" + raise ComponentConfigError(msg) + seen.add(config_id) + try: + 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}" + _preflight_value(item, path=child_path, depth=depth + 1, seen=seen) + finally: + seen.discard(config_id) + + +def _preflight_mapping( + value: dict[object, object], + *, + path: str, + depth: int, + seen: set[int], +) -> None: + if "class_path" in value: + _preflight_config(value, path=path, depth=depth, seen=seen) + return + + value_id = id(value) + if value_id in seen: + msg = f"{format_path(path)}: cyclic mapping is not instantiable" + raise ComponentConfigError(msg) + seen.add(value_id) + try: + 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 + _preflight_value(item, path=child_path, depth=depth + 1, seen=seen) + finally: + seen.discard(value_id) + + +def _preflight_list( + value: list[object], + *, + path: str, + depth: int, + seen: set[int], +) -> None: + value_id = id(value) + if value_id in seen: + msg = f"{format_path(path)}: cyclic sequence is not instantiable" + raise ComponentConfigError(msg) + seen.add(value_id) + try: + for index, item in enumerate(value): + child_path = f"{path}[{index}]" if path else f"[{index}]" + _preflight_value(item, path=child_path, depth=depth + 1, seen=seen) + finally: + seen.discard(value_id) + + +def _preflight_value( + value: object, + *, + path: str, + depth: int, + seen: set[int], +) -> None: + """Validate one constructor value against the recursive JSON model. + + Raises: + ComponentConfigError: If the value is outside the JSON model. + """ + _check_preflight_depth(path, depth) + + if isinstance(value, Enum): + msg = f"{format_path(path)}: Enum is not a JSON-compatible component config value; pass its value instead" + raise ComponentConfigError(msg) + if value is None or isinstance(value, (bool, str)): + return + if isinstance(value, int): + return + 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 + + if isinstance(value, dict): + _preflight_mapping(value, path=path, depth=depth, seen=seen) + return + + if isinstance(value, list): + _preflight_list(value, path=path, depth=depth, seen=seen) + return + + msg = f"{format_path(path)}: {type(value).__name__} is not a JSON-compatible component config value" + raise ComponentConfigError(msg) + + def _decode_value( value: object, *, @@ -140,4 +262,5 @@ def instantiate(config: ComponentConfig | Mapping[str, JsonValue]) -> object: Returns: A new instance of the configured class. """ + _preflight_config(config, path="", depth=0, seen=set()) return _instantiate_impl(config, path="", depth=0, seen=set()) diff --git a/src/physicalai/config/_types.py b/src/physicalai/config/_types.py index 4563a45..d1326f6 100644 --- a/src/physicalai/config/_types.py +++ b/src/physicalai/config/_types.py @@ -35,6 +35,7 @@ def to_config_value(self) -> JsonValue: _CAPTURED_INIT_ARGS_ATTR = "_physicalai_captured_init_args" _EXPORT_DEPTH_ATTR = "_physicalai_export_config_depth" _EXPORT_MARKER_ATTR = "_physicalai_export_config" +_NORMALIZE_CAPTURED_INIT_ARGS_ATTR = "_physicalai_normalize_captured_init_args" # Optional public class_path override stored on the decorated __init__ wrapper. _CONFIG_CLASS_PATH_ATTR = "_physicalai_config_class_path" diff --git a/src/physicalai/robot/transport/_shared_robot.py b/src/physicalai/robot/transport/_shared_robot.py index 91a603b..ac85ce2 100644 --- a/src/physicalai/robot/transport/_shared_robot.py +++ b/src/physicalai/robot/transport/_shared_robot.py @@ -285,6 +285,12 @@ def __init__( self._latest: TransportObservation | None = None self._connected = False + @classmethod + def _physicalai_normalize_captured_init_args(cls, supplied: dict[str, object]) -> None: + robot = supplied.get("robot") + if isinstance(robot, Mapping): + supplied["robot"] = normalize_robot_config(robot) + @classmethod def from_config( cls, diff --git a/tests/unit/capture/test_camera_component_config.py b/tests/unit/capture/test_camera_component_config.py index dde0d42..b0063ee 100644 --- a/tests/unit/capture/test_camera_component_config.py +++ b/tests/unit/capture/test_camera_component_config.py @@ -135,6 +135,20 @@ def test_spawn_recipe_round_trip(self) -> None: assert "_connected" not in wire["init_args"] assert "_node" not in wire["init_args"] + def test_defining_module_camera_path_is_canonical_on_first_export(self) -> None: + from physicalai.capture import SharedCamera + + camera = SharedCamera( + camera={ + "class_path": "physicalai.capture.cameras.uvc._camera.UVCCamera", + "init_args": {"device": 0, "backend": "v4l2"}, + }, + ) + wire = _assert_construction_round_trip(camera) + nested = wire["init_args"]["camera"] + assert isinstance(nested, dict) + assert nested["class_path"] == "physicalai.capture.UVCCamera" + def test_attach_only_round_trip(self) -> None: from physicalai.capture import SharedCamera diff --git a/tests/unit/capture/test_transport.py b/tests/unit/capture/test_transport.py index 7627ce0..7aaa280 100644 --- a/tests/unit/capture/test_transport.py +++ b/tests/unit/capture/test_transport.py @@ -26,7 +26,11 @@ encode_frame, ) from physicalai.capture.transport._shared_camera import SharedCamera -from physicalai.capture.transport._spec import CameraPublisherConfig, derive_service_name +from physicalai.capture.transport._spec import ( + CameraPublisherConfig, + derive_service_name, + validate_reconfigure_request, +) from .conftest import FAKE_CAMERA_CLASS @@ -109,6 +113,122 @@ def test_to_json_dict_camera_shape(self) -> None: assert "service_name" not in payload["camera"]["init_args"] +class TestReconfigureRequest: + def test_accepts_partial_positive_integer_settings(self) -> None: + assert validate_reconfigure_request({ + "kind": "RECONFIGURE", + "settings": {"width": 640, "fps": 30}, + }) == {"width": 640, "fps": 30} + + @pytest.mark.parametrize("value", [True, 0, -1, 30.0, "30", None]) + def test_rejects_invalid_setting_values(self, value: object) -> None: + with pytest.raises((TypeError, ValueError), match="reconfigure setting"): + validate_reconfigure_request({ + "kind": "RECONFIGURE", + "settings": {"fps": value}, + }) + + @pytest.mark.parametrize( + "payload", + [ + {"kind": "RECONFIGURE", "settings": {}}, + {"kind": "RECONFIGURE", "settings": {"device": 1}}, + {"kind": "RECONFIGURE", "settings": {"backend": 1}}, + {"kind": "RECONFIGURE", "settings": {"class_path": 1}}, + {"kind": "RECONFIGURE", "spec": {"camera": {}}}, + ], + ) + def test_rejects_empty_unknown_and_legacy_shapes(self, payload: dict[str, object]) -> None: + with pytest.raises((TypeError, ValueError), match="reconfigure"): + validate_reconfigure_request(payload) + + def test_worker_patches_only_trusted_camera_settings(self) -> None: + from physicalai.capture.transport._publisher_worker import _PublisherState, _handle_reconfigure + + old_camera = MagicMock() + old_camera.disconnect.return_value = None + replacement = MagicMock() + state = _PublisherState( + camera=old_camera, + publisher=MagicMock(), + camera_fps=30, + config={ + "camera": { + "class_path": FAKE_CAMERA_CLASS, + "init_args": { + "device_name": "trusted-device", + "backend": "trusted-backend", + "width": 320, + "height": 240, + "fps": 30, + }, + }, + "service_name": "physicalai/test/trusted/frame", + "_factory_override": "tests.unit.capture.fake:FakeCamera", + }, + ) + with patch( + "physicalai.capture.transport._publisher_worker.build_camera", + return_value=replacement, + ) as build: + result = _handle_reconfigure( + state, + {"kind": "RECONFIGURE", "settings": {"width": 640, "fps": 15}}, + "physicalai/test/trusted/frame", + ) + + assert result == {"ok": True} + new_config = build.call_args.args[0] + assert new_config["camera"] == { + "class_path": FAKE_CAMERA_CLASS, + "init_args": { + "device_name": "trusted-device", + "backend": "trusted-backend", + "width": 640, + "height": 240, + "fps": 15, + }, + } + assert new_config["_factory_override"] == "tests.unit.capture.fake:FakeCamera" + old_camera.disconnect.assert_called_once_with() + replacement.connect.assert_called_once_with() + assert state.camera is replacement + assert state.camera_fps == 15 + + def test_worker_rejects_component_payload_before_side_effects(self) -> None: + from physicalai.capture.transport._publisher_worker import _PublisherState, _handle_reconfigure + + camera = MagicMock() + state = _PublisherState( + camera=camera, + publisher=MagicMock(), + camera_fps=30, + config={ + "camera": {"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 320}}, + "service_name": "physicalai/test/trusted/frame", + }, + ) + with patch("physicalai.capture.transport._publisher_worker.build_camera") as build: + result = _handle_reconfigure( + state, + { + "kind": "RECONFIGURE", + "spec": { + "camera": { + "class_path": "subprocess.Popen", + "init_args": {"args": ["touch", "/tmp/unsafe"]}, + }, + }, + }, + "physicalai/test/trusted/frame", + ) + + assert result["ok"] is False + assert "invalid reconfigure request" in result["error"] + camera.disconnect.assert_not_called() + build.assert_not_called() + + class TestFrameHeader: def test_sizeof_is_44(self) -> None: diff --git a/tests/unit/config/test_component_config.py b/tests/unit/config/test_component_config.py index d532c92..8db047d 100644 --- a/tests/unit/config/test_component_config.py +++ b/tests/unit/config/test_component_config.py @@ -11,6 +11,7 @@ from enum import Enum from pathlib import Path from typing import Protocol, cast, runtime_checkable +from unittest.mock import MagicMock, patch import pytest @@ -150,6 +151,20 @@ def __init__(self, name: str | None = "default") -> None: self.name = name +@export_config +class CanonicalName: + normalize_calls = 0 + + def __init__(self, name: str = "default") -> None: + self.name = name + + @classmethod + def _physicalai_normalize_captured_init_args(cls, supplied: dict[str, object]) -> None: + cls.normalize_calls += 1 + if "name" in supplied: + supplied["name"] = str(supplied["name"]).lower() + + @export_config class Boom: def __init__(self, x: int) -> None: @@ -411,6 +426,69 @@ def test_constructor_failure_propagates_with_note(self) -> None: notes = getattr(info.value, "__notes__", []) assert any("constructor failed" in note for note in notes) + def test_malformed_nested_config_rejected_before_any_import(self) -> None: + config = { + "class_path": "tests.unit.config.test_component_config.Point", + "init_args": { + "x": { + "class_path": "tests.unit.config.test_component_config.Point", + "init_args": None, + }, + }, + } + with ( + patch("physicalai.config._instantiate.import_dotted_path") as import_path, + pytest.raises(ComponentConfigError, match="init_args"), + ): + instantiate(config) # type: ignore[arg-type] + import_path.assert_not_called() + + @pytest.mark.parametrize("value", [math.nan, math.inf, (1, 2), Path("config.json"), Color.RED, object()]) + def test_non_json_value_rejected_before_any_import(self, value: object) -> None: + config = { + "class_path": "tests.unit.config.test_component_config.DomainHolder", + "init_args": {"payload": value}, + } + with ( + patch("physicalai.config._instantiate.import_dotted_path") as import_path, + pytest.raises(ComponentConfigError), + ): + instantiate(config) # type: ignore[arg-type] + import_path.assert_not_called() + + def test_non_string_plain_mapping_key_rejected_before_any_import(self) -> None: + config = { + "class_path": "tests.unit.config.test_component_config.MappingHolder", + "init_args": {"data": {1: "value"}}, + } + with ( + patch("physicalai.config._instantiate.import_dotted_path") as import_path, + pytest.raises(ComponentConfigError, match="mapping keys must be strings"), + ): + instantiate(config) # type: ignore[arg-type] + import_path.assert_not_called() + + def test_cyclic_component_config_rejected_before_any_import(self) -> None: + config: dict[str, object] = { + "class_path": "tests.unit.config.test_component_config.Nest", + "init_args": {}, + } + cast("dict[str, object]", config["init_args"])["child"] = config + with ( + patch("physicalai.config._instantiate.import_dotted_path") as import_path, + pytest.raises(ComponentConfigError, match="cyclic component config"), + ): + instantiate(config) # type: ignore[arg-type] + import_path.assert_not_called() + + def test_plain_nested_mapping_remains_valid(self) -> None: + config = cast("ComponentConfig", { + "class_path": "tests.unit.config.test_component_config.MappingHolder", + "init_args": {"data": {"nested": {"value": 1}}}, + }) + restored = cast(MappingHolder, instantiate(config)) + assert restored.data == {"nested": {"value": 1}} + class TestExportConfig: def test_positional_binds_to_names(self) -> None: @@ -542,6 +620,14 @@ def test_depth_attr_cleaned_after_construction(self) -> None: assert is_config_exportable(point) assert "_physicalai_export_config_depth" not in vars(point) + def test_private_capture_normalizer_canonicalizes_supplied_args_only(self) -> None: + CanonicalName.normalize_calls = 0 + explicit = CanonicalName("LOUD") + omitted = CanonicalName() + assert to_config(explicit)["init_args"] == {"name": "loud"} + assert to_config(omitted)["init_args"] == {} + assert CanonicalName.normalize_calls == 2 + class TestScalarVarKwargs: def test_scalar_var_kwargs_round_trip(self) -> None: diff --git a/tests/unit/robot/test_robot_component_config.py b/tests/unit/robot/test_robot_component_config.py index 6c0a6b0..9666d59 100644 --- a/tests/unit/robot/test_robot_component_config.py +++ b/tests/unit/robot/test_robot_component_config.py @@ -257,6 +257,24 @@ def test_spawn_recipe_round_trip(self) -> None: assert "_connected" not in wire["init_args"] assert "session" not in wire["init_args"] + def test_defining_module_robot_path_is_canonical_on_first_export(self) -> None: + from physicalai.robot import SharedRobot + + robot = SharedRobot( + "canonical-arm", + robot={ + "class_path": "physicalai.robot.so101.so101.SO101", + "init_args": { + "port": "/dev/ttyUSB0", + "calibration": SAMPLE_CALIBRATION, + }, + }, + ) + wire = _assert_construction_round_trip(robot) + nested = wire["init_args"]["robot"] + assert isinstance(nested, dict) + assert nested["class_path"] == "physicalai.robot.SO101" + def test_attach_only_round_trip(self) -> None: from physicalai.robot import SharedRobot From fcbeb987a39b60336a1021cd85a63a688151a19f Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Sat, 25 Jul 2026 23:25:40 +0200 Subject: [PATCH 19/21] feat(config): pass declared config args through instantiate Adds @export_config(config_args=...) so a class can declare init args it consumes as ComponentConfig *data*. instantiate() hands those mappings over undecoded instead of building the nested component and letting the owner immediately re-serialize it. SharedRobot and SharedCamera declare robot/camera this way, which removes the build-then-unbuild round trip, the live-instance coercion helpers, and the is_connected property-vs-method asymmetry between the two protocols. Both constructors now take a ComponentConfig mapping only; a caller holding a live driver converts it explicitly with to_config(driver). --- docs/development/component-config.md | 61 +++++++-------- docs/explanation/cameras.md | 2 - docs/how-to/runtime/share-a-robot.md | 1 - .../capture/transport/_shared_camera.py | 78 +++++-------------- src/physicalai/config/_envelope.py | 4 + src/physicalai/config/_export.py | 64 ++++++++++++++- src/physicalai/config/_instantiate.py | 12 ++- src/physicalai/config/_types.py | 3 + .../robot/transport/_shared_robot.py | 66 ++++------------ .../capture/test_camera_component_config.py | 27 ++++--- tests/unit/capture/test_transport.py | 28 +------ tests/unit/config/test_component_config.py | 55 +++++++++++++ .../unit/robot/test_robot_component_config.py | 38 +++++---- .../unit/robot/transport/test_shared_robot.py | 26 +------ 14 files changed, 245 insertions(+), 220 deletions(-) diff --git a/docs/development/component-config.md b/docs/development/component-config.md index 5aa9730..2bfc6e7 100644 --- a/docs/development/component-config.md +++ b/docs/development/component-config.md @@ -626,30 +626,24 @@ for `RobotRuntime` capture in v1. Keep construction and transport ownership separate: ```python -SharedRobot(name="follower", robot=driver) # live disconnected driver +SharedRobot(name="follower", robot={"class_path": ..., "init_args": {...}}) SharedRobot.from_config(to_config(robot), name="follower") # trusted recipe (Studio API) ``` The `SharedRobot` constructor (`robot=`) and `SharedRobot.from_config()` are the two entry points; there is no `from_robot()` sugar. `from_config()` is the -named, Studio-facing API for a trusted `ComponentConfig`. The constructor -additionally accepts a live, **disconnected** `@export_config` driver and -converts it via `to_config()`. It requires `is_config_exportable(robot)`, -rejects a connected driver via `robot.is_connected()`, and never disconnects a -caller-owned live driver implicitly. Studio builders must return disconnected -drivers for wrapping, or explicitly release a driver they own first. - -**Coercion contract.** The live-driver path exists because `instantiate()` is -domain-agnostic: for a nested `robot={class_path, init_args}` it builds the -inner driver into a **live** instance first, then calls -`SharedRobot(robot=)`, which re-serializes it with `to_config()`. -This build-then-unbuild round-trip is intentional and harmless — construction -never opens the hardware (construction ≠ `connect()`), so no device is touched. - -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. +named, Studio-facing API for a trusted `ComponentConfig`. Both take a +`ComponentConfig` mapping only. A caller holding a live driver converts it +explicitly with `to_config(driver)`. + +**Declared config args.** `robot` is declared via +`@export_config(config_args=("robot",))`. `instantiate()` treats a declared +config arg as **data**: for a nested `robot={class_path, init_args}` it passes +the mapping straight through instead of building the inner driver and handing +it to `SharedRobot`. This removes a build-then-unbuild round-trip, keeps the +subscriber process from importing the driver package at all, and removes the +need for a connected-state guard on the constructor. `SharedCamera` declares +`camera` the same way. ### Private startup envelopes (hard cutover) @@ -742,9 +736,8 @@ object. - **`SharedRobot` constructor:** accept `robot: ComponentConfig` to spawn, or `robot=None` for attach-only (prefer :meth:`SharedRobot.attach`). Prefer `SharedRobot.from_config(...)` when building from a trusted config. - Nested `instantiate()` may pass a live `@export_config` driver for - `robot=`; the constructor converts it to a ComponentConfig and rejects - connected drivers. + `robot` is declared via `@export_config(config_args=("robot",))`, so nested + `instantiate()` passes the recipe through instead of constructing a 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 @@ -779,11 +772,9 @@ capture publisher / iceoryx2 node / frame / connected state. `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. Nested - `instantiate()` may pass a live **disconnected** `@export_config` camera for - `camera=`; convert to ComponentConfig and reject connected cameras. There is - no `from_camera()` sugar — the constructor's coercion covers the - live-instance case (same build-then-unbuild contract as `SharedRobot`). + (rejected before import) — not a dual adapter API. `camera` is a declared + `config_args` argument, so nested `instantiate()` passes the recipe through + without constructing a camera. There is no `from_camera()` sugar. - **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 @@ -1025,11 +1016,11 @@ cutovers; do not describe them as schema-preserving. spawn without explicit `service_name` fails before publisher start; the publisher envelope carries a concrete `service_name` not inside `init_args`. -- The `SharedCamera` constructor coerces a live **disconnected** `@export_config` - camera via `to_config(...)`, requires exportability, and rejects connected - cameras before publisher spawn (no `from_camera()` sugar); public ctor and - publisher stdin reject flat `camera_type` / `camera_kwargs` as unsupported - (not a dual API). +- The `SharedCamera` constructor takes a `ComponentConfig` mapping only; + `camera` is declared via `config_args`, so a nested `instantiate()` does not + construct the camera in the subscriber process (no `from_camera()` sugar); + 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 @@ -1039,9 +1030,9 @@ cutovers; do not describe them as schema-preserving. defining-module paths normalize to public re-exports before store/advertise/compare; metadata `robot_class` equals that public `robot["class_path"]`. -- The `SharedRobot` constructor coerces a live **disconnected** `@export_config` - driver via `to_config(...)`, requires exportability, and rejects connected - drivers before owner spawn (no `from_robot()` sugar). +- The `SharedRobot` constructor takes a `ComponentConfig` mapping only; + `robot` is declared via `config_args`, so a nested `instantiate()` does not + construct the driver in the subscriber process (no `from_robot()` sugar). - Composite bimanual robots spawn as one owner; nested arm configs round-trip under the composite. - The exact PolicySource nested graph (including smoothers) round-trips; diff --git a/docs/explanation/cameras.md b/docs/explanation/cameras.md index e6d2682..c06fb5a 100644 --- a/docs/explanation/cameras.md +++ b/docs/explanation/cameras.md @@ -52,8 +52,6 @@ shared = SharedCamera.from_config( ) # Equivalent after to_config(disconnected_driver): # shared = SharedCamera.from_config(to_config(driver)) -# Or let the constructor export the disconnected driver: -# shared = SharedCamera(camera=driver) shared.connect() # Safe to read from multiple threads/processes diff --git a/docs/how-to/runtime/share-a-robot.md b/docs/how-to/runtime/share-a-robot.md index 3f6b11b..bf6788f 100644 --- a/docs/how-to/runtime/share-a-robot.md +++ b/docs/how-to/runtime/share-a-robot.md @@ -33,7 +33,6 @@ driver = SO101( calibration="~/.cache/calibration/so101.json", # path stays relative/as given ) robot = SharedRobot.from_config(to_config(driver), name="left-arm") -# or: SharedRobot("left-arm", robot=driver) # or: SharedRobot("left-arm", robot={"class_path": "physicalai.robot.SO101", "init_args": {...}}) robot.connect() diff --git a/src/physicalai/capture/transport/_shared_camera.py b/src/physicalai/capture/transport/_shared_camera.py index 28d3e50..285bec4 100644 --- a/src/physicalai/capture/transport/_shared_camera.py +++ b/src/physicalai/capture/transport/_shared_camera.py @@ -4,12 +4,14 @@ """Shared-memory camera subscriber transport based on iceoryx2. Construction is :class:`~physicalai.config.ComponentConfig`-only via -``camera=`` or :meth:`SharedCamera.from_config`; a disconnected -``@export_config`` camera instance is also accepted and converted via -:func:`~physicalai.config.to_config`. 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. +``camera=`` or :meth:`SharedCamera.from_config`. ``camera`` is declared as an +``@export_config(config_args=...)`` argument, so a nested +:func:`~physicalai.config.instantiate` hands over the recipe instead of +building a camera this process would immediately discard. 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 @@ -39,44 +41,6 @@ _SERVICE_NAME_EXPECTED_PARTS = 5 -def _coerce_camera_recipe(camera: object) -> ComponentConfig | Mapping[str, object]: - """Accept a ComponentConfig mapping or a 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 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 " - "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*. @@ -122,7 +86,7 @@ def _probe_with_retry(service_name: str, timeout: float, interval: float = 0.1) time.sleep(interval) -@export_config(class_path="physicalai.capture.SharedCamera") +@export_config(class_path="physicalai.capture.SharedCamera", config_args=("camera",)) class SharedCamera(Camera): """Camera subscriber that reads frames from shared memory via iceoryx2. @@ -131,10 +95,9 @@ class SharedCamera(Camera): for zero-copy fan-out. Prefer :meth:`from_config` when sharing; never keep a direct camera open - while sharing. The constructor takes ``camera: ComponentConfig`` (or a - disconnected ``@export_config`` camera) to spawn, or - ``camera=None`` + ``service_name`` for attach-only (:meth:`from_publisher` - is the explicit form). + while sharing. The constructor takes ``camera: ComponentConfig`` to spawn, + or ``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``, @@ -148,10 +111,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. A live - ``@export_config`` camera (as produced by nested - :func:`~physicalai.config.instantiate`) is accepted and converted - to a ComponentConfig; connected cameras are rejected. + ``service_name``. ``None`` means attach-only. Declared as an + ``@export_config`` config arg, so nested + :func:`~physicalai.config.instantiate` passes the recipe through + without constructing the camera here. 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. @@ -188,16 +151,15 @@ def __init__( msg = "must provide camera ComponentConfig or service_name" raise ValueError(msg) - 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) + recipe = None if camera is None else normalize_camera_config(camera) + if recipe is not None: + service_name = derive_service_name(recipe, service_name=service_name) elif service_name is None: msg = "service_name must be provided if camera is None" raise ValueError(msg) super().__init__(color_mode=color_mode) - self._camera = normalized + self._camera = recipe self._service_name: str = service_name self._zero_copy = zero_copy self._validate_on_connect = validate_on_connect diff --git a/src/physicalai/config/_envelope.py b/src/physicalai/config/_envelope.py index a525126..741635e 100644 --- a/src/physicalai/config/_envelope.py +++ b/src/physicalai/config/_envelope.py @@ -138,9 +138,13 @@ def normalize_component_config( A validated config whose ``class_path`` is the public import path. Raises: + ComponentConfigError: If *config* is not a mapping. ValueError: If ``class_path`` cannot be imported or ``init_args`` is not JSON-serializable. """ + if not isinstance(config, Mapping): + msg = f"{component_key} must be a ComponentConfig mapping, got {type(config).__name__}" + raise ComponentConfigError(msg) 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"] diff --git a/src/physicalai/config/_export.py b/src/physicalai/config/_export.py index 2067bad..5a6ac0b 100644 --- a/src/physicalai/config/_export.py +++ b/src/physicalai/config/_export.py @@ -17,6 +17,7 @@ from ._path import format_path from ._types import ( _CAPTURED_INIT_ARGS_ATTR, + _CONFIG_ARGS_ATTR, _CONFIG_CLASS_PATH_ATTR, _EXPORT_DEPTH_ATTR, _EXPORT_MARKER_ATTR, @@ -28,7 +29,7 @@ from .importing import import_dotted_path if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Sequence _T = TypeVar("_T", bound=type) @@ -86,6 +87,20 @@ def is_config_exportable(value: object) -> bool: return _has_export_marker(init) +def declared_config_args(cls: type) -> frozenset[str]: + """Return init-arg names *cls* consumes as ComponentConfig data. + + Declared via ``@export_config(config_args=...)``. :func:`instantiate` + passes these arguments through as plain mappings instead of building the + nested component, so the component receives the recipe it needs to hand to + another process rather than a live object it would immediately discard. + + Returns: + The declared argument names, or an empty set when none are declared. + """ + return getattr(cls.__init__, _CONFIG_ARGS_ATTR, frozenset()) + + def _class_path_override(cls: type) -> str | None: """Return the decorator ``class_path=`` only when *cls* owns the decorated ``__init__``. @@ -257,11 +272,45 @@ def _flatten_var_kwargs( supplied[key] = snapshot_captured_value(value) +def _validate_config_args( + cls: type, + signature: inspect.Signature, + config_args: Sequence[str], + var_kw_name: str | None, +) -> frozenset[str]: + """Check that every declared config arg is a real keyword parameter. + + Returns: + The validated names. + + Raises: + TypeError: If a name is not a string, is not declared by ``__init__``, + or names the ``**kwargs`` parameter. + """ + names: set[str] = set() + for name in config_args: + if not isinstance(name, str) or not name: + msg = f"@export_config on {cls.__qualname__}: config_args entries must be non-empty strings" + raise TypeError(msg) + if name == var_kw_name: + msg = ( + f"@export_config on {cls.__qualname__}: config_args cannot name the " + f"**{name} parameter; declare the individual arguments instead" + ) + raise TypeError(msg) + if name not in signature.parameters or name == "self": + msg = f"@export_config on {cls.__qualname__}: config_args {name!r} is not an __init__ parameter" + raise TypeError(msg) + names.add(name) + return frozenset(names) + + def _decorate_export_config( cls: _T, *, class_path: str | None, scalar_var_kwargs: bool, + config_args: Sequence[str] | None, ) -> _T: if not isinstance(cls, type): msg = f"@export_config expects a class, got {type(cls).__name__}" @@ -299,6 +348,8 @@ def _decorate_export_config( msg = f"@export_config on {cls.__qualname__}: scalar_var_kwargs=True requires a **kwargs parameter" raise TypeError(msg) + declared = _validate_config_args(cls, signature, config_args or (), var_kw_name) + @functools.wraps(original_init) def wrapped_init(self: object, *args: object, **kwargs: object) -> None: bound = signature.bind(self, *args, **kwargs) @@ -334,6 +385,8 @@ def wrapped_init(self: object, *args: object, **kwargs: object) -> None: setattr(wrapped_init, _EXPORT_MARKER_ATTR, True) if class_path is not None: setattr(wrapped_init, _CONFIG_CLASS_PATH_ATTR, class_path) + if declared: + setattr(wrapped_init, _CONFIG_ARGS_ATTR, declared) 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] @@ -349,6 +402,7 @@ def export_config( *, class_path: str | None = None, scalar_var_kwargs: bool = False, + config_args: Sequence[str] | None = None, ) -> Callable[[_T], _T]: ... @@ -358,6 +412,7 @@ def export_config( *, class_path: str | None = None, scalar_var_kwargs: bool = False, + config_args: Sequence[str] | None = None, ) -> _T | Callable[[_T], _T]: """Opt a concrete class into constructor-config export via :func:`to_config`. @@ -409,6 +464,11 @@ class InferenceModel: ... 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`. + config_args: Init-arg names the class consumes as ComponentConfig + *data*. :func:`instantiate` passes these through as plain mappings + instead of constructing the nested component — use it for spawn + recipes that must cross a process boundary (for example + ``SharedCamera(camera=...)``). Returns: The decorated class, or a decorator when keyword options are passed. @@ -418,6 +478,7 @@ class InferenceModel: ... cls, class_path=class_path, scalar_var_kwargs=scalar_var_kwargs, + config_args=config_args, ) def decorator(target: _T) -> _T: @@ -425,6 +486,7 @@ def decorator(target: _T) -> _T: target, class_path=class_path, scalar_var_kwargs=scalar_var_kwargs, + config_args=config_args, ) return decorator diff --git a/src/physicalai/config/_instantiate.py b/src/physicalai/config/_instantiate.py index 70f5be9..5f665c8 100644 --- a/src/physicalai/config/_instantiate.py +++ b/src/physicalai/config/_instantiate.py @@ -11,6 +11,7 @@ from typing import cast from ._errors import ComponentConfigError, ComponentImportError +from ._export import declared_config_args from ._normalize import validate_component_config from ._path import format_path from ._types import _MAX_CONFIG_DEPTH, ComponentConfig, JsonValue @@ -227,9 +228,15 @@ def _instantiate_impl( validated = validate_component_config(config, path=path) cls = _resolve_class(validated["class_path"], path=path) + config_args = declared_config_args(cls) decoded_args: dict[str, object] = {} for key, item in validated["init_args"].items(): + if key in config_args: + # Declared ComponentConfig data: hand the recipe over untouched so + # nothing nested is constructed in this process. + decoded_args[key] = item + continue 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) @@ -246,7 +253,10 @@ def instantiate(config: ComponentConfig | Mapping[str, JsonValue]) -> object: 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. + arguments. Init args a class declares via + ``@export_config(config_args=...)`` are passed through as plain mappings + instead of being constructed. 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. diff --git a/src/physicalai/config/_types.py b/src/physicalai/config/_types.py index d1326f6..f727b33 100644 --- a/src/physicalai/config/_types.py +++ b/src/physicalai/config/_types.py @@ -38,5 +38,8 @@ def to_config_value(self) -> JsonValue: _NORMALIZE_CAPTURED_INIT_ARGS_ATTR = "_physicalai_normalize_captured_init_args" # Optional public class_path override stored on the decorated __init__ wrapper. _CONFIG_CLASS_PATH_ATTR = "_physicalai_config_class_path" +# Init-arg names the component consumes as ComponentConfig *data* rather than as +# a constructed object. instantiate() passes these through undecoded. +_CONFIG_ARGS_ATTR = "_physicalai_config_args" _REPR_LIMIT = 80 diff --git a/src/physicalai/robot/transport/_shared_robot.py b/src/physicalai/robot/transport/_shared_robot.py index ac85ce2..c943e8d 100644 --- a/src/physicalai/robot/transport/_shared_robot.py +++ b/src/physicalai/robot/transport/_shared_robot.py @@ -10,10 +10,11 @@ *name*, anywhere reachable) attach. Construction is :class:`~physicalai.config.ComponentConfig`-only via -``robot=`` or :meth:`from_config`; a disconnected ``@export_config`` driver -instance is also accepted and converted via -:func:`~physicalai.config.to_config`. Pass ``robot=None`` (or use -:meth:`attach`) for attach-only. +``robot=`` or :meth:`from_config`. ``robot`` is declared as an +``@export_config(config_args=...)`` argument, so a nested +:func:`~physicalai.config.instantiate` hands over the recipe instead of +building a driver this process would immediately discard. 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 @@ -58,45 +59,6 @@ from physicalai.robot import RobotObservation -def _coerce_robot_recipe(robot: object) -> ComponentConfig | Mapping[str, object]: - """Accept a ComponentConfig mapping or a 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 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 " - "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 @@ -216,7 +178,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") +@export_config(class_path="physicalai.robot.SharedRobot", config_args=("robot",)) class SharedRobot: """Robot subscriber that attaches to (or spawns) a shared owner process. @@ -226,9 +188,8 @@ class SharedRobot: is a drop-in replacement for a direct driver. Prefer :meth:`from_config`. The constructor takes - ``robot: ComponentConfig`` (or a disconnected ``@export_config`` driver) - to spawn, or ``robot=None`` (attach-only; :meth:`attach` is the explicit - form). + ``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). @@ -241,10 +202,10 @@ class SharedRobot: 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. A live - ``@export_config`` driver (as produced by nested - :func:`~physicalai.config.instantiate`) is accepted and converted - to a ComponentConfig; connected drivers are rejected. + attach-only — use :meth:`attach` for that case. Declared as an + ``@export_config`` config arg, so nested + :func:`~physicalai.config.instantiate` passes the recipe through + without constructing the driver here. 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, @@ -268,8 +229,7 @@ def __init__( _session: object | None = None, ) -> None: self._name = validate_name(name) - recipe = None if robot is None else _coerce_robot_recipe(robot) - self._robot = None if recipe is None else normalize_robot_config(recipe) + 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 diff --git a/tests/unit/capture/test_camera_component_config.py b/tests/unit/capture/test_camera_component_config.py index b0063ee..9418d34 100644 --- a/tests/unit/capture/test_camera_component_config.py +++ b/tests/unit/capture/test_camera_component_config.py @@ -15,7 +15,7 @@ import pytest from physicalai.capture.camera import Camera, ColorMode -from physicalai.config import instantiate, is_config_exportable, to_config +from physicalai.config import ComponentConfig, instantiate, is_config_exportable, to_config def _assert_construction_round_trip(camera: object) -> dict[str, Any]: @@ -158,13 +158,20 @@ def test_attach_only_round_trip(self) -> None: assert wire["init_args"]["camera"] is None assert wire["init_args"]["service_name"] == "physicalai/camera/uvc/0/frame" - def test_constructor_recipe_still_rejects_connected(self) -> None: + def test_nested_recipe_is_not_instantiated(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="disconnected camera recipe"): - SharedCamera(camera=driver, service_name="physicalai/test/x/frame") - assert driver.is_connected + + config: ComponentConfig = { + "class_path": "physicalai.capture.SharedCamera", + "init_args": { + "camera": { + "class_path": "physicalai.capture.UVCCamera", + "init_args": {"device": 0, "backend": "v4l2"}, + }, + "service_name": "physicalai/camera/uvc/0/frame", + }, + } + restored = instantiate(config) + assert isinstance(restored, SharedCamera) + # The declared config arg stays a mapping — no UVCCamera is built here. + assert restored._camera == config["init_args"]["camera"] diff --git a/tests/unit/capture/test_transport.py b/tests/unit/capture/test_transport.py index 7aaa280..3ed40e5 100644 --- a/tests/unit/capture/test_transport.py +++ b/tests/unit/capture/test_transport.py @@ -31,6 +31,7 @@ derive_service_name, validate_reconfigure_request, ) +from physicalai.config import ComponentConfigError from .conftest import FAKE_CAMERA_CLASS @@ -417,33 +418,12 @@ def test_envelope_service_name_not_in_init_args(self) -> None: assert "service_name" not in cam._camera["init_args"] assert derive_service_name(cam._camera) == cam._service_name - def test_constructor_recipe_requires_exportable(self) -> None: - class _Bare: - @property - def is_connected(self) -> bool: - return False - - with pytest.raises(TypeError, match="config-exportable camera"): - SharedCamera(camera=_Bare()) # type: ignore[arg-type] - - def test_constructor_recipe_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="disconnected camera recipe"): - SharedCamera(camera=driver, service_name="physicalai/test/x/frame") - assert driver.is_connected - - def test_constructor_recipe_accepts_disconnected(self) -> None: + def test_constructor_requires_component_config_mapping(self) -> None: from tests.unit.capture.fake import FakeCamera driver = FakeCamera(width=32, height=32, device_name="d1") - cam = SharedCamera(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 + with pytest.raises(ComponentConfigError, match="camera must be a ComponentConfig mapping"): + SharedCamera(camera=driver, service_name="physicalai/test/x/frame") # type: ignore[arg-type] def test_third_party_build_bypasses_create_camera_registry(self) -> None: spec = CameraPublisherConfig( diff --git a/tests/unit/config/test_component_config.py b/tests/unit/config/test_component_config.py index 8db047d..2ceb6ce 100644 --- a/tests/unit/config/test_component_config.py +++ b/tests/unit/config/test_component_config.py @@ -121,6 +121,19 @@ def __init__(self, base: int, **kwargs: object) -> None: self.kwargs = kwargs +@export_config +class Leaf: + def __init__(self, value: int) -> None: + self.value = value + + +@export_config(config_args=("recipe",)) +class Holder: + def __init__(self, recipe: ComponentConfig, eager: object) -> None: + self.recipe = recipe + self.eager = eager + + @export_config class PathHolder: def __init__(self, path: str | Path) -> None: @@ -666,3 +679,45 @@ def test_scalar_var_kwargs_requires_var_keyword(self) -> None: class NoVarKwargs: def __init__(self, x: int) -> None: self.x = x + + +class TestConfigArgs: + def test_declared_config_arg_is_not_instantiated(self) -> None: + config: ComponentConfig = { + "class_path": f"{__name__}.Holder", + "init_args": { + "recipe": {"class_path": f"{__name__}.Leaf", "init_args": {"value": 3}}, + "eager": {"class_path": f"{__name__}.Leaf", "init_args": {"value": 3}}, + }, + } + holder = cast(Holder, instantiate(config)) + assert holder.recipe == config["init_args"]["recipe"] + assert isinstance(holder.eager, Leaf) + + def test_declared_config_arg_round_trips(self) -> None: + recipe: ComponentConfig = { + "class_path": f"{__name__}.Leaf", + "init_args": {"value": 3}, + } + holder = Holder(recipe=recipe, eager=Leaf(value=3)) + config = to_config(holder) + assert config["init_args"]["recipe"] == recipe + wire = json.loads(json.dumps(config)) + restored = cast(Holder, instantiate(wire)) + assert to_config(restored) == wire + + def test_unknown_config_arg_name_rejected(self) -> None: + with pytest.raises(TypeError, match="config_args 'missing' is not an __init__ parameter"): + + @export_config(config_args=("missing",)) + class Bad: + def __init__(self, x: int) -> None: + self.x = x + + def test_config_args_cannot_name_var_keyword(self) -> None: + with pytest.raises(TypeError, match=r"config_args cannot name the \*\*kwargs parameter"): + + @export_config(config_args=("kwargs",)) + class BadVarKw: + def __init__(self, **kwargs: object) -> None: + self.kwargs = kwargs diff --git a/tests/unit/robot/test_robot_component_config.py b/tests/unit/robot/test_robot_component_config.py index 9666d59..e9512fa 100644 --- a/tests/unit/robot/test_robot_component_config.py +++ b/tests/unit/robot/test_robot_component_config.py @@ -15,7 +15,7 @@ import pytest -from physicalai.config import instantiate, is_config_exportable, to_config +from physicalai.config import ComponentConfig, instantiate, is_config_exportable, to_config from physicalai.robot import Robot # SO101 imports scservo_sdk at module load; keep unit tests hardware-free. @@ -315,10 +315,14 @@ def test_from_config_omits_null_session(self) -> None: def test_constructor_recipe_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-arm", robot=driver) + robot = SharedRobot( + "from-robot-arm", + robot={ + "class_path": "tests.unit.robot.transport.fake.FakeRobot", + "init_args": {"port": "/dev/fake-from-robot", "device_ids": ["fake:follower"]}, + }, + ) wire = _assert_construction_round_trip(robot) assert "_session" not in wire["init_args"] @@ -348,13 +352,21 @@ def test_classmethod_live_session_still_fails_at_to_config(self) -> None: with pytest.raises(ComponentConfigError, match=r"init_args\._session"): to_config(robot) - def test_constructor_recipe_still_rejects_connected(self) -> None: + def test_nested_recipe_is_not_instantiated(self) -> None: + from physicalai.config import instantiate 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 recipe"): - SharedRobot("left-arm", robot=driver) - assert driver.is_connected() + + config: ComponentConfig = { + "class_path": "physicalai.robot.SharedRobot", + "init_args": { + "name": "nested-arm", + "robot": { + "class_path": "tests.unit.robot.transport.fake.FakeRobot", + "init_args": {"port": "/dev/fake-nested", "device_ids": ["fake:follower"]}, + }, + }, + } + restored = instantiate(config) + assert isinstance(restored, SharedRobot) + # The declared config arg stays a mapping — no driver is built here. + assert restored._robot == config["init_args"]["robot"] diff --git a/tests/unit/robot/transport/test_shared_robot.py b/tests/unit/robot/transport/test_shared_robot.py index b918f07..e046dbb 100644 --- a/tests/unit/robot/transport/test_shared_robot.py +++ b/tests/unit/robot/transport/test_shared_robot.py @@ -11,6 +11,7 @@ import numpy as np import pytest +from physicalai.config import ComponentConfigError from physicalai.robot.errors import ( RobotNameConflict, RobotNotConnectedError, @@ -100,29 +101,10 @@ def test_from_config_stores_component_config(self) -> None: ) assert robot._robot == {"class_path": FAKE_ROBOT_CLASS, "init_args": {"port": "/dev/fake0"}} - def test_constructor_recipe_requires_exportable(self) -> None: - class _Bare: - def is_connected(self) -> bool: - return False - - with pytest.raises(TypeError, match="config-exportable robot"): - SharedRobot("left-arm", robot=_Bare()) # type: ignore[arg-type] - - def test_constructor_recipe_rejects_connected_without_disconnect(self) -> None: - driver = FakeRobot(port="/dev/fake0") - driver.connect() - assert driver.is_connected() - with pytest.raises(ValueError, match="disconnected driver recipe"): - SharedRobot("left-arm", robot=driver) - assert driver.is_connected() - assert not driver.disconnect_called - - def test_constructor_recipe_accepts_disconnected_driver(self) -> None: + def test_constructor_requires_component_config_mapping(self) -> None: driver = FakeRobot(port="/dev/fake0", device_ids=("fake:/dev/fake0",)) - robot = SharedRobot("left-arm", robot=driver) - assert robot._robot is not None - assert robot._robot["class_path"] == FAKE_ROBOT_CLASS - assert robot._robot["init_args"]["port"] == "/dev/fake0" + with pytest.raises(ComponentConfigError, match="robot must be a ComponentConfig mapping"): + SharedRobot("left-arm", robot=driver) # type: ignore[arg-type] def test_satisfies_robot_protocol(self) -> None: from physicalai.robot import Robot From 8846e8f0a2a68dc93b0a52fefd0335cc8d1c5e5b Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Sat, 25 Jul 2026 23:37:42 +0200 Subject: [PATCH 20/21] fix(transport): stop importing driver class paths when building envelopes normalize_class_reference imported every string class_path so a defining-module path could be rewritten to its public re-export. That import runs in the subscriber process, which made SharedCamera/SharedRobot construction require the vendor SDK on machines that only attach to a publisher - realsense/_camera.py imports pyrealsense2 at module scope. The same problem sat in builtin.py, which imported all three driver modules behind an lru_cache to resolve the token map live. A string class_path is now trusted and stored exactly as written; only class objects resolve to a public path. Import errors surface where they belong, in the process that calls instantiate(). builtin.py becomes a static table listing every accepted spelling per backend, so one device still derives one service name however the config names its driver. --- docs/development/component-config.md | 45 ++++++---- src/physicalai/capture/transport/_spec.py | 21 ++--- src/physicalai/capture/transport/builtin.py | 83 +++++++++---------- src/physicalai/config/_envelope.py | 55 ++++++------ .../robot/transport/_owner_config.py | 12 +-- .../capture/test_camera_component_config.py | 9 +- tests/unit/capture/test_factory.py | 29 +++++++ tests/unit/capture/test_transport.py | 29 +++++++ .../unit/robot/test_robot_component_config.py | 8 +- .../unit/robot/transport/test_owner_config.py | 56 +++++++------ 10 files changed, 213 insertions(+), 134 deletions(-) diff --git a/docs/development/component-config.md b/docs/development/component-config.md index 2bfc6e7..2319808 100644 --- a/docs/development/component-config.md +++ b/docs/development/component-config.md @@ -534,13 +534,20 @@ Transport owns naming; `ComponentConfig` never embeds `service_name`. Spawn has no `camera_type` on the construction config. Rules: -- **Shareable built-in spawn:** a private map from public `class_path` to the +- **Shareable built-in spawn:** a private map from camera `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. +- **The map is a static table and must not import a driver module.** Since + string `class_path` values are stored as written, the table lists every + accepted spelling per backend — the public re-export (canonical, index 0), + the sub-package re-export, and the defining module — so one physical device + derives one service name however the config names its driver. A test + asserts the table still matches each driver's `@export_config(class_path=)` + whenever the optional camera extra is installed. - **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 @@ -738,21 +745,24 @@ object. Prefer `SharedRobot.from_config(...)` when building from a trusted config. `robot` is declared via `@export_config(config_args=("robot",))`, so nested `instantiate()` passes the recipe through instead of constructing a 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`). +- **Public path normalization:** a **class object** given for + `robot.class_path` normalizes through the same public-path resolution used + by `to_config` (decorator `class_path=` override, else + `__module__.__qualname__`). A **string** path is trusted and stored exactly + as written — it is deliberately not imported. Envelope construction happens + in the subscriber process, which must not load the driver package and often + cannot (the vendor SDK is only installed where the hardware lives). Import + errors surface in the process that calls `instantiate()`. +- **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 `robot["class_path"]`. Conflict checks compare + that string unchanged; because spellings are no longer canonicalized, a + defining-module path and a public re-export of the same driver compare as a + mismatch. That stays **diagnostic, not fatal** — the warning names both + strings and attach proceeds. - **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 @@ -1027,9 +1037,12 @@ cutovers; do not describe them as schema-preserving. `robot=` / `--robot` ComponentConfig (plus `from_config`); 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"]`. + a string `class_path` is stored exactly as written and metadata + `robot_class` equals that string. +- Building a transport envelope imports nothing: a subscriber constructs a + `SharedRobot` / `SharedCamera` and derives its service name on a machine + where the driver package (and its vendor SDK) is not installed. Only the + process that calls `instantiate()` imports the `class_path`. - The `SharedRobot` constructor takes a `ComponentConfig` mapping only; `robot` is declared via `config_args`, so a nested `instantiate()` does not construct the driver in the subscriber process (no `from_robot()` sugar). diff --git a/src/physicalai/capture/transport/_spec.py b/src/physicalai/capture/transport/_spec.py index 08f639e..383e1c6 100644 --- a/src/physicalai/capture/transport/_spec.py +++ b/src/physicalai/capture/transport/_spec.py @@ -52,8 +52,8 @@ def validate_publisher_config(data: Mapping[str, Any]) -> Mapping[str, object]: """Validate a trusted publisher stdin payload schema-positively. Returns: - The validated ``camera`` ComponentConfig mapping (not yet - public-path-normalized — :func:`normalize_camera_config` does that). + The validated ``camera`` ComponentConfig mapping (see + :func:`normalize_camera_config` for the JSON-serializability check). """ return validate_envelope( data, @@ -114,19 +114,19 @@ def validate_reconfigure_request(request: Mapping[str, Any]) -> dict[str, int]: def normalize_camera_class(camera_class: type | str) -> str: - """Normalize a camera class reference to its public import path. + """Normalize a camera class reference to a dotted import path. Returns: - The normalized public dotted path. + The public path for a class object, or the string path as given. """ 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``. + """Validate a camera ComponentConfig without importing its ``class_path``. Returns: - A validated config whose ``class_path`` is the public import path. + A validated config whose ``class_path`` is a dotted import path. """ return normalize_component_config( camera, @@ -142,10 +142,11 @@ def derive_service_name( ) -> 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*. + Built-in class paths derive ``physicalai/camera/{token}/{device_id}/frame`` + via the transport class-path → type-token map, which accepts both the + public re-export and the internal spelling of each built-in driver. + 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. diff --git a/src/physicalai/capture/transport/builtin.py b/src/physicalai/capture/transport/builtin.py index 827eefa..e0fff4e 100644 --- a/src/physicalai/capture/transport/builtin.py +++ b/src/physicalai/capture/transport/builtin.py @@ -7,68 +7,63 @@ ``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. + +The table is static on purpose: a subscriber process derives a service name +without the vendor SDK installed, so nothing here may import a driver +module. ``tests/unit/capture/test_factory.py`` asserts the table still +matches each driver's ``@export_config(class_path=...)`` when the optional +camera extras are available. """ 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]] = { +# token → accepted class paths. Index 0 is the canonical public path declared +# by ``@export_config(class_path=...)`` on the driver; the rest are the +# sub-package re-export and the defining module, so a hand-written config that +# spells a driver the internal way still derives the same service name instead +# of demanding an explicit one. +_BUILTIN_SHARED: dict[str, tuple[str, ...]] = { "uvc": ( - "physicalai.capture.cameras.uvc", - "UVCCamera", "physicalai.capture.UVCCamera", + "physicalai.capture.cameras.uvc.UVCCamera", + "physicalai.capture.cameras.uvc._camera.UVCCamera", ), "realsense": ( - "physicalai.capture.cameras.realsense", - "RealSenseCamera", "physicalai.capture.RealSenseCamera", + "physicalai.capture.cameras.realsense.RealSenseCamera", + "physicalai.capture.cameras.realsense._camera.RealSenseCamera", ), "basler": ( - "physicalai.capture.cameras.basler", - "BaslerCamera", "physicalai.capture.BaslerCamera", + "physicalai.capture.cameras.basler.BaslerCamera", + "physicalai.capture.cameras.basler._camera.BaslerCamera", ), } +_TOKEN_TO_CLASS_PATH: dict[str, str] = {token: paths[0] for token, paths in _BUILTIN_SHARED.items()} +_CLASS_PATH_TO_TOKEN: dict[str, str] = {path: token for token, paths in _BUILTIN_SHARED.items() for path in paths} -@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 +def builtin_shared_type_tokens() -> frozenset[str]: + """Return CameraType tokens that support shared service-name derivation.""" + return frozenset(_BUILTIN_SHARED) -@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_class_paths_for_type(token: str) -> tuple[str, ...]: + r"""Return every class_path spelling accepted for a built-in type token. + Args: + token: Lowercase ``CameraType`` value (e.g. ``\"uvc\"``). -def builtin_shared_type_tokens() -> frozenset[str]: - """Return CameraType tokens that support shared service-name derivation.""" - return frozenset(_BUILTIN_SHARED) + Returns: + Accepted paths with the canonical public one first, or an empty tuple + if the token is not a shareable built-in. + """ + return _BUILTIN_SHARED.get(token, ()) def builtin_class_path_for_type(token: str) -> str | None: - r"""Return the public class_path for a built-in shared camera type token. + r"""Return the canonical public class_path for a built-in shared camera type token. Args: token: Lowercase ``CameraType`` value (e.g. ``\"uvc\"``). @@ -76,17 +71,21 @@ def builtin_class_path_for_type(token: str) -> str | None: Returns: Public ``class_path``, or ``None`` if the token is not a shareable built-in. """ - return _token_to_class_path().get(token) + 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. + """Return the legacy type token for a built-in camera class_path. + + Accepts the canonical public path plus the internal spellings listed in + :data:`_BUILTIN_SHARED`, so the same physical device derives one service + name however the config names its driver. Args: - class_path: Normalized public camera ``class_path``. + class_path: Camera ``class_path`` as written in the config. Returns: Type token (``uvc`` / ``realsense`` / ``basler``), or ``None`` for third-party / stub / unknown paths. """ - return _class_path_to_token().get(class_path) + return _CLASS_PATH_TO_TOKEN.get(class_path) diff --git a/src/physicalai/config/_envelope.py b/src/physicalai/config/_envelope.py index 741635e..3e3aa0e 100644 --- a/src/physicalai/config/_envelope.py +++ b/src/physicalai/config/_envelope.py @@ -5,8 +5,12 @@ 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. +schema-positive validation in one place; transports supply their key names +and allowlists. + +Nothing here imports a ``class_path``. Envelopes are built in the subscriber +process, which must stay free of the driver package — the import happens in +the process that calls :func:`~physicalai.config.instantiate`. """ from __future__ import annotations @@ -18,7 +22,6 @@ 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 @@ -46,8 +49,8 @@ def validate_envelope( (for example ``"owner"`` or ``"publisher"``). Returns: - The validated ComponentConfig mapping (not yet public-path-normalized - — :func:`normalize_component_config` does that). + The validated ComponentConfig mapping (see + :func:`normalize_component_config` for the JSON-serializability check). Raises: TypeError: If *data* or the component value is not a mapping. @@ -79,36 +82,34 @@ def validate_envelope( def normalize_class_reference(ref: type | str, *, label: str) -> str: - """Normalize a class object or dotted path to its verified public path. + """Normalize a class object to its public path, or pass a dotted path through. + + A class object is resolved exactly like :func:`to_config` does: decorator + ``class_path=`` override when present, otherwise ``__module__.__qualname__``. - 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. + A string is trusted and returned **as given**. It is deliberately not + imported here: envelope construction happens in the subscriber process, + which must not load the driver package (and often cannot — the vendor SDK + is only installed where the hardware lives). Import errors surface later, + in the process that actually calls :func:`~physicalai.config.instantiate`. 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. + The 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). + TypeError: If *ref* is neither a string nor a class. + ValueError: If a string is not a dotted path, 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 ref.strip() or "." not in ref: + msg = f"{label} must be a nonempty dotted path, got {ref!r}" + raise ValueError(msg) + return ref if not isinstance(ref, type): msg = f"{label} must be a class or a dotted path string, got {type(ref).__name__}" raise TypeError(msg) @@ -126,7 +127,7 @@ def normalize_component_config( class_label: str, json_hint: str = "", ) -> ComponentConfig: - """Validate a ComponentConfig and normalize its ``class_path`` to the public path. + """Validate a ComponentConfig without importing its ``class_path``. Args: config: Candidate ``class_path`` + ``init_args`` mapping. @@ -135,11 +136,11 @@ def normalize_component_config( json_hint: Optional suffix appended to the JSON-serializability error. Returns: - A validated config whose ``class_path`` is the public import path. + A validated config whose ``class_path`` is a dotted import path. Raises: ComponentConfigError: If *config* is not a mapping. - ValueError: If ``class_path`` cannot be imported or ``init_args`` is + ValueError: If ``class_path`` is not a dotted path or ``init_args`` is not JSON-serializable. """ if not isinstance(config, Mapping): diff --git a/src/physicalai/robot/transport/_owner_config.py b/src/physicalai/robot/transport/_owner_config.py index 0348de2..56d7221 100644 --- a/src/physicalai/robot/transport/_owner_config.py +++ b/src/physicalai/robot/transport/_owner_config.py @@ -72,8 +72,8 @@ def validate_owner_config(data: Mapping[str, Any]) -> Mapping[str, object]: """Validate an owner stdin payload schema-positively. Returns: - The validated ``robot`` ComponentConfig mapping (not yet - public-path-normalized — :func:`normalize_robot_config` does that). + The validated ``robot`` ComponentConfig mapping (see + :func:`normalize_robot_config` for the JSON-serializability check). """ return validate_envelope( data, @@ -84,19 +84,19 @@ def validate_owner_config(data: Mapping[str, Any]) -> Mapping[str, object]: def normalize_robot_class(robot_class: type | str) -> str: - """Normalize a robot class reference to its public import path. + """Normalize a robot class reference to a dotted import path. Returns: - The normalized public dotted path. + The public path for a class object, or the string path as given. """ return normalize_class_reference(robot_class, label="robot_class") def normalize_robot_config(robot: Mapping[str, object]) -> ComponentConfig: - """Validate a robot ComponentConfig and normalize ``class_path``. + """Validate a robot ComponentConfig without importing its ``class_path``. Returns: - A validated config whose ``class_path`` is the public import path. + A validated config whose ``class_path`` is a dotted import path. """ return normalize_component_config( robot, diff --git a/tests/unit/capture/test_camera_component_config.py b/tests/unit/capture/test_camera_component_config.py index 9418d34..c8458da 100644 --- a/tests/unit/capture/test_camera_component_config.py +++ b/tests/unit/capture/test_camera_component_config.py @@ -135,19 +135,22 @@ def test_spawn_recipe_round_trip(self) -> None: assert "_connected" not in wire["init_args"] assert "_node" not in wire["init_args"] - def test_defining_module_camera_path_is_canonical_on_first_export(self) -> None: + def test_defining_module_camera_path_round_trips_as_given(self) -> None: from physicalai.capture import SharedCamera + defining = "physicalai.capture.cameras.uvc._camera.UVCCamera" camera = SharedCamera( camera={ - "class_path": "physicalai.capture.cameras.uvc._camera.UVCCamera", + "class_path": defining, "init_args": {"device": 0, "backend": "v4l2"}, }, ) wire = _assert_construction_round_trip(camera) nested = wire["init_args"]["camera"] assert isinstance(nested, dict) - assert nested["class_path"] == "physicalai.capture.UVCCamera" + # Stored as written; the class-path alias table still derives one service name. + assert nested["class_path"] == defining + assert camera._service_name == "physicalai/camera/uvc/0/frame" def test_attach_only_round_trip(self) -> None: from physicalai.capture import SharedCamera diff --git a/tests/unit/capture/test_factory.py b/tests/unit/capture/test_factory.py index 427cfae..5959036 100644 --- a/tests/unit/capture/test_factory.py +++ b/tests/unit/capture/test_factory.py @@ -9,6 +9,7 @@ from physicalai.capture.factory import create_camera from physicalai.capture.transport.builtin import ( builtin_class_path_for_type, + builtin_class_paths_for_type, builtin_shared_type_tokens, builtin_type_for_class_path, ) @@ -57,6 +58,34 @@ def test_matches_export_config_when_importable(self) -> None: assert builtin_class_path_for_type("uvc") == resolve_public_class_path(UVCCamera) + @pytest.mark.parametrize("token", ["uvc", "realsense", "basler"]) + def test_static_table_matches_drivers(self, token: str) -> None: + """Every listed spelling must reach the driver the decorator declares.""" + from physicalai.config import import_dotted_path, resolve_public_class_path + + paths = builtin_class_paths_for_type(token) + expected: object = None + try: + expected = import_dotted_path(paths[0]) + except (ImportError, AttributeError) as exc: # optional camera extra absent + pytest.skip(f"{token} driver is not installed: {exc}") + assert isinstance(expected, type) + assert resolve_public_class_path(expected) == paths[0] + + # Compare by defining name rather than identity: other tests reload + # driver modules with a mocked SDK, which replaces the class object. + defining = f"{expected.__module__}.{expected.__qualname__}" + assert defining == paths[-1] + for alias in paths[1:]: + resolved = import_dotted_path(alias) + assert isinstance(resolved, type) + assert f"{resolved.__module__}.{resolved.__qualname__}" == defining + + @pytest.mark.parametrize("token", ["uvc", "realsense", "basler"]) + def test_every_spelling_maps_back_to_one_token(self, token: str) -> None: + for path in builtin_class_paths_for_type(token): + assert builtin_type_for_class_path(path) == token + 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 diff --git a/tests/unit/capture/test_transport.py b/tests/unit/capture/test_transport.py index 3ed40e5..a5cf1e1 100644 --- a/tests/unit/capture/test_transport.py +++ b/tests/unit/capture/test_transport.py @@ -6,6 +6,9 @@ import ctypes import importlib.util import pickle +import subprocess +import sys +import textwrap from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -425,6 +428,32 @@ def test_constructor_requires_component_config_mapping(self) -> None: with pytest.raises(ComponentConfigError, match="camera must be a ComponentConfig mapping"): SharedCamera(camera=driver, service_name="physicalai/test/x/frame") # type: ignore[arg-type] + def test_subscriber_never_imports_the_vendor_driver(self) -> None: + """A subscriber derives a service name without the vendor SDK installed. + + Runs in a fresh interpreter so the assertion is not masked by driver + modules other tests already imported. + """ + script = textwrap.dedent(""" + import sys + + from physicalai.capture import SharedCamera + + camera = SharedCamera( + camera={ + "class_path": "physicalai.capture.RealSenseCamera", + "init_args": {"serial_number": "0001"}, + }, + ) + assert camera._service_name == "physicalai/camera/realsense/0001/frame" + leaked = sorted( + name for name in sys.modules + if name.startswith("physicalai.capture.cameras.realsense") or name == "pyrealsense2" + ) + assert not leaked, f"subscriber imported the driver package: {leaked}" + """) + subprocess.run([sys.executable, "-c", script], check=True, timeout=120) + def test_third_party_build_bypasses_create_camera_registry(self) -> None: spec = CameraPublisherConfig( camera={"class_path": FAKE_CAMERA_CLASS, "init_args": {"width": 16, "height": 16}}, diff --git a/tests/unit/robot/test_robot_component_config.py b/tests/unit/robot/test_robot_component_config.py index e9512fa..4c1b81c 100644 --- a/tests/unit/robot/test_robot_component_config.py +++ b/tests/unit/robot/test_robot_component_config.py @@ -257,13 +257,14 @@ def test_spawn_recipe_round_trip(self) -> None: assert "_connected" not in wire["init_args"] assert "session" not in wire["init_args"] - def test_defining_module_robot_path_is_canonical_on_first_export(self) -> None: + def test_defining_module_robot_path_round_trips_as_given(self) -> None: from physicalai.robot import SharedRobot + defining = "physicalai.robot.so101.so101.SO101" robot = SharedRobot( "canonical-arm", robot={ - "class_path": "physicalai.robot.so101.so101.SO101", + "class_path": defining, "init_args": { "port": "/dev/ttyUSB0", "calibration": SAMPLE_CALIBRATION, @@ -273,7 +274,8 @@ def test_defining_module_robot_path_is_canonical_on_first_export(self) -> None: wire = _assert_construction_round_trip(robot) nested = wire["init_args"]["robot"] assert isinstance(nested, dict) - assert nested["class_path"] == "physicalai.robot.SO101" + # Stored as written: the subscriber never imports the driver to canonicalize it. + assert nested["class_path"] == defining def test_attach_only_round_trip(self) -> None: from physicalai.robot import SharedRobot diff --git a/tests/unit/robot/transport/test_owner_config.py b/tests/unit/robot/transport/test_owner_config.py index 6edcb9b..dd88267 100644 --- a/tests/unit/robot/transport/test_owner_config.py +++ b/tests/unit/robot/transport/test_owner_config.py @@ -12,7 +12,7 @@ import pytest -from physicalai.config import ComponentConfigError +from physicalai.config import ComponentConfigError, ComponentImportError from physicalai.robot.transport._owner import RobotOwner from physicalai.robot.transport._owner_config import ( RobotOwnerConfig, @@ -47,12 +47,13 @@ class TestNormalizeRobotClass: def test_class_object(self) -> None: 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. + def test_string_path_passes_through_without_importing(self) -> None: + # No scservo_sdk mock: a string path is trusted and never imported here, + # so the owner envelope can be built where the driver is not installed. defining = "physicalai.robot.so101.so101.SO101" - assert normalize_robot_class(defining) == "physicalai.robot.SO101" + assert normalize_robot_class(defining) == defining - def test_public_path_idempotent(self, mock_scservo_sdk: MagicMock) -> None: + def test_public_path_passes_through(self) -> None: assert normalize_robot_class("physicalai.robot.SO101") == "physicalai.robot.SO101" def test_nested_qualname(self) -> None: @@ -69,13 +70,10 @@ 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") + @pytest.mark.parametrize("ref", ["", " ", "NotDotted"]) + def test_non_dotted_string_raises(self, ref: str) -> None: + with pytest.raises(ValueError, match="must be a nonempty dotted path"): + normalize_robot_class(ref) class TestRobotOwnerConfig: @@ -145,18 +143,20 @@ def test_build_known_class(self) -> None: assert isinstance(robot, FakeRobot) def test_build_non_class_path_raises(self) -> None: - with pytest.raises(TypeError, match="does not resolve to a class"): - RobotOwnerConfig( - name="left-arm", - robot={"class_path": "physicalai.robot.transport._ids.KEY_PREFIX", "init_args": {}}, - ) + config = RobotOwnerConfig( + name="left-arm", + robot={"class_path": "physicalai.robot.transport._ids.KEY_PREFIX", "init_args": {}}, + ) + with pytest.raises(ComponentImportError, match="does not resolve to a class"): + config.build() def test_build_unknown_path_raises(self) -> None: - with pytest.raises(ValueError, match="could not import"): - RobotOwnerConfig( - name="left-arm", - robot={"class_path": "totally.unknown.module.Cls", "init_args": {}}, - ) + config = RobotOwnerConfig( + name="left-arm", + robot={"class_path": "totally.unknown.module.Cls", "init_args": {}}, + ) + with pytest.raises(ComponentImportError, match="cannot import class_path"): + config.build() def test_flat_stdin_rejected_before_import(self) -> None: flat = { @@ -218,17 +218,19 @@ def test_malformed_robot_rejected_before_import(self) -> None: }, ) - def test_defining_module_path_normalized_on_store(self, mock_scservo_sdk: MagicMock) -> None: + def test_defining_module_path_stored_as_given(self) -> None: + # No scservo_sdk mock: storing the recipe must not import the driver. + defining = "physicalai.robot.so101.so101.SO101" config = RobotOwnerConfig( name="left-arm", robot={ - "class_path": "physicalai.robot.so101.so101.SO101", + "class_path": defining, "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" + assert config.robot["class_path"] == defining + assert config.robot_class == defining + assert config.to_json_dict()["robot"]["class_path"] == defining def test_relative_path_survives_json_and_popen_inherits_cwd(self, monkeypatch: pytest.MonkeyPatch) -> None: config = RobotOwnerConfig( From d99d35930394372d0bf14c1690856e2a9159b65d Mon Sep 17 00:00:00 2001 From: Max Xiang Date: Sat, 25 Jul 2026 23:43:16 +0200 Subject: [PATCH 21/21] refactor(config): drop the dead class-reference normalizers validate_component_config already requires class_path to be a string, so normalize_class_reference's class-object branch was unreachable from every envelope path. Its only remaining callers were normalize_robot_class and normalize_camera_class, which had no production call sites at all. Inlines the dotted-path check into normalize_component_config and removes normalize_class_reference from the physicalai.config surface, leaving two public envelope helpers instead of three. Also records why the camera envelope is CameraPublisherConfig rather than CameraSpec, mirroring the collision rationale already documented on RobotOwnerConfig. --- docs/development/component-config.md | 6 +++ src/physicalai/capture/transport/_spec.py | 10 ---- src/physicalai/config/__init__.py | 2 - src/physicalai/config/_envelope.py | 53 ++++--------------- .../robot/transport/_owner_config.py | 10 ---- .../unit/robot/transport/test_owner_config.py | 52 +++++------------- 6 files changed, 31 insertions(+), 102 deletions(-) diff --git a/docs/development/component-config.md b/docs/development/component-config.md index 2319808..7fff7b5 100644 --- a/docs/development/component-config.md +++ b/docs/development/component-config.md @@ -1088,5 +1088,11 @@ cutovers; do not describe them as schema-preserving. - Security rules: [runtime security](security.md) - Robot owner envelope: `physicalai.robot.transport._owner_config.RobotOwnerConfig` - Camera publisher envelope: `physicalai.capture.transport._spec.CameraPublisherConfig` + — named for symmetry with `RobotOwnerConfig`, which is itself named that way + to avoid colliding with the unrelated `physicalai.inference.manifest.RobotSpec` + manifest model. `CameraSpec` would collide with + `physicalai.inference.manifest.CameraSpec` the same way, and the type no + longer describes a `camera_type` + `camera_kwargs` spec anyway — it wraps one + `camera: ComponentConfig`. - Review context: Mark's comments on Studio PR #818 (factory wrap; plugins should not care about `SharedRobot`) diff --git a/src/physicalai/capture/transport/_spec.py b/src/physicalai/capture/transport/_spec.py index 383e1c6..6e41594 100644 --- a/src/physicalai/capture/transport/_spec.py +++ b/src/physicalai/capture/transport/_spec.py @@ -24,7 +24,6 @@ from physicalai.config import ( ComponentConfig, instantiate, - normalize_class_reference, normalize_component_config, validate_envelope, ) @@ -113,15 +112,6 @@ def validate_reconfigure_request(request: Mapping[str, Any]) -> dict[str, int]: return validated -def normalize_camera_class(camera_class: type | str) -> str: - """Normalize a camera class reference to a dotted import path. - - Returns: - The public path for a class object, or the string path as given. - """ - return normalize_class_reference(camera_class, label="camera class_path") - - def normalize_camera_config(camera: Mapping[str, object]) -> ComponentConfig: """Validate a camera ComponentConfig without importing its ``class_path``. diff --git a/src/physicalai/config/__init__.py b/src/physicalai/config/__init__.py index d79c2c3..db13007 100644 --- a/src/physicalai/config/__init__.py +++ b/src/physicalai/config/__init__.py @@ -22,7 +22,6 @@ """ from ._envelope import ( - normalize_class_reference, normalize_component_config, validate_envelope, ) @@ -51,7 +50,6 @@ "instantiate", "is_config_exportable", "load_yaml", - "normalize_class_reference", "normalize_component_config", "resolve_public_class_path", "save_yaml", diff --git a/src/physicalai/config/_envelope.py b/src/physicalai/config/_envelope.py index 3e3aa0e..8d9dc0a 100644 --- a/src/physicalai/config/_envelope.py +++ b/src/physicalai/config/_envelope.py @@ -20,7 +20,6 @@ from typing import TYPE_CHECKING, Any from ._errors import ComponentConfigError -from ._export import resolve_public_class_path from ._normalize import validate_component_config if TYPE_CHECKING: @@ -81,45 +80,6 @@ def validate_envelope( return validate_component_config(dict(component), path=component_key) -def normalize_class_reference(ref: type | str, *, label: str) -> str: - """Normalize a class object to its public path, or pass a dotted path through. - - A class object is resolved exactly like :func:`to_config` does: decorator - ``class_path=`` override when present, otherwise ``__module__.__qualname__``. - - A string is trusted and returned **as given**. It is deliberately not - imported here: envelope construction happens in the subscriber process, - which must not load the driver package (and often cannot — the vendor SDK - is only installed where the hardware lives). Import errors surface later, - in the process that actually calls :func:`~physicalai.config.instantiate`. - - 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 dotted path. - - Raises: - TypeError: If *ref* is neither a string nor a class. - ValueError: If a string is not a dotted path, or the public path - cannot be resolved (for example a local class). - """ - if isinstance(ref, str): - if not ref.strip() or "." not in ref: - msg = f"{label} must be a nonempty dotted path, got {ref!r}" - raise ValueError(msg) - return ref - 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], *, @@ -129,10 +89,16 @@ def normalize_component_config( ) -> ComponentConfig: """Validate a ComponentConfig without importing its ``class_path``. + The ``class_path`` is trusted and kept exactly as written: envelopes are + built in the subscriber process, which must not load the driver package + (and often cannot — the vendor SDK is only installed where the hardware + lives). Import errors surface later, in the process that calls + :func:`~physicalai.config.instantiate`. + 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. + class_label: Argument label for ``class_path`` errors. json_hint: Optional suffix appended to the JSON-serializability error. Returns: @@ -147,7 +113,10 @@ def normalize_component_config( msg = f"{component_key} must be a ComponentConfig mapping, got {type(config).__name__}" raise ComponentConfigError(msg) validated = validate_component_config(dict(config), path=component_key) - class_path = normalize_class_reference(validated["class_path"], label=class_label) + class_path = validated["class_path"] + if not class_path.strip() or "." not in class_path: + msg = f"{class_label} must be a nonempty dotted path, got {class_path!r}" + raise ValueError(msg) init_args = validated["init_args"] try: json.dumps({"class_path": class_path, "init_args": init_args}) diff --git a/src/physicalai/robot/transport/_owner_config.py b/src/physicalai/robot/transport/_owner_config.py index 56d7221..9fe6bf8 100644 --- a/src/physicalai/robot/transport/_owner_config.py +++ b/src/physicalai/robot/transport/_owner_config.py @@ -36,7 +36,6 @@ from physicalai.config import ( ComponentConfig, instantiate, - normalize_class_reference, normalize_component_config, validate_envelope, ) @@ -83,15 +82,6 @@ def validate_owner_config(data: Mapping[str, Any]) -> Mapping[str, object]: ) -def normalize_robot_class(robot_class: type | str) -> str: - """Normalize a robot class reference to a dotted import path. - - Returns: - The public path for a class object, or the string path as given. - """ - return normalize_class_reference(robot_class, label="robot_class") - - def normalize_robot_config(robot: Mapping[str, object]) -> ComponentConfig: """Validate a robot ComponentConfig without importing its ``class_path``. diff --git a/tests/unit/robot/transport/test_owner_config.py b/tests/unit/robot/transport/test_owner_config.py index dd88267..53236b8 100644 --- a/tests/unit/robot/transport/test_owner_config.py +++ b/tests/unit/robot/transport/test_owner_config.py @@ -8,7 +8,7 @@ import subprocess import sys from typing import Any, cast -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest @@ -16,7 +16,6 @@ from physicalai.robot.transport._owner import RobotOwner from physicalai.robot.transport._owner_config import ( RobotOwnerConfig, - normalize_robot_class, normalize_robot_config, validate_owner_config, ) @@ -32,48 +31,25 @@ def _fake_robot(**init_args: object) -> dict[str, object]: return {"class_path": FAKE_ROBOT_CLASS, "init_args": dict(init_args)} -@pytest.fixture -def mock_scservo_sdk() -> Any: - sdk = MagicMock() - with patch.dict(sys.modules, {"scservo_sdk": sdk}): - yield sdk - - -class _Outer: - class Inner: ... - - -class TestNormalizeRobotClass: - def test_class_object(self) -> None: - assert normalize_robot_class(FakeRobot) == FAKE_ROBOT_CLASS - - def test_string_path_passes_through_without_importing(self) -> None: +class TestNormalizeRobotConfigClassPath: + def test_string_path_stored_as_given_without_importing(self) -> None: # No scservo_sdk mock: a string path is trusted and never imported here, # so the owner envelope can be built where the driver is not installed. defining = "physicalai.robot.so101.so101.SO101" - assert normalize_robot_class(defining) == defining + assert normalize_robot_config({"class_path": defining})["class_path"] == defining - def test_public_path_passes_through(self) -> None: - assert normalize_robot_class("physicalai.robot.SO101") == "physicalai.robot.SO101" + def test_public_path_stored_as_given(self) -> None: + public = "physicalai.robot.SO101" + assert normalize_robot_config({"class_path": public})["class_path"] == public - def test_nested_qualname(self) -> None: - path = normalize_robot_class(_Outer.Inner) - assert path.endswith(".test_owner_config._Outer.Inner") - - def test_local_class_raises(self) -> None: - class _Local: ... - - with pytest.raises(ValueError, match="local class"): - normalize_robot_class(_Local) - - 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] - - @pytest.mark.parametrize("ref", ["", " ", "NotDotted"]) - def test_non_dotted_string_raises(self, ref: str) -> None: + @pytest.mark.parametrize("class_path", [" ", "NotDotted"]) + def test_non_dotted_path_raises(self, class_path: str) -> None: with pytest.raises(ValueError, match="must be a nonempty dotted path"): - normalize_robot_class(ref) + normalize_robot_config({"class_path": class_path}) + + def test_empty_path_raises(self) -> None: + with pytest.raises(ComponentConfigError, match="must be a non-empty string"): + normalize_robot_config({"class_path": ""}) class TestRobotOwnerConfig: