diff --git a/docs/development/README.md b/docs/development/README.md index f72a0b41..9e61e7e6 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -4,3 +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](component-config.md) — canonical accepted design note for captured component configuration. diff --git a/docs/development/component-config.md b/docs/development/component-config.md new file mode 100644 index 00000000..b1767e29 --- /dev/null +++ b/docs/development/component-config.md @@ -0,0 +1,1057 @@ +# 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 / @export_config(class_path=..., scalar_var_kwargs=...) = opt into ComponentConfig export +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. + +Mental model: + +```text +@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} +``` + +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 +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 Protocol, 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]}) + + +class ConfigValue(Protocol): + def to_config_value(self) -> JsonValue: ... + + +def export_config( + cls: type | None = None, + *, + class_path: str | None = None, + scalar_var_kwargs: bool = False, +): ... + + +def to_config(value: object) -> ComponentConfig: ... + + +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 +@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): ... + + +@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) +``` + +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. `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 + +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 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 +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`. +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. + +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; 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. 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=`. +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 +internal defining-module path. Plugins must do the same when they promise a +stable public import surface. + +`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 (`@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 `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 +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. 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 +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 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: + +```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 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 +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 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 + +Camera implementations opt in using public class paths. This supports +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`. + +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** + `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 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 + +`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. + +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 +`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. 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 +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 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 + +Public construction matches private stdin: `robot` / `--robot` only. +Flat `robot_class` / `robot_kwargs` on the public API and CLI are +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` +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 + `__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 + +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`. +- **`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`). +- **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. + +`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 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. 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) +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) +``` + +`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`. + +## 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`. When the public import path + differs from the defining module, pass + `@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 + `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 (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. + +## 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 + +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 + (behavior-preserving). Do not change inference factory behavior. +2. Add `@export_config`, `to_config()`, and `is_config_exportable()` with + 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 + `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`); + 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()` / + `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 + `instantiate()` and jsonargparse (under `runtime:` where the CLI requires + it). +8. Studio drops interim serializers and applies explicit sharing policy to + 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 + 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 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`. +- 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 + 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 + 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). +- 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`); + 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 + 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`) diff --git a/docs/development/security.md b/docs/development/security.md index 89f980be..b4bf1c4a 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/docs/development/shared-construction-wire-decision.md b/docs/development/shared-construction-wire-decision.md new file mode 100644 index 00000000..2b36568d --- /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). diff --git a/docs/explanation/cameras.md b/docs/explanation/cameras.md index 247eff77..7599dbb2 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/docs/how-to/runtime/share-a-robot.md b/docs/how-to/runtime/share-a-robot.md index fa731fb8..f247fef5 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 65af4c6c..37cb870b 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/capture/shared_camera.ipynb b/examples/capture/shared_camera.ipynb index 91ea7418..eb16dc55 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 034bbbf0..097fbea3 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 @@ -71,32 +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. SharedCamera matches - # publishers by ``camera_type`` + ``camera_kwargs``, so keep these 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_type: uvc - camera_kwargs: - 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 mode: spawn log_images: true image_decimation: 1 # log every Nth frame; raise to reduce viewer load diff --git a/examples/so101/serve.yaml b/examples/so101/serve.yaml index 31100d97..2b93aad2 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/examples/tutorials/collect_train_deploy.ipynb b/examples/tutorials/collect_train_deploy.ipynb index dc61ebe0..0e5426cb 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 064df50c..8c1d982f 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 2b50d719..2b932f71 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 6a2340f2..b325b392 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 66d2d959..b8d6b719 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 96abbece..3a901c32 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 80ed9a3c..3549e0f5 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 e5284f1a..3f7aa38f 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 862c1471..2452c485 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 09e64850..8aa9d6b8 100644 --- a/src/physicalai/capture/transport/_shared_camera.py +++ b/src/physicalai/capture/transport/_shared_camera.py @@ -1,34 +1,82 @@ # 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 import contextlib import ctypes import time +from collections.abc import Mapping 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 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 _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*. @@ -74,16 +122,7 @@ 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" - - +@export_config(class_path="physicalai.capture.SharedCamera") class SharedCamera(Camera): """Camera subscriber that reads frames from shared memory via iceoryx2. @@ -91,21 +130,40 @@ 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). + + 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. + 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. 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. - 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 +175,29 @@ 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) + 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: - 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 +213,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 +356,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 +368,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 +545,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 +565,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 +682,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 69b5f118..4ef08324 100644 --- a/src/physicalai/capture/transport/_spec.py +++ b/src/physicalai/capture/transport/_spec.py @@ -1,68 +1,213 @@ # 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 +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING, Any +from physicalai.config import ( + ComponentConfig, + instantiate, + normalize_class_reference, + normalize_component_config, + validate_envelope, +) + +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. + + Returns: + The validated ``camera`` ComponentConfig mapping (not yet + public-path-normalized — :func:`normalize_camera_config` does that). + """ + 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. + + Returns: + The normalized public dotted path. + """ + 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``. + + Returns: + A validated config whose ``class_path`` is the public import path. + """ + return normalize_component_config( + camera, + component_key="camera", + class_label="camera class_path", + ) + + +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 00000000..827eefac --- /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/src/physicalai/cli/robot.py b/src/physicalai/cli/robot.py index b07ba970..58220c4c 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 new file mode 100644 index 00000000..a5a3cefd --- /dev/null +++ b/src/physicalai/config/__init__.py @@ -0,0 +1,58 @@ +# 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`. + +Opt-in path: + +- ``@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). + +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 ._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", + "ComponentConfigError", + "ComponentImportError", + "ConfigValue", + "JsonScalar", + "JsonValue", + "export_config", + "import_dotted_path", + "instantiate", + "is_config_exportable", + "normalize_class_reference", + "normalize_component_config", + "resolve_public_class_path", + "to_config", + "validate_component_config", + "validate_envelope", +] diff --git a/src/physicalai/config/_envelope.py b/src/physicalai/config/_envelope.py new file mode 100644 index 00000000..a5251269 --- /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/config/_errors.py b/src/physicalai/config/_errors.py new file mode 100644 index 00000000..5b8515f2 --- /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 00000000..b554c251 --- /dev/null +++ b/src/physicalai/config/_export.py @@ -0,0 +1,423 @@ +# 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 TYPE_CHECKING, TypeVar, overload + +from ._errors import ComponentConfigError +from ._normalize import ( + normalize_value, + snapshot_captured_value, +) +from ._path import format_path +from ._types import ( + _CAPTURED_INIT_ARGS_ATTR, + _CONFIG_CLASS_PATH_ATTR, + _EXPORT_DEPTH_ATTR, + _EXPORT_MARKER_ATTR, + _MAX_CONFIG_DEPTH, + ComponentConfig, + JsonValue, +) +from .importing import import_dotted_path + +if TYPE_CHECKING: + from collections.abc import Callable + +_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)) + + +def _encode_domain_value(value: object) -> tuple[JsonValue] | None: + """Encode a domain value via ``to_config_value()`` if present. + + Returns: + ``(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): + 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. + """ + init = type(value).__init__ + return _has_export_marker(init) + + +def _class_path_override(cls: type) -> str | None: + """Return the decorator ``class_path=`` only when *cls* owns the decorated ``__init__``. + + 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: + 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: + """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__: + 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) + return _class_path_override(cls) or 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``. + + 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``. + + 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 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" + ) + 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, + domain_codec=_encode_domain_value, + ) + 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 _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) + + +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) + + 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 (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) + + 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) + # Do not apply_defaults — omit unsupplied arguments so reconstruction + # uses current constructor defaults. + 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, + 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) + 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) + 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, + scalar_var_kwargs: bool = False, +) -> Callable[[_T], _T]: ... + + +def export_config( + cls: _T | None = None, + /, + *, + 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`. + + 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: ... + + @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. + 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. + 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 keyword options are passed. + """ + if cls is not None: + 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, + scalar_var_kwargs=scalar_var_kwargs, + ) + + return decorator diff --git a/src/physicalai/config/_instantiate.py b/src/physicalai/config/_instantiate.py new file mode 100644 index 00000000..8d8e6839 --- /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 00000000..f4ee4a0d --- /dev/null +++ b/src/physicalai/config/_normalize.py @@ -0,0 +1,396 @@ +# 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] +# 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: + 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 " + 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'" + 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], + codec_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, + codec_seen=codec_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, + codec_seen=codec_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], + codec_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, + codec_seen=codec_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, + codec_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, 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. 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, 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: + 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_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( + value, + path=path, + depth=depth, + seen=seen, + codec_seen=codec_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, + codec_seen=codec_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, + *, + memo: dict[int, object] | None = None, +) -> object: + """Deep-snapshot built-in mutable containers; keep other values by reference. + + 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: + 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, 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, memo=memo) for item in value) + snap = result_list + else: + memo[obj_id] = value + snap = tuple(snapshot_captured_value(item, memo=memo) for item in value) + memo[obj_id] = snap + return snap + + return value diff --git a/src/physicalai/config/_path.py b/src/physicalai/config/_path.py new file mode 100644 index 00000000..921336d1 --- /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 00000000..4563a455 --- /dev/null +++ b/src/physicalai/config/_types.py @@ -0,0 +1,41 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Types for JSON-safe component configuration.""" + +from __future__ import annotations + +from typing import Protocol, TypedDict, runtime_checkable + +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] + + +@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 + +# 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" +# 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/config/importing.py b/src/physicalai/config/importing.py new file mode 100644 index 00000000..bb49b720 --- /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) diff --git a/src/physicalai/inference/_importing.py b/src/physicalai/inference/_importing.py index 8965d973..8d277b20 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/inference/model.py b/src/physicalai/inference/model.py index e48d2b2f..596545ff 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/so101/calibration.py b/src/physicalai/robot/so101/calibration.py index 4765df65..1f0cbad2 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 1f916f7e..ad0d63b7 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/transport/_importing.py b/src/physicalai/robot/transport/_importing.py index 0f3dc58c..021561f7 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/src/physicalai/robot/transport/_owner_config.py b/src/physicalai/robot/transport/_owner_config.py index 88643d11..0348de2c 100644 --- a/src/physicalai/robot/transport/_owner_config.py +++ b/src/physicalai/robot/transport/_owner_config.py @@ -9,12 +9,18 @@ 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. - -Security: *robot_class* is trusted local application/config input, exactly +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. + +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 4, 9, 11). It must never originate from network-received data (e.g. a ``/metadata`` payload) — that would let an untrusted peer choose an @@ -23,14 +29,21 @@ from __future__ import annotations -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, + instantiate, + normalize_class_reference, + normalize_component_config, + validate_envelope, +) if TYPE_CHECKING: + from collections.abc import Mapping + from physicalai.robot.interface import Robot DEFAULT_RATE_HZ = 100.0 @@ -42,38 +55,55 @@ justify a different value for a specific robot class. """ +# 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 normalize_robot_class(robot_class: type | str) -> str: - """Normalize a robot class reference to an importable dotted 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. - Args: - robot_class: A class object, or its dotted import path as a string. +def validate_owner_config(data: Mapping[str, Any]) -> Mapping[str, object]: + """Validate an owner stdin payload schema-positively. Returns: - The normalized dotted path. + The validated ``robot`` ComponentConfig mapping (not yet + public-path-normalized — :func:`normalize_robot_config` does that). + """ + return validate_envelope( + data, + component_key="robot", + allowed_keys=_OWNER_ENVELOPE_KEYS, + envelope_name="owner", + ) + - 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. +def normalize_robot_class(robot_class: type | str) -> str: + """Normalize a robot class reference to its public import path. + + Returns: + The normalized public dotted path. """ - if isinstance(robot_class, str): - return robot_class - 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) + return normalize_class_reference(robot_class, label="robot_class") + - 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 +def normalize_robot_config(robot: Mapping[str, object]) -> ComponentConfig: + """Validate a robot ComponentConfig and normalize ``class_path``. + + Returns: + A validated config whose ``class_path`` is the public import path. + """ + return normalize_component_config( + robot, + component_key="robot", + class_label="robot_class", + json_hint=" (e.g. paths as str, not objects)", + ) @dataclass(frozen=True) @@ -88,9 +118,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 +129,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 +160,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 +195,27 @@ def to_json_dict(self) -> dict[str, Any]: def from_json_dict(cls, data: dict[str, Any]) -> RobotOwnerConfig: """Deserialize from a JSON dictionary. + 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`. Returns: A new :class:`RobotOwnerConfig` instance. + + Raises: + 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) + + robot = validate_owner_config(data) return cls( name=data["name"], - robot_class=data["robot_class"], - robot_kwargs=data.get("robot_kwargs", {}), + 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), @@ -179,14 +224,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 fed690cb..69ff6532 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 50c3e394..cff051a2 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 @@ -25,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, @@ -40,15 +46,54 @@ 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: - from collections.abc import Mapping - import numpy as np - from physicalai.robot import RobotObservation + 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 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 @@ -169,6 +214,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. @@ -177,17 +223,25 @@ 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). + + 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_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. 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, @@ -203,8 +257,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 +265,8 @@ 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 {}) + 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 @@ -229,6 +282,102 @@ 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. + """ + # Omit default None so @export_config does not capture "_session": null. + return cls( + name, + robot=robot_config, + 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 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, @@ -253,7 +402,15 @@ def attach( Returns: A ``SharedRobot`` that never spawns an owner. """ - return cls(name, allow_remote=allow_remote, connect_timeout=connect_timeout, _session=_session) + # Omit default None so @export_config does not capture "_session": null. + 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: + """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: @@ -336,8 +493,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 +502,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 +511,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 +595,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/src/physicalai/robot/trossen/bimanual_widowxai.py b/src/physicalai/robot/trossen/bimanual_widowxai.py index 1db88d0f..d1e286ed 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 ccb5e5c1..75e14eb2 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/src/physicalai/runtime/action_sources/policy.py b/src/physicalai/runtime/action_sources/policy.py index b390f05f..75ba923c 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 10685ed1..2652e837 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 b3c3b78b..5afb2eef 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 eddf8834..3a37564a 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 6eb3daf3..1de04806 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 c4495cbb..ce76cc7e 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 7aedb7fa..abf53812 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 3183bd14..9a73102a 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 a7685056..78fba4cd 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 958e3c84..6a7a199e 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 93af3650..bdc504f9 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 373f2eea..689a0d28 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 d1ba6fae..30d30491 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 0181adf7..6ee6e95c 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/conftest.py b/tests/unit/capture/conftest.py index e031109f..f60d5c36 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 5121b211..58bfa054 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 00000000..5aeb6c2c --- /dev/null +++ b/tests/unit/capture/test_camera_component_config.py @@ -0,0 +1,156 @@ +# 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 + + +# --------------------------------------------------------------------------- +# 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/capture/test_factory.py b/tests/unit/capture/test_factory.py index aa329520..427cfae1 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 500aaa60..c1e014a1 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" diff --git a/tests/unit/cli/test_robot_cli.py b/tests/unit/cli/test_robot_cli.py index ab19564a..cec5a7c0 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,18 +25,28 @@ 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_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, } values.update(overrides) - return SimpleNamespace(**values) + return _ns(**values) def test_serve_runs_owner_in_foreground_with_persistent_timeout(capsys: object) -> None: @@ -57,11 +68,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, @@ -122,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 @@ -136,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 @@ -194,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] @@ -203,13 +225,40 @@ 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: - 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() @@ -231,12 +280,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/config/__init__.py b/tests/unit/config/__init__.py new file mode 100644 index 00000000..888f2e02 --- /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 00000000..d532c923 --- /dev/null +++ b/tests/unit/config/test_component_config.py @@ -0,0 +1,582 @@ +# 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.""" + + +@export_config +class WithExtras: + def __init__(self, base: int, **kwargs: object) -> None: + self.base = base + 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: + 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 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 + + +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="to_config_value"): + 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_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.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 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) + 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) + + +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/inference/test_inference_model_component_config.py b/tests/unit/inference/test_inference_model_component_config.py new file mode 100644 index 00000000..8512900f --- /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/robot/test_robot_component_config.py b/tests/unit/robot/test_robot_component_config.py new file mode 100644 index 00000000..fc0bb315 --- /dev/null +++ b/tests/unit/robot/test_robot_component_config.py @@ -0,0 +1,331 @@ +# 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"} + + +# --------------------------------------------------------------------------- +# 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() diff --git a/tests/unit/robot/transport/fake.py b/tests/unit/robot/transport/fake.py index ecda1a81..581e4d89 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 c9e621ab..9475ba4f 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 08730208..6edcb9b8 100644 --- a/tests/unit/robot/transport/test_owner_config.py +++ b/tests/unit/robot/transport/test_owner_config.py @@ -5,34 +5,55 @@ 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, + validate_owner_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 +69,217 @@ 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="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="unknown owner config keys"): + RobotOwnerConfig.from_json_dict( + { + "name": "left-arm", + "robot": _fake_robot(), + "robot_kwargs": {}, + }, + ) + + 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( + { + "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 9938c610..590737ad 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 b22fbf81..f85ffc3a 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 8dfb6cb6..8b05350d 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() 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 00000000..34d32e5c --- /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