From 84e3cbe6d7fc914522bb1fff3ebbb684132b1a8e Mon Sep 17 00:00:00 2001 From: Samet Date: Fri, 21 Aug 2026 15:00:12 +0200 Subject: [PATCH 1/4] refactor(config)!: remove deprecated configuration shims --- CHANGELOG.md | 6 + .../capture/transport/_shared_camera.py | 2 +- src/physicalai/config/__init__.py | 24 +- src/physicalai/config/_deprecate.py | 17 -- src/physicalai/config/_envelope.py | 146 --------- src/physicalai/config/_export.py | 25 +- .../config/{importing.py => _importing.py} | 0 src/physicalai/config/_instantiate.py | 2 +- src/physicalai/config/_yaml.py | 103 ------- src/physicalai/config/base.py | 2 +- src/physicalai/config/loading.py | 240 --------------- src/physicalai/config/mixin.py | 150 ---------- src/physicalai/config/serializable.py | 147 ---------- src/physicalai/inference/_importing.py | 5 +- src/physicalai/robot/so101/calibration.py | 2 +- src/physicalai/robot/transport/_importing.py | 5 +- .../robot/transport/_shared_robot.py | 2 +- tests/unit/capture/test_factory.py | 2 +- tests/unit/config/test_export_config.py | 89 +++--- .../test_from_config_and_instantiate.py | 276 ------------------ tests/unit/config/test_yaml.py | 93 ------ tests/unit/runtime/test_runtime_config.py | 2 +- 22 files changed, 70 insertions(+), 1270 deletions(-) delete mode 100644 src/physicalai/config/_deprecate.py delete mode 100644 src/physicalai/config/_envelope.py rename src/physicalai/config/{importing.py => _importing.py} (100%) delete mode 100644 src/physicalai/config/_yaml.py delete mode 100644 src/physicalai/config/loading.py delete mode 100644 src/physicalai/config/mixin.py delete mode 100644 src/physicalai/config/serializable.py delete mode 100644 tests/unit/config/test_from_config_and_instantiate.py delete mode 100644 tests/unit/config/test_yaml.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a499434..55ddfa91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Remove deprecated configuration compatibility shims. Use jsonargparse for + typed construction and `Config.from_instance()` / `Config.load()` for + portable configuration recipes. + ## [0.1.1](https://github.com/openvinotoolkit/physicalai/compare/v0.1.0...v0.1.1) (2026-06-02) **Full Changelog**: [v0.1.0...v0.1.1](https://github.com/openvinotoolkit/physicalai/compare/v0.1.0...v0.1.1) diff --git a/src/physicalai/capture/transport/_shared_camera.py b/src/physicalai/capture/transport/_shared_camera.py index da7abe10..a7519186 100644 --- a/src/physicalai/capture/transport/_shared_camera.py +++ b/src/physicalai/capture/transport/_shared_camera.py @@ -101,7 +101,7 @@ class SharedCamera(Camera): Opted into :func:`~physicalai.config.export_config` as a **construction recipe** only (nested ``camera`` Config, ``service_name``, ``color_mode``, transport knobs). Publisher / iceoryx2 session / frame - state is never part of :func:`~physicalai.config.to_config`. + state is never part of :func:`~physicalai.config.Config.from_instance`. The publisher subprocess owns the device exclusively. Another connected holder of the same hardware will cause open to fail; this API does not diff --git a/src/physicalai/config/__init__.py b/src/physicalai/config/__init__.py index 7ef8265f..860c16cb 100644 --- a/src/physicalai/config/__init__.py +++ b/src/physicalai/config/__init__.py @@ -7,9 +7,7 @@ and :func:`instantiate` for trusted local recipes (robots, cameras, exported components). -Legacy helpers in :mod:`physicalai.config.loading` and -:mod:`physicalai.config.mixin` remain for compatibility and emit -:class:`DeprecationWarning`; prefer jsonargparse for known types and +Use jsonargparse directly for known types and :meth:`Config.from_instance` / :meth:`Config.save` for export. Opt-in export: @@ -24,25 +22,16 @@ modules. """ -from ._envelope import ( - normalize_config, - validate_envelope, -) from ._errors import ConfigError, ConfigImportError from ._export import ( export_config, is_config_exportable, resolve_public_class_path, - to_config, ) from ._instantiate import instantiate as _strict_instantiate from ._normalize import validate_config from ._types import ConfigValue, JsonScalar, JsonValue -from ._yaml import load_yaml, save_yaml, to_yaml from .base import Config -from .importing import import_dotted_path -from .loading import import_class, instantiate_obj -from .mixin import FromConfig, from_config instantiate = _strict_instantiate # ruff: ignore[RUF067] @@ -51,22 +40,11 @@ "ConfigError", "ConfigImportError", "ConfigValue", - "FromConfig", "JsonScalar", "JsonValue", "export_config", - "from_config", - "import_class", - "import_dotted_path", "instantiate", - "instantiate_obj", "is_config_exportable", - "load_yaml", - "normalize_config", "resolve_public_class_path", - "save_yaml", - "to_config", - "to_yaml", "validate_config", - "validate_envelope", ] diff --git a/src/physicalai/config/_deprecate.py b/src/physicalai/config/_deprecate.py deleted file mode 100644 index 1b81bb8b..00000000 --- a/src/physicalai/config/_deprecate.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright (C) 2026 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -"""Shared deprecation warnings for compatibility facades.""" - -from __future__ import annotations - -import warnings - - -def deprecate(name: str, replacement: str) -> None: - """Emit a :class:`DeprecationWarning` for a legacy public API.""" - warnings.warn( - f"{name} is deprecated and will be removed in a future release; use {replacement} instead.", - DeprecationWarning, - stacklevel=3, - ) diff --git a/src/physicalai/config/_envelope.py b/src/physicalai/config/_envelope.py deleted file mode 100644 index 3ad62cf8..00000000 --- a/src/physicalai/config/_envelope.py +++ /dev/null @@ -1,146 +0,0 @@ -# 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:`Config` plus transport-only keys. These helpers keep the -schema-positive validation in one place; transports supply their key names -and allowlists. - -Nothing here imports a ``class_path``. Envelopes are built in the subscriber -process, which must stay free of the driver package — the import happens in -the process that calls :func:`~physicalai.config.instantiate`. - -.. deprecated:: - Prefer domain validators such as ``validate_owner_config`` and - ``validate_publisher_config``. Emits :class:`DeprecationWarning` at runtime. -""" - -from __future__ import annotations - -import json -from collections.abc import Mapping -from typing import Any - -from ._deprecate import deprecate -from ._errors import ConfigError -from ._normalize import validate_config -from .base import Config - - -def validate_envelope( - data: Mapping[str, Any], - *, - component_key: str, - allowed_keys: frozenset[str], - envelope_name: str, -) -> Config: - """Validate a transport stdin envelope schema-positively. - - Requires *component_key* with a valid Config shape and allows - only *allowed_keys*. Unknown keys raise a clear schema error before any - import or hardware access. - - .. deprecated:: - Use domain-owned envelope validators in robot/capture transport modules. - - Args: - data: Full stdin envelope dict. - component_key: Envelope key holding the nested Config - (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 Config mapping (see - :func:`normalize_config` for the JSON-serializability check). - - Raises: - TypeError: If *data* or the component value is not a mapping. - ValueError: If the component key is missing or unknown keys are present. - """ - deprecate( - "physicalai.config.validate_envelope", - "domain validators such as validate_owner_config or validate_publisher_config", - ) - 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} Config" - 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 Config.from_dict(validate_config(dict(component), path=component_key)) - - -def normalize_config( - config: Config | Mapping[str, object], - *, - component_key: str, - class_label: str, - json_hint: str = "", -) -> Config: - """Validate a Config without importing its ``class_path``. - - The ``class_path`` is trusted and kept exactly as written: envelopes are - built in the subscriber process, which must not load the driver package - (and often cannot — the vendor SDK is only installed where the hardware - lives). Import errors surface later, in the process that calls - :func:`~physicalai.config.instantiate`. - - .. deprecated:: - Use ``normalize_robot_config`` or ``normalize_camera_config`` in - transport modules. - - Args: - config: Candidate ``class_path`` + ``init_args`` mapping. - component_key: Path prefix for validation errors (``"robot"`` / ``"camera"``). - class_label: Argument label for ``class_path`` errors. - json_hint: Optional suffix appended to the JSON-serializability error. - - Returns: - A validated config whose ``class_path`` is a dotted import path. - - Raises: - ConfigError: If *config* is not a mapping. - ValueError: If ``class_path`` is not a dotted path or ``init_args`` is - not JSON-serializable. - """ - deprecate( - "physicalai.config.normalize_config (transport envelope helper)", - "normalize_robot_config or normalize_camera_config in transport modules", - ) - if type(config) is Config: - config = config.to_dict() - if not isinstance(config, Mapping): - msg = f"{component_key} must be a Config mapping, got {type(config).__name__}" - raise ConfigError(msg) - validated = validate_config(dict(config), path=component_key) - class_path = validated["class_path"] - if not class_path.strip() or "." not in class_path: - msg = f"{class_label} must be a nonempty dotted path, got {class_path!r}" - raise ValueError(msg) - init_args = validated["init_args"] - try: - json.dumps({"class_path": class_path, "init_args": init_args}, allow_nan=False) - except (TypeError, ValueError) as exc: - msg = f"{component_key}.init_args must be JSON-serializable{json_hint}: {exc}" - raise ValueError(msg) from exc - return Config(class_path, dict(init_args)) diff --git a/src/physicalai/config/_export.py b/src/physicalai/config/_export.py index 609a3b36..d3244585 100644 --- a/src/physicalai/config/_export.py +++ b/src/physicalai/config/_export.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, TypeVar, overload from ._errors import ConfigError +from ._importing import import_dotted_path from ._normalize import ( normalize_value, snapshot_captured_value, @@ -26,7 +27,6 @@ JsonValue, ValidatedConfigDict, ) -from .importing import import_dotted_path if TYPE_CHECKING: from collections.abc import Callable, Sequence @@ -37,7 +37,7 @@ class _NonScalarVarKwarg: - """Poison value so ``to_config`` rejects non-scalar ``**kwargs`` entries. + """Poison value so export rejects non-scalar ``**kwargs`` entries. Used when ``@export_config(scalar_var_kwargs=True)`` seals flattened var-keyword arguments to JSON scalars only. @@ -245,19 +245,6 @@ def _export_instance( return {"class_path": class_path, "init_args": init_args} -def to_config(value: object) -> Config: - """Return the canonical recipe for an ``@export_config`` instance. - - .. deprecated:: - Use :meth:`~physicalai.config.Config.from_instance` instead. - """ - from ._deprecate import deprecate # noqa: PLC0415 - from .base import Config # noqa: PLC0415 - - deprecate("physicalai.config.to_config", "Config.from_instance") - return Config.from_instance(value) - - def _validate_replayable_signature(cls: type, signature: inspect.Signature) -> None: for param in signature.parameters.values(): if param.name == "self": @@ -294,7 +281,7 @@ def _flatten_var_kwargs( 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). + # Seal so normalize fails during export (no silent JSON nest). supplied[key] = _NonScalarVarKwarg(key) else: supplied[key] = snapshot_captured_value(value) @@ -462,7 +449,7 @@ def export_config( scalar_var_kwargs: bool = False, config_args: Sequence[str] | None = None, ) -> _T | Callable[[_T], _T]: - """Opt a concrete class into constructor-config export via :func:`to_config`. + """Opt a concrete class into constructor-config export via :meth:`Config.from_instance`. Remembers caller-supplied ``__init__`` arguments (not defaults). Rejects constructors that declare positional-only parameters or ``*args``. @@ -485,7 +472,7 @@ class InferenceModel: ... 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 + Non-scalar var-keyword values then fail during export instead of being normalized as nested JSON. Requires a ``**kwargs`` parameter. Nested non-component domain values (for example calibration objects) may @@ -513,7 +500,7 @@ class InferenceModel: ... 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`. + scalars so non-scalars fail during export. config_args: Init-arg names the class consumes as Config *data*. :func:`instantiate` passes these through as plain mappings instead of constructing the nested component — use it for spawn diff --git a/src/physicalai/config/importing.py b/src/physicalai/config/_importing.py similarity index 100% rename from src/physicalai/config/importing.py rename to src/physicalai/config/_importing.py diff --git a/src/physicalai/config/_instantiate.py b/src/physicalai/config/_instantiate.py index e593b8d0..7334df14 100644 --- a/src/physicalai/config/_instantiate.py +++ b/src/physicalai/config/_instantiate.py @@ -15,12 +15,12 @@ from ._errors import ConfigError, ConfigImportError from ._export import declared_config_args +from ._importing import import_dotted_path from ._normalize import validate_config from ._path import format_path from ._typed_wire import enum_from_wire from ._types import _MAX_CONFIG_DEPTH, JsonValue from .base import Config, parse_class_config -from .importing import import_dotted_path _T = TypeVar("_T") diff --git a/src/physicalai/config/_yaml.py b/src/physicalai/config/_yaml.py deleted file mode 100644 index 47ceadf9..00000000 --- a/src/physicalai/config/_yaml.py +++ /dev/null @@ -1,103 +0,0 @@ -# Copyright (C) 2026 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -"""YAML round-trip helpers for configs. - -``to_yaml`` / ``save_yaml`` serialize a live ``@export_config`` component (or -an existing ``class_path`` + ``init_args`` mapping) to a YAML document that -``load_yaml`` + :func:`~physicalai.config.instantiate` can rebuild. The same -document is accepted by ``physicalai run --config`` when the top-level -component is a ``RobotRuntime``. - -Trusted local configs only — never feed network-received YAML to -:func:`~physicalai.config.instantiate`. - -.. deprecated:: - Prefer :meth:`~physicalai.config.Config.save` and - :meth:`~physicalai.config.Config.load`. Emits :class:`DeprecationWarning` - at runtime. -""" - -from __future__ import annotations - -from collections.abc import Mapping -from pathlib import Path - -import yaml - -from ._deprecate import deprecate -from ._errors import ConfigError -from ._export import to_config -from ._normalize import normalize_config -from .base import Config - -_REPLACEMENT = "Config.from_instance(...).save(path) and Config.load(path)" - - -def to_yaml(component: object) -> str: - """Serialize a component to a YAML ``class_path`` + ``init_args`` document. - - .. deprecated:: - Use :meth:`~physicalai.config.Config.save` or serialize - :meth:`~physicalai.config.Config.to_dict`. - - Args: - component: A live ``@export_config`` instance, or an existing - :class:`Config` mapping (validated, not re-exported). - - Returns: - YAML text of the validated :class:`Config`. - """ - deprecate("physicalai.config.to_yaml", _REPLACEMENT) - if type(component) is Config: - config = component - elif isinstance(component, Mapping): - config = Config.from_dict(component) - else: - config = to_config(component) - return yaml.safe_dump(config.to_dict(), sort_keys=False, default_flow_style=False) - - -def save_yaml(component: object, path: str | Path) -> None: - """Write :func:`to_yaml` output to *path* (parent directories must exist). - - .. deprecated:: - Use :meth:`~physicalai.config.Config.save`. - - Args: - component: A live ``@export_config`` instance or a - :class:`Config` mapping. - path: Destination file path. - """ - deprecate("physicalai.config.save_yaml", _REPLACEMENT) - Path(path).write_text(to_yaml(component), encoding="utf-8") - - -def load_yaml(path: str | Path) -> Config: - """Load a YAML document as a mapping, ready for :func:`~physicalai.config.instantiate`. - - The document is parsed with ``yaml.safe_load`` and only shape-checked to - be a mapping — full component validation happens in - :func:`~physicalai.config.instantiate`. - - .. deprecated:: - Use :meth:`~physicalai.config.Config.load`. - - Args: - path: YAML file previously written by :func:`save_yaml` (or - hand-authored in the same shape). - - Returns: - The loaded top-level mapping. - - Raises: - ConfigError: If the document is not a mapping. - """ - deprecate("physicalai.config.load_yaml", "Config.load(path)") - loaded = yaml.safe_load(Path(path).read_text(encoding="utf-8")) - if loaded is None: - loaded = {} - if not isinstance(loaded, dict): - msg = f"{str(path)!r}: YAML config must be a mapping, got {type(loaded).__name__}" - raise ConfigError(msg) - return Config.from_dict(normalize_config(loaded)) diff --git a/src/physicalai/config/base.py b/src/physicalai/config/base.py index 5e4b6c0d..454a5584 100644 --- a/src/physicalai/config/base.py +++ b/src/physicalai/config/base.py @@ -148,7 +148,7 @@ def resolve_type(self) -> type: Raises: ConfigImportError: If the path does not resolve to a class. """ - from .importing import import_dotted_path # noqa: PLC0415 + from ._importing import import_dotted_path # noqa: PLC0415 resolved = import_dotted_path(self.class_path) if not isinstance(resolved, type): diff --git a/src/physicalai/config/loading.py b/src/physicalai/config/loading.py deleted file mode 100644 index b527def0..00000000 --- a/src/physicalai/config/loading.py +++ /dev/null @@ -1,240 +0,0 @@ -# Copyright (C) 2026 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -"""General configuration instantiation helpers. - -.. deprecated:: - Delegates to jsonargparse and :func:`~physicalai.config.base.parse_class_config`. - Prefer jsonargparse ``ArgumentParser.add_class_arguments`` with - ``parser.instantiate``, or :meth:`~physicalai.config.Config.instantiate` for - portable recipes. Emits :class:`DeprecationWarning` at runtime. -""" - -from __future__ import annotations - -import dataclasses -from collections.abc import Mapping -from pathlib import Path - -import yaml -from pydantic import BaseModel - -from ._deprecate import deprecate -from ._errors import ConfigError -from ._instantiate import instantiate -from ._types import _MAX_CONFIG_DEPTH -from .base import Config, parse_class_config -from .importing import import_dotted_path - -ConfigMapping = Mapping[str, object] - -__all__ = [ - "import_class", - "instantiate_obj", - "instantiate_obj_from_dataclass", - "instantiate_obj_from_dict", - "instantiate_obj_from_file", - "instantiate_obj_from_pydantic", -] - -_REPLACEMENT = ( - "jsonargparse.ArgumentParser.add_class_arguments with parser.instantiate(), " - "or Config.instantiate() for portable recipes" -) - - -def import_class(class_path: str) -> type: - """Import and validate a class from a dotted path. - - .. deprecated:: - Use :func:`~physicalai.config.importing.import_dotted_path` instead. - - Returns: - The imported class. - - Raises: - ImportError: If the module or attribute cannot be imported. - TypeError: If the path resolves to a non-class object. - """ - deprecate("physicalai.config.import_class", "physicalai.config.importing.import_dotted_path") - try: - value = import_dotted_path(class_path) - except (ValueError, ImportError, AttributeError) as exc: - msg = f"Cannot import {class_path!r}: {exc}" - raise ImportError(msg) from exc - if not isinstance(value, type): - msg = f"{class_path!r} does not resolve to a class" - raise TypeError(msg) - return value - - -def _instantiate_recursive(value: object, *, depth: int = 0) -> object: - if depth > _MAX_CONFIG_DEPTH: - msg = f"Configuration nesting depth exceeds {_MAX_CONFIG_DEPTH}" - raise ConfigError(msg) - if isinstance(value, dict): - if "class_path" in value: - return instantiate(Config.from_dict(value)) - return {key: _instantiate_recursive(item, depth=depth + 1) for key, item in value.items()} - if isinstance(value, list): - return [_instantiate_recursive(item, depth=depth + 1) for item in value] - if isinstance(value, tuple): - return tuple(_instantiate_recursive(item, depth=depth + 1) for item in value) - return value - - -def instantiate_obj_from_dict( - config: ConfigMapping, - *, - key: str | None = None, - target_cls: type | None = None, -) -> object: - """Instantiate an object from a configuration mapping. - - When ``target_cls`` is set and the selected mapping has no ``class_path``, - entries are passed as keyword arguments after recursive instantiation. - The reserved key ``args`` supplies positional constructor arguments (a - sequence); it is removed from ``init_args`` before ``target_cls`` is called. - - .. deprecated:: - Use jsonargparse or :func:`~physicalai.config.base.parse_class_config` instead. - - Returns: - The constructed object. - - Raises: - ValueError: If ``key`` is missing or no ``class_path``/``target_cls`` is available. - TypeError: If a selected sub-config is not a mapping. - """ - deprecate("physicalai.config.loading.instantiate_obj_from_dict", _REPLACEMENT) - selected: object = config - if key is not None: - if key not in config: - msg = f"Configuration must contain {key!r} key. Got keys: {list(config.keys())}" - raise ValueError(msg) - selected = config[key] - if not isinstance(selected, Mapping): - msg = f"Configuration at key {key!r} must be a mapping, got {type(selected).__name__}" - raise TypeError(msg) - if not isinstance(selected, Mapping): - msg = f"Configuration must be a mapping, got {type(selected).__name__}" - raise TypeError(msg) - if "class_path" in selected: - return instantiate(Config.from_dict(selected)) - if target_cls is None: - msg = ( - "Configuration must contain 'class_path' for instantiation, " - f"or pass target_cls explicitly. Got keys: {list(selected.keys())}" - ) - raise ValueError(msg) - init_args = dict(selected) - args = init_args.pop("args", ()) - if args: - decoded = {name: _instantiate_recursive(value) for name, value in init_args.items()} - return target_cls(*args, **decoded) - try: - return parse_class_config(target_cls, init_args) - except Exception: # noqa: BLE001 - decoded = {name: _instantiate_recursive(value) for name, value in init_args.items()} - return target_cls(**decoded) - - -def instantiate_obj_from_pydantic( - config: BaseModel, - *, - key: str | None = None, - target_cls: type | None = None, -) -> object: - """Instantiate from a Pydantic model. - - .. deprecated:: - Use jsonargparse or :func:`~physicalai.config.base.parse_class_config` instead. - - Returns: - The constructed object. - """ - deprecate("physicalai.config.loading.instantiate_obj_from_pydantic", _REPLACEMENT) - return instantiate_obj_from_dict(config.model_dump(), key=key, target_cls=target_cls) - - -def instantiate_obj_from_dataclass( - config: object, - *, - key: str | None = None, - target_cls: type | None = None, -) -> object: - """Instantiate from a dataclass instance. - - .. deprecated:: - Use jsonargparse or :func:`~physicalai.config.base.parse_class_config` instead. - - Returns: - The constructed object. - - Raises: - TypeError: If ``config`` is not a dataclass instance. - """ - deprecate("physicalai.config.loading.instantiate_obj_from_dataclass", _REPLACEMENT) - if not dataclasses.is_dataclass(config) or isinstance(config, type): - msg = f"Expected dataclass instance, got {type(config)}" - raise TypeError(msg) - return instantiate_obj_from_dict(dataclasses.asdict(config), key=key, target_cls=target_cls) - - -def instantiate_obj_from_file( - file_path: str | Path, - *, - key: str | None = None, - target_cls: type | None = None, -) -> object: - """Instantiate from a YAML or JSON file. - - .. deprecated:: - Use jsonargparse or :func:`~physicalai.config.base.parse_class_config` instead. - - Returns: - The constructed object. - - Raises: - TypeError: If the file root is not a mapping. - """ - deprecate("physicalai.config.loading.instantiate_obj_from_file", _REPLACEMENT) - config = yaml.safe_load(Path(file_path).read_text(encoding="utf-8")) - if config is None: - config = {} - if not isinstance(config, Mapping): - msg = f"Expected YAML root to be a mapping, got {type(config).__name__}" - raise TypeError(msg) - return instantiate_obj_from_dict(config, key=key, target_cls=target_cls) - - -def instantiate_obj( - config: ConfigMapping | Config | BaseModel | object | str | Path, - *, - key: str | None = None, - target_cls: type | None = None, -) -> object: - """Instantiate from a recipe, mapping, model, dataclass, or file. - - .. deprecated:: - Use jsonargparse or :func:`~physicalai.config.base.parse_class_config` instead. - - Returns: - The constructed object. - - Raises: - TypeError: If ``config`` has an unsupported type. - """ - deprecate("physicalai.config.instantiate_obj", _REPLACEMENT) - if type(config) is Config: - return config.instantiate() - if isinstance(config, (str, Path)): - return instantiate_obj_from_file(config, key=key, target_cls=target_cls) - if isinstance(config, BaseModel): - return instantiate_obj_from_pydantic(config, key=key, target_cls=target_cls) - if dataclasses.is_dataclass(config) and not isinstance(config, type): - return instantiate_obj_from_dataclass(config, key=key, target_cls=target_cls) - if isinstance(config, Mapping): - return instantiate_obj_from_dict(config, key=key, target_cls=target_cls) - msg = f"Unsupported configuration type: {type(config)}. Expected dict, file path, Pydantic model, or dataclass." - raise TypeError(msg) diff --git a/src/physicalai/config/mixin.py b/src/physicalai/config/mixin.py deleted file mode 100644 index 3a7d2fbb..00000000 --- a/src/physicalai/config/mixin.py +++ /dev/null @@ -1,150 +0,0 @@ -# Copyright (C) 2026 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -"""Mixins for configuration-based construction. - -.. deprecated:: - Prefer :class:`jsonargparse.FromConfigMixin` and - :func:`~physicalai.config.base.parse_class_config`. Emits - :class:`DeprecationWarning` at runtime. -""" - -from __future__ import annotations - -import dataclasses -from collections.abc import Mapping -from pathlib import Path -from typing import Any, Self, TypeVar, cast - -from jsonargparse import FromConfigMixin -from pydantic import BaseModel - -from ._deprecate import deprecate -from .loading import instantiate_obj_from_dict, instantiate_obj_from_file -from .serializable import dataclass_to_dict - -__all__ = ["FromConfig", "from_config"] - -_T = TypeVar("_T", bound=type) - -_REPLACEMENT = "jsonargparse.FromConfigMixin.from_config or parse_class_config via physicalai.config.Config" - - -class FromConfig(FromConfigMixin): - """Mixin adding constructors for mapping, YAML, Pydantic, and dataclass configs. - - .. deprecated:: - Inherit :class:`jsonargparse.FromConfigMixin` instead. - """ - - @classmethod - def from_yaml(cls, file_path: str | Path, *, key: str | None = None) -> Self: - """Load configuration from YAML and instantiate the class. - - .. deprecated:: - Use :meth:`jsonargparse.FromConfigMixin.from_config` with a file path. - - Returns: - An instance of ``cls``. - """ - deprecate(f"{cls.__name__}.from_yaml", _REPLACEMENT) - return cast("Self", instantiate_obj_from_file(file_path, key=key, target_cls=cls)) - - @classmethod - def from_dict(cls, config: Mapping[str, Any], *, key: str | None = None) -> Self: - """Instantiate the class from a mapping. - - .. deprecated:: - Use :meth:`jsonargparse.FromConfigMixin.from_config` with a dict. - - Returns: - An instance of ``cls``. - """ - deprecate(f"{cls.__name__}.from_dict", _REPLACEMENT) - return cast("Self", instantiate_obj_from_dict(config, key=key, target_cls=cls)) - - @classmethod - def from_pydantic(cls, config: BaseModel, *, key: str | None = None, recursive: bool = False) -> Self: - """Instantiate the class from a Pydantic model. - - .. deprecated:: - Use :meth:`jsonargparse.FromConfigMixin.from_config` after ``model_dump()``. - - Returns: - An instance of ``cls``. - """ - deprecate(f"{cls.__name__}.from_pydantic", _REPLACEMENT) - values = ( - config.model_dump() - if recursive - else {name: getattr(config, name) for name in config.__class__.model_fields} - ) - return cls.from_dict(values, key=key) - - @classmethod - def from_dataclass(cls, config: object, *, key: str | None = None, recursive: bool = False) -> Self: - """Instantiate the class from a dataclass instance. - - .. deprecated:: - Use :meth:`jsonargparse.FromConfigMixin.from_config` with a dict. - - Returns: - An instance of ``cls``. - - Raises: - TypeError: If ``config`` is not a dataclass instance. - """ - deprecate(f"{cls.__name__}.from_dataclass", _REPLACEMENT) - if not dataclasses.is_dataclass(config) or isinstance(config, type): - msg = f"Expected dataclass instance, got {type(config)}" - raise TypeError(msg) - values = cast("dict[str, Any]", dataclass_to_dict(config, recursive=recursive)) - return cls.from_dict(values, key=key) - - @classmethod - def from_config( - cls, - config: Mapping[str, Any] | BaseModel | object | str | Path, - *, - key: str | None = None, - recursive: bool = False, - ) -> Self: - """Dispatch to the matching configuration constructor. - - .. deprecated:: - Use :meth:`jsonargparse.FromConfigMixin.from_config` instead. - - Returns: - An instance of ``cls``. - - Raises: - TypeError: If ``config`` has an unsupported type. - """ - deprecate(f"{cls.__name__}.from_config", "jsonargparse.FromConfigMixin.from_config") - if isinstance(config, (str, Path)): - return cls.from_yaml(config, key=key) - if isinstance(config, BaseModel): - return cls.from_pydantic(config, key=key, recursive=recursive) - if dataclasses.is_dataclass(config) and not isinstance(config, type): - return cls.from_dataclass(config, key=key, recursive=recursive) - if isinstance(config, Mapping): - if "class_path" in config and key is None: - return cast("Self", instantiate_obj_from_dict(config, target_cls=cls)) - return cls.from_dict(config, key=key) - msg = f"Unsupported configuration type: {type(config)}. Expected dict, file path, Pydantic model, or dataclass." - raise TypeError(msg) - - -def from_config(cls: _T) -> _T: - """Decorate a class with the constructors provided by :class:`FromConfig`. - - .. deprecated:: - Inherit :class:`jsonargparse.FromConfigMixin` instead. - - Returns: - The same class with ``from_*`` constructors attached. - """ - deprecate("@physicalai.config.from_config", "inherit jsonargparse.FromConfigMixin instead") - for name in ("from_yaml", "from_dict", "from_pydantic", "from_dataclass", "from_config"): - setattr(cls, name, FromConfig.__dict__[name]) - return cls diff --git a/src/physicalai/config/serializable.py b/src/physicalai/config/serializable.py deleted file mode 100644 index 2612b7ca..00000000 --- a/src/physicalai/config/serializable.py +++ /dev/null @@ -1,147 +0,0 @@ -# Copyright (C) 2026 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 - -"""Serialization utilities for typed dataclass configs. - -.. deprecated:: - Prefer :meth:`~physicalai.config.Config.to_dict` and - :meth:`~physicalai.config.Config.from_dict` (or jsonargparse). Emits - :class:`DeprecationWarning` at runtime. -""" - -from __future__ import annotations - -import dataclasses -import operator -import types -from enum import Enum -from functools import reduce -from itertools import starmap -from pathlib import PurePath -from typing import TYPE_CHECKING, TypeVar, Union, get_args, get_origin, get_type_hints - -from ._deprecate import deprecate - -if TYPE_CHECKING: - from collections.abc import Mapping - -_MIN_DICT_TYPE_ARGS = 2 -_VAR_TUPLE_ARG_COUNT = 2 -_T = TypeVar("_T") - -__all__ = ["dataclass_to_dict", "dict_to_dataclass"] - - -def dataclass_to_dict(obj: object, *, recursive: bool = True) -> object: # ruff: ignore[PLR0911] - """Convert a dataclass or nested structure to plain Python data. - - .. deprecated:: - Use :meth:`~physicalai.config.Config.to_dict` for typed configs. - - Returns: - Plain dicts, lists, and scalars suitable for ``torch.save(weights_only=True)``. - """ - deprecate( - "physicalai.config.serializable.dataclass_to_dict", - "Config.to_dict() for typed configs or jsonargparse parser.dump", - ) - if dataclasses.is_dataclass(obj) and not isinstance(obj, type): - if not recursive: - return {field.name: getattr(obj, field.name) for field in dataclasses.fields(obj)} - return {field.name: dataclass_to_dict(getattr(obj, field.name)) for field in dataclasses.fields(obj)} - if not recursive: - return obj - if isinstance(obj, dict): - return {(key.value if isinstance(key, Enum) else key): dataclass_to_dict(value) for key, value in obj.items()} - if isinstance(obj, (list, tuple)): - return [dataclass_to_dict(item) for item in obj] - if isinstance(obj, Enum): - return obj.value - if isinstance(obj, PurePath): - return str(obj) - if hasattr(obj, "tolist") and hasattr(obj, "ndim"): - return obj.tolist() # type: ignore[union-attr] - return obj - - -def dict_to_dataclass(cls: type[_T], data: Mapping[str, object], *, strict: bool = True) -> _T: - """Reconstruct a dataclass from a mapping using its type hints. - - .. deprecated:: - Use :meth:`~physicalai.config.Config.from_dict` or - :func:`~physicalai.config.base.parse_class_config`. - - Args: - cls: Dataclass type to construct. - data: Field values (typically from YAML or a checkpoint). - strict: When ``True``, reject keys that are not dataclass fields. - - Returns: - An instance of ``cls``. - - Raises: - TypeError: If ``cls`` is not a dataclass or ``strict`` rejects extra keys. - """ - deprecate( - "physicalai.config.serializable.dict_to_dataclass", - "Config.from_dict() for Config subclasses or parse_class_config", - ) - if not dataclasses.is_dataclass(cls): - msg = f"Expected dataclass, got {cls}" - raise TypeError(msg) - if strict: - field_names = {field.name for field in dataclasses.fields(cls)} - extras = set(data.keys()) - field_names - if extras: - msg = f"Unexpected keys for {cls.__name__}: {sorted(extras)}" - raise TypeError(msg) - try: - hints = get_type_hints(cls) - except (NameError, TypeError, AttributeError, KeyError): - hints = {} - kwargs = {} - for field in dataclasses.fields(cls): - if field.name in data: - kwargs[field.name] = _reconstruct_value(data[field.name], hints.get(field.name, field.type)) - return cls(**kwargs) # type: ignore[return-value] - - -def _reconstruct_value(value: object, field_type: object) -> object: # ruff: ignore[PLR0911] - if value is None: - return None - origin = get_origin(field_type) - args = get_args(field_type) - if _is_optional_type(field_type): - return _reconstruct_value(value, _get_optional_inner_type(field_type)) - if origin is dict and isinstance(value, dict): - if len(args) >= _MIN_DICT_TYPE_ARGS: - return {key: _reconstruct_value(item, args[1]) for key, item in value.items()} - return value - if origin is list and isinstance(value, list): - return [_reconstruct_value(item, args[0]) for item in value] if args else value - if origin is tuple and isinstance(value, list): - if not args: - return tuple(value) - if len(args) == _VAR_TUPLE_ARG_COUNT and args[1] is ...: - return tuple(_reconstruct_value(item, args[0]) for item in value) - return tuple(starmap(_reconstruct_value, zip(value, args, strict=False))) - actual_type = origin or field_type - if isinstance(actual_type, type) and dataclasses.is_dataclass(actual_type) and isinstance(value, dict): - return dict_to_dataclass(actual_type, value) - if isinstance(actual_type, type) and issubclass(actual_type, PurePath) and isinstance(value, str): - return actual_type(value) - if isinstance(actual_type, type) and issubclass(actual_type, Enum) and not isinstance(value, Enum): - return actual_type(value) - return value - - -def _is_optional_type(field_type: object) -> bool: - origin = get_origin(field_type) - return origin in {types.UnionType, Union} and type(None) in get_args(field_type) - - -def _get_optional_inner_type(field_type: object) -> object: - non_none_args = [arg for arg in get_args(field_type) if arg is not type(None)] - if len(non_none_args) == 1: - return non_none_args[0] - return reduce(operator.or_, non_none_args) diff --git a/src/physicalai/inference/_importing.py b/src/physicalai/inference/_importing.py index 8d277b20..85b7e6fd 100644 --- a/src/physicalai/inference/_importing.py +++ b/src/physicalai/inference/_importing.py @@ -3,13 +3,14 @@ """Dotted-path imports for inference components. -Canonical implementation lives in :mod:`physicalai.config.importing`. This +Canonical implementation lives in :mod:`physicalai.config._importing`. This module re-exports for backward-compatible imports without loading export or instantiate. """ from __future__ import annotations -from physicalai.config.importing import import_dotted_path +# This private re-export keeps inference imports independent of config construction. +from physicalai.config._importing import import_dotted_path # ruff: ignore[PLC2701] __all__ = ["import_dotted_path"] diff --git a/src/physicalai/robot/so101/calibration.py b/src/physicalai/robot/so101/calibration.py index 1f0cbad2..3e25bbb3 100644 --- a/src/physicalai/robot/so101/calibration.py +++ b/src/physicalai/robot/so101/calibration.py @@ -72,7 +72,7 @@ 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`. + """Encode as a constructor-compatible dict for :func:`~physicalai.config.Config.from_instance`. Returns: The LeRobot calibration mapping produced by :meth:`to_dict`. diff --git a/src/physicalai/robot/transport/_importing.py b/src/physicalai/robot/transport/_importing.py index 00c8b452..6abb28c0 100644 --- a/src/physicalai/robot/transport/_importing.py +++ b/src/physicalai/robot/transport/_importing.py @@ -3,13 +3,14 @@ """Dotted-path imports for robot owner configuration. -Canonical implementation lives in :mod:`physicalai.config.importing`. This +Canonical implementation lives in :mod:`physicalai.config._importing`. This module re-exports for backward-compatible imports without loading export or instantiate. """ from __future__ import annotations -from physicalai.config.importing import import_dotted_path +# This private re-export keeps transport imports independent of config construction. +from physicalai.config._importing import import_dotted_path # ruff: ignore[PLC2701] __all__ = ["import_dotted_path"] diff --git a/src/physicalai/robot/transport/_shared_robot.py b/src/physicalai/robot/transport/_shared_robot.py index 8acac3d2..a42e3716 100644 --- a/src/physicalai/robot/transport/_shared_robot.py +++ b/src/physicalai/robot/transport/_shared_robot.py @@ -194,7 +194,7 @@ class SharedRobot: Opted into :func:`~physicalai.config.export_config` as a **construction recipe** only (name, nested ``robot`` Config, transport knobs). Connection / Zenoh session / publisher state is never part of - :func:`~physicalai.config.to_config`. + :func:`~physicalai.config.Config.from_instance`. Args: name: Required logical name — keys the Zenoh topics directly. Two diff --git a/tests/unit/capture/test_factory.py b/tests/unit/capture/test_factory.py index 09493ea6..b1a50a5e 100644 --- a/tests/unit/capture/test_factory.py +++ b/tests/unit/capture/test_factory.py @@ -55,7 +55,7 @@ class TestShareableClassPaths: def test_matches_export_config_when_importable(self, token: str) -> None: """Keeps the hand-written table honest when the extra is installed.""" from physicalai.config._export import resolve_public_class_path - from physicalai.config.importing import import_dotted_path + from physicalai.config._importing import import_dotted_path class_path = _SHAREABLE_CLASS_PATHS[token] driver: object = None diff --git a/tests/unit/config/test_export_config.py b/tests/unit/config/test_export_config.py index b302fb40..b09cc9bf 100644 --- a/tests/unit/config/test_export_config.py +++ b/tests/unit/config/test_export_config.py @@ -26,9 +26,8 @@ export_config, ) from physicalai.config._types import ConfigExportable -from physicalai.config.importing import import_dotted_path +from physicalai.config._importing import import_dotted_path -to_config = Config.from_instance is_config_exportable = Config.is_exportable @@ -312,24 +311,24 @@ def test_empty_segment_raises_value_error(self, path: str) -> None: class TestNormalizeAndInstantiate: def test_primitives_round_trip(self) -> None: point = Point(1, y=2) - config = to_config(point) + config = Config.from_instance(point) wire = json.loads(json.dumps(config.to_dict())) restored = instantiate(wire) assert isinstance(restored, Point) assert restored.x == 1 assert restored.y == 2 - assert to_config(restored) == wire + assert Config.from_instance(restored) == wire def test_omitted_defaults_stay_omitted(self) -> None: point = Point(3) - config = to_config(point) + config = Config.from_instance(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) + config = Config.from_instance(obj) assert config["init_args"] == {"name": None} restored = cast("OptionalName", instantiate(config)) assert restored.name is None @@ -337,14 +336,14 @@ def test_explicit_none_is_preserved(self) -> 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" + assert Config.from_instance(relative)["init_args"]["path"] == "calib.json" + assert Config.from_instance(absolute)["init_args"]["path"] == "/var/calib.json" str_relative = PathHolder("./relative.json") - assert to_config(str_relative)["init_args"]["path"] == "./relative.json" + assert Config.from_instance(str_relative)["init_args"]["path"] == "./relative.json" def test_enum_value(self) -> None: holder = EnumHolder(Color.RED) - config = to_config(holder) + config = Config.from_instance(holder) assert config["init_args"]["color"] == "RED" assert not isinstance(config["init_args"]["color"], Color) restored = cast("EnumHolder", config.instantiate(expected_type=EnumHolder)) @@ -360,21 +359,21 @@ def test_enum_round_trip_without_expected_type(self) -> None: def test_non_finite_float_rejected(self) -> None: holder = MappingHolder({"x": math.nan}) with pytest.raises(ConfigError, match="non-finite"): - to_config(holder) + Config.from_instance(holder) def test_nested_component(self) -> None: box = Box(Point(1, 2), label="b") - config = to_config(box) + config = Config.from_instance(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.to_dict())))) assert restored.origin.x == 1 - assert to_config(restored) == json.loads(json.dumps(config.to_dict())) + assert Config.from_instance(restored) == json.loads(json.dumps(config.to_dict())) def test_list_and_mapping(self) -> None: holder = ListHolder([Point(1), {"a": 1}]) - config = to_config(holder) + config = Config.from_instance(holder) items = _as_list(config["init_args"]["items"]) first = _as_mapping(items[0]) assert _as_mapping(first["init_args"])["x"] == 1 @@ -386,14 +385,14 @@ 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 + assert _as_mapping(Config.from_instance(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(ConfigError, match="cyclic"): - to_config(holder) + Config.from_instance(holder) def test_depth_limit_on_mappings(self) -> None: nested: dict[str, object] = {"leaf": 1} @@ -401,7 +400,7 @@ def test_depth_limit_on_mappings(self) -> None: nested = {"child": nested} holder = MappingHolder(nested) with pytest.raises(ConfigError, match="nesting depth"): - to_config(holder) + Config.from_instance(holder) def test_nested_component_depth_symmetric(self) -> None: """Export and instantiate share the same nested-component depth limit. @@ -419,14 +418,14 @@ def nest_chain(length: int) -> Nest: return node allowed = nest_chain(_MAX_CONFIG_DEPTH) - config = to_config(allowed) + config = Config.from_instance(allowed) restored = instantiate(config) assert isinstance(restored, Nest) - assert to_config(restored) == config + assert Config.from_instance(restored) == config too_deep = nest_chain(_MAX_CONFIG_DEPTH + 1) with pytest.raises(ConfigError, match="nesting depth"): - to_config(too_deep) + Config.from_instance(too_deep) with pytest.raises(ConfigError, match="nesting depth"): Config( @@ -437,7 +436,7 @@ def nest_chain(length: int) -> Nest: def test_nested_error_path_includes_parent(self) -> None: outer = Outer(BadInner(lambda: None)) with pytest.raises(ConfigError, match=r"Outer\.init_args\.child\.init_args\.fn"): - to_config(outer) + Config.from_instance(outer) def test_malformed_nested_config_before_import(self) -> None: with pytest.raises(ConfigError, match="unexpected keys"): @@ -469,12 +468,12 @@ def test_unimportable_class_path(self) -> None: def test_dict_with_class_path_is_reserved(self) -> None: holder = MappingHolder({"class_path": "not.a.component", "other": 1}) with pytest.raises(ConfigError, match="to_config_value"): - to_config(holder) + Config.from_instance(holder) def test_unsupported_object_reports_path(self) -> None: holder = MappingHolder({"fn": lambda: None}) with pytest.raises(ConfigError, match=r"init_args\.data\.fn"): - to_config(holder) + Config.from_instance(holder) def test_local_class_export_fails(self) -> None: @export_config @@ -484,7 +483,7 @@ def __init__(self, x: int) -> None: obj = LocalPoint(1) with pytest.raises(ConfigError, match=""): - to_config(obj) + Config.from_instance(obj) def test_local_class_instantiate_fails(self) -> None: with pytest.raises(ConfigError, match=""): @@ -583,11 +582,11 @@ def test_instantiate_helper_accepts_config_recipe(self) -> None: class TestExportConfig: def test_positional_binds_to_names(self) -> None: point = Point(4, 5) - assert to_config(point)["init_args"] == {"x": 4, "y": 5} + assert Config.from_instance(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} + assert Config.from_instance(obj)["init_args"] == {"base": 1, "color": "red", "count": 2} def test_rejects_var_positional(self) -> None: with pytest.raises(TypeError, match=r"\*args"): @@ -614,7 +613,7 @@ class Redundant(BaseWidget): def test_outermost_super_wins(self) -> None: widget = DerivedWidget("w", 10) - config = to_config(widget) + config = Config.from_instance(widget) assert config["class_path"].endswith(".DerivedWidget") assert config["init_args"] == {"name": "w", "size": 10} @@ -622,12 +621,12 @@ def test_undecorated_override_fails(self) -> None: obj = UndecoratedOverride("n", 1) assert not is_config_exportable(obj) with pytest.raises(ConfigError, match="not config-exportable"): - to_config(obj) + Config.from_instance(obj) def test_inherited_decorated_constructor(self) -> None: obj = InheritsDecorated("ok") assert is_config_exportable(obj) - config = to_config(obj) + config = Config.from_instance(obj) assert config["class_path"].endswith(".InheritsDecorated") assert config["init_args"] == {"name": "ok"} @@ -638,7 +637,7 @@ def test_failed_constructor_does_not_capture(self) -> None: def test_explicit_class_path_override(self) -> None: obj = _HiddenExport(3) assert is_config_exportable(obj) - config = to_config(obj) + config = Config.from_instance(obj) assert config["class_path"] == "tests.unit.config.test_export_config.ExportAlias" assert config["init_args"] == {"x": 3} restored = instantiate(config) @@ -648,7 +647,7 @@ def test_explicit_class_path_override(self) -> None: def test_inherited_class_path_override_does_not_leak(self) -> None: obj = InheritsAliasedExport(9) assert is_config_exportable(obj) - config = to_config(obj) + config = Config.from_instance(obj) assert config["class_path"] == ("tests.unit.config.test_export_config.InheritsAliasedExport") assert config["init_args"] == {"x": 9} restored = instantiate(config) @@ -656,16 +655,16 @@ def test_inherited_class_path_override_does_not_leak(self) -> None: def test_domain_value_hook_encodes_to_json(self) -> None: holder = DomainHolder(DomainPayload(42)) - config = to_config(holder) + config = Config.from_instance(holder) assert config["init_args"]["payload"] == {"amount": 42} wire = json.loads(json.dumps(config.to_dict())) restored = cast("DomainHolder", instantiate(wire)) assert restored.payload == {"amount": 42} - assert to_config(restored) == wire + assert Config.from_instance(restored) == wire def test_domain_value_none_is_json_null(self) -> None: holder = DomainHolder(NullDomain()) - config = to_config(holder) + config = Config.from_instance(holder) assert config["init_args"]["payload"] is None wire = json.loads(json.dumps(config.to_dict())) restored = cast("DomainHolder", instantiate(wire)) @@ -678,17 +677,17 @@ def test_domain_value_codec_cycle_raises(self) -> None: right.other = left holder = DomainHolder(left) with pytest.raises(ConfigError, match="cyclic to_config_value"): - to_config(holder) + Config.from_instance(holder) def test_domain_value_hook_output_is_renormalized(self) -> None: holder = DomainHolder(BadNanDomain()) with pytest.raises(ConfigError, match="non-finite"): - to_config(holder) + Config.from_instance(holder) def test_domain_value_hook_reserved_class_path_validated(self) -> None: holder = DomainHolder(BadReservedDomain()) with pytest.raises(ConfigError, match="to_config_value"): - to_config(holder) + Config.from_instance(holder) def test_export_config_injects_as_config_api(self) -> None: point = Point(1, 2) @@ -715,15 +714,15 @@ def test_private_capture_normalizer_canonicalizes_supplied_args_only(self) -> No CanonicalName.normalize_calls = 0 explicit = CanonicalName("LOUD") omitted = CanonicalName() - assert to_config(explicit)["init_args"] == {"name": "loud"} - assert to_config(omitted)["init_args"] == {} + assert Config.from_instance(explicit)["init_args"] == {"name": "loud"} + assert Config.from_instance(omitted)["init_args"] == {} assert CanonicalName.normalize_calls == 2 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) + config = Config.from_instance(obj) assert config["init_args"] == { "base": 1, "count": 2, @@ -738,17 +737,17 @@ def test_scalar_var_kwargs_round_trip(self) -> None: def test_non_scalar_dict_var_kwarg_fails(self) -> None: obj = ScalarVarKwargs(1, config_blob={"a": 1}) with pytest.raises(ConfigError, match=r"init_args\.config_blob"): - to_config(obj) + Config.from_instance(obj) def test_non_scalar_list_var_kwarg_fails(self) -> None: obj = ScalarVarKwargs(1, tags=["x", "y"]) with pytest.raises(ConfigError, match=r"init_args\.tags"): - to_config(obj) + Config.from_instance(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} + assert Config.from_instance(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"): @@ -775,11 +774,11 @@ def test_declared_config_arg_is_not_instantiated(self) -> None: def test_declared_config_arg_round_trips(self) -> None: recipe = Config(f"{__name__}.Leaf", {"value": 3}) holder = Holder(recipe=recipe, eager=Leaf(value=3)) - config = to_config(holder) + config = Config.from_instance(holder) assert config["init_args"]["recipe"] == recipe.to_dict() wire = json.loads(json.dumps(config.to_dict())) restored = cast("Holder", instantiate(wire)) - assert to_config(restored) == wire + assert Config.from_instance(restored) == wire def test_unknown_config_arg_name_rejected(self) -> None: with pytest.raises(TypeError, match="config_args 'missing' is not an __init__ parameter"): diff --git a/tests/unit/config/test_from_config_and_instantiate.py b/tests/unit/config/test_from_config_and_instantiate.py deleted file mode 100644 index af1ff610..00000000 --- a/tests/unit/config/test_from_config_and_instantiate.py +++ /dev/null @@ -1,276 +0,0 @@ -# Copyright (C) 2026 Intel Corporation -# SPDX-License-Identifier: Apache-2.0 -# ruff:file-ignore[missing-type-function-argument, missing-return-type-undocumented-public-function, class-as-data-structure, undocumented-public-init, magic-value-comparison, no-self-use, float-equality-comparison, assert] - -"""Tests for FromConfig, instantiate_obj, and typed dataclass Config.""" - -from __future__ import annotations - -import dataclasses -from dataclasses import dataclass, field -from enum import StrEnum -from typing import Any, cast - -import numpy as np -import pytest -from pydantic import BaseModel - -from physicalai.config import Config, from_config -from physicalai.config.loading import import_class, instantiate_obj -from physicalai.config.mixin import FromConfig - - -class SampleModel(FromConfig): - def __init__(self, hidden_size: int, num_layers: int = 3, **kwargs: object) -> None: - self.hidden_size = hidden_size - self.num_layers = num_layers - self.kwargs = kwargs - - -class SampleModelConfig(BaseModel): - hidden_size: int = 128 - num_layers: int = 3 - - -@dataclass -class SampleModelDataclassConfig(Config): - hidden_size: int = 128 - num_layers: int = 3 - - -class NestedComponent: - def __init__(self, value: int) -> None: - self.value = value - - -class ParentModel(FromConfig): - def __init__(self, component: NestedComponent, components: list[NestedComponent] | None = None) -> None: - self.component = component - self.components = components or [] - - -@from_config -class DecoratedModel: - def __init__(self, hidden_size: int, num_layers: int = 3) -> None: - self.hidden_size = hidden_size - self.num_layers = num_layers - - -@dataclasses.dataclass -class SampleModelDataclass: - hidden_size: int = 128 - num_layers: int = 3 - - -class ActivationType(StrEnum): - RELU = "relu" - GELU = "gelu" - - -@dataclass -class SimpleConfig(Config): - hidden_size: int = 128 - num_layers: int = 3 - - -@dataclass -class NestedConfig(Config): - model: SimpleConfig = field(default_factory=SimpleConfig) - learning_rate: float = 0.001 - - -@dataclass -class ComplexConfig(Config): - activation: ActivationType = ActivationType.RELU - layers: tuple = (64, 128) - weights: np.ndarray = field(default_factory=lambda: np.array([1.0, 2.0])) - - -class TestInstantiateObj: - def test_from_dict(self) -> None: - result = instantiate_obj({"class_path": "builtins.dict", "init_args": {"key": "value"}}) - assert result == {"key": "value"} - - def test_from_dict_with_key(self) -> None: - config = {"model": {"class_path": "builtins.dict", "init_args": {"size": 128}}} - assert instantiate_obj(config, key="model") == {"size": 128} - - def test_nested_instantiation(self) -> None: - config = { - "class_path": "builtins.dict", - "init_args": {"nested": {"class_path": "builtins.dict", "init_args": {"k": "v"}}}, - } - result = instantiate_obj(config) - assert isinstance(result, dict) - assert result["nested"] == {"k": "v"} - - def test_from_file(self, tmp_path) -> None: - (tmp_path / "config.yaml").write_text("class_path: builtins.dict\ninit_args:\n key: value") - assert instantiate_obj(tmp_path / "config.yaml") == {"key": "value"} - - def test_missing_class_path_raises(self) -> None: - with pytest.raises(ValueError, match="class_path"): - instantiate_obj({"init_args": {}}) - - def test_invalid_import_raises(self) -> None: - with pytest.raises(ImportError): - import_class("nonexistent.module.Class") - - def test_import_class_imports_symbol(self) -> None: - assert import_class("builtins.dict") is dict - - -class TestFromConfigMixin: - def test_from_dict(self) -> None: - model = SampleModel.from_dict({"hidden_size": 256, "num_layers": 4}) - assert model.hidden_size == 256 - assert model.num_layers == 4 - - def test_from_dict_with_key(self) -> None: - model = SampleModel.from_dict({"model": {"hidden_size": 512, "num_layers": 6}}, key="model") - assert model.hidden_size == 512 - - def test_from_pydantic(self) -> None: - model = SampleModel.from_pydantic(SampleModelConfig(hidden_size=256)) - assert model.hidden_size == 256 - - def test_from_dataclass(self) -> None: - model = SampleModel.from_dataclass(SampleModelDataclass(hidden_size=512)) - assert model.hidden_size == 512 - - def test_from_yaml(self, tmp_path) -> None: - (tmp_path / "config.yaml").write_text("hidden_size: 1024\nnum_layers: 8") - model = SampleModel.from_yaml(tmp_path / "config.yaml") - assert model.hidden_size == 1024 - - def test_from_config_unified(self) -> None: - assert SampleModel.from_config({"hidden_size": 128, "num_layers": 3}).hidden_size == 128 - assert SampleModel.from_config(SampleModelConfig()).hidden_size == 128 - assert SampleModel.from_config(SampleModelDataclass()).hidden_size == 128 - - def test_concrete_class_accepts_jsonargparse_config(self) -> None: - config = { - "class_path": f"{SampleModel.__module__}.SampleModel", - "init_args": {"hidden_size": 256, "num_layers": 4}, - } - model = SampleModel.from_config(config) - assert model.hidden_size == 256 - assert model.num_layers == 4 - - def test_concrete_class_accepts_config_dataclass(self) -> None: - model = SampleModel.from_config(SampleModelDataclassConfig(hidden_size=384, num_layers=5)) - assert model.hidden_size == 384 - assert model.num_layers == 5 - - def test_nested_class_path_values_in_direct_args(self) -> None: - model = ParentModel.from_config( - { - "component": { - "class_path": f"{NestedComponent.__module__}.NestedComponent", - "init_args": {"value": 10}, - }, - "components": [ - { - "class_path": f"{NestedComponent.__module__}.NestedComponent", - "init_args": {"value": 20}, - }, - ], - }, - ) - assert isinstance(model.component, NestedComponent) - assert model.component.value == 10 - assert isinstance(model.components[0], NestedComponent) - assert model.components[0].value == 20 - - def test_from_config_decorator(self) -> None: - decorated_model_cls = cast("Any", DecoratedModel) - model = decorated_model_cls.from_config({"hidden_size": 512, "num_layers": 6}) - assert model.hidden_size == 512 - assert model.num_layers == 6 - - def test_from_config_decorator_with_yaml(self, tmp_path) -> None: - decorated_model_cls = cast("Any", DecoratedModel) - path = tmp_path / "decorated.yaml" - path.write_text("hidden_size: 640\nnum_layers: 7") - model = decorated_model_cls.from_config(path) - assert model.hidden_size == 640 - assert model.num_layers == 7 - - def test_recursive_parameter(self) -> None: - @dataclass - class Nested: - size: int = 64 - - @dataclass - class Parent: - hidden_size: int = 128 - nested: Nested = field(default_factory=Nested) - - class Model(FromConfig): - def __init__(self, hidden_size: int, nested: Nested | None = None) -> None: - self.hidden_size = hidden_size - self.nested = nested - - parent = Parent() - assert isinstance(Model.from_dataclass(parent, recursive=False).nested, Nested) - assert isinstance(Model.from_dataclass(parent, recursive=True).nested, dict) - - -class TestConfigSerialization: - def test_to_jsonargparse(self) -> None: - result = SimpleConfig(hidden_size=256).to_jsonargparse() - assert "class_path" in result - assert result["init_args"]["hidden_size"] == 256 - - def test_to_dict(self) -> None: - result = SimpleConfig(hidden_size=256).to_dict() - assert "class_path" not in result - assert result["hidden_size"] == 256 - - def test_from_dict(self) -> None: - config = SimpleConfig.from_dict({"hidden_size": 512, "num_layers": 8}) - assert config.hidden_size == 512 - - def test_from_dict_nested(self) -> None: - config = NestedConfig.from_dict({"model": {"hidden_size": 256, "num_layers": 4}, "learning_rate": 0.01}) - assert isinstance(config.model, SimpleConfig) - assert config.model.hidden_size == 256 - - def test_round_trip(self) -> None: - original = NestedConfig(model=SimpleConfig(hidden_size=512), learning_rate=0.005) - restored = NestedConfig.from_dict(original.to_dict()) - assert restored.model.hidden_size == 512 - assert restored.learning_rate == 0.005 - - def test_type_conversions(self) -> None: - result = ComplexConfig( - activation=ActivationType.GELU, - layers=(32, 64), - weights=np.array([[1.0, 2.0]]), - ).to_jsonargparse() - assert result["init_args"]["activation"] == "GELU" - assert result["init_args"]["layers"] == [32, 64] - assert result["init_args"]["weights"] == [[1.0, 2.0]] - - -class TestConfigSaveLoad: - def test_save_load_jsonargparse(self, tmp_path) -> None: - path = tmp_path / "config.yaml" - SimpleConfig(hidden_size=256).save(path) - assert SimpleConfig.load(path).hidden_size == 256 - - def test_save_load_dict_format(self, tmp_path) -> None: - path = tmp_path / "config.yaml" - SimpleConfig(hidden_size=512).save(path, format="dict") - assert SimpleConfig.load(path).hidden_size == 512 - - def test_invalid_extension_raises(self, tmp_path) -> None: - with pytest.raises(ValueError, match="Unsupported file extension"): - SimpleConfig().save(tmp_path / "config.json") - - def test_not_dataclass_raises(self) -> None: - class NotDataclass(Config): - pass - - with pytest.raises(TypeError, match="Config subclasses must be dataclasses"): - NotDataclass("builtins.dict") diff --git a/tests/unit/config/test_yaml.py b/tests/unit/config/test_yaml.py deleted file mode 100644 index 7b5dec73..00000000 --- a/tests/unit/config/test_yaml.py +++ /dev/null @@ -1,93 +0,0 @@ -# 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, magic-value-comparison, no-self-use, assert] - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pytest -import yaml - -from physicalai.config import ( - ConfigError, - export_config, - instantiate, - load_yaml, - save_yaml, - to_config, - to_yaml, -) - -if TYPE_CHECKING: - from pathlib import Path - - -@export_config -class Gadget: - def __init__(self, size: int, label: str = "gadget") -> None: - self.size = size - self.label = label - - -@export_config -class Holder: - def __init__(self, gadget: Gadget, note: str | None = None) -> None: - self.gadget = gadget - self.note = note - - -class TestToYaml: - def test_live_component_round_trips_through_yaml(self) -> None: - holder = Holder(Gadget(3, label="inner"), note="hi") - - text = to_yaml(holder) - rebuilt = instantiate(yaml.safe_load(text)) - - assert isinstance(rebuilt, Holder) - assert rebuilt.note == "hi" - assert rebuilt.gadget.size == 3 - assert rebuilt.gadget.label == "inner" - - def test_accepts_existing_config_mapping(self) -> None: - config = to_config(Gadget(7)) - - text = to_yaml(config) - loaded = yaml.safe_load(text) - - assert loaded == {"class_path": f"{__name__}.Gadget", "init_args": {"size": 7}} - - def test_rejects_malformed_mapping(self) -> None: - with pytest.raises(ConfigError, match="class_path"): - to_yaml({"init_args": {"size": 1}}) - - def test_rejects_non_exportable_object(self) -> None: - with pytest.raises(ConfigError): - to_yaml(object()) - - -class TestSaveLoadYaml: - def test_save_then_load_then_instantiate(self, tmp_path: Path) -> None: - target = tmp_path / "gadget.yaml" - save_yaml(Gadget(5), target) - - loaded = load_yaml(target) - rebuilt = instantiate(loaded) - - assert isinstance(rebuilt, Gadget) - assert rebuilt.size == 5 - assert rebuilt.label == "gadget" - - def test_load_rejects_non_mapping_document(self, tmp_path: Path) -> None: - target = tmp_path / "list.yaml" - target.write_text("- 1\n- 2\n", encoding="utf-8") - - with pytest.raises(ConfigError, match="must be a mapping"): - load_yaml(target) - - def test_load_empty_file_as_empty_mapping(self, tmp_path: Path) -> None: - target = tmp_path / "empty.yaml" - target.write_text("", encoding="utf-8") - - with pytest.raises(ConfigError, match="class_path"): - load_yaml(target) diff --git a/tests/unit/runtime/test_runtime_config.py b/tests/unit/runtime/test_runtime_config.py index d05160ba..2747587e 100644 --- a/tests/unit/runtime/test_runtime_config.py +++ b/tests/unit/runtime/test_runtime_config.py @@ -518,7 +518,7 @@ def test_bare_component_export_round_trips( from physicalai.runtime import RobotRuntime runtime = _make_full_runtime(inference_model) - # The bare to_config shape (top-level class_path) — no manual rewrite + # The bare Config recipe shape (top-level class_path) — no manual rewrite # to ``runtime:`` needed on either load path. cfg_path = tmp_path / "runtime_export.json" cfg_path.write_text(json.dumps(Config.from_instance(runtime).to_dict())) From f4ece11da35eb57814a100c079ba26bd610e000c Mon Sep 17 00:00:00 2001 From: Samet Date: Sat, 22 Aug 2026 09:09:14 +0200 Subject: [PATCH 2/4] docs(config): fix Config.from_instance references --- src/physicalai/capture/transport/_shared_camera.py | 2 +- src/physicalai/robot/so101/calibration.py | 2 +- src/physicalai/robot/transport/_shared_robot.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/physicalai/capture/transport/_shared_camera.py b/src/physicalai/capture/transport/_shared_camera.py index a7519186..9a88ec74 100644 --- a/src/physicalai/capture/transport/_shared_camera.py +++ b/src/physicalai/capture/transport/_shared_camera.py @@ -101,7 +101,7 @@ class SharedCamera(Camera): Opted into :func:`~physicalai.config.export_config` as a **construction recipe** only (nested ``camera`` Config, ``service_name``, ``color_mode``, transport knobs). Publisher / iceoryx2 session / frame - state is never part of :func:`~physicalai.config.Config.from_instance`. + state is never part of :meth:`~physicalai.config.Config.from_instance`. The publisher subprocess owns the device exclusively. Another connected holder of the same hardware will cause open to fail; this API does not diff --git a/src/physicalai/robot/so101/calibration.py b/src/physicalai/robot/so101/calibration.py index 3e25bbb3..703ab43f 100644 --- a/src/physicalai/robot/so101/calibration.py +++ b/src/physicalai/robot/so101/calibration.py @@ -72,7 +72,7 @@ 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.Config.from_instance`. + """Encode as a constructor-compatible dict for :meth:`~physicalai.config.Config.from_instance`. Returns: The LeRobot calibration mapping produced by :meth:`to_dict`. diff --git a/src/physicalai/robot/transport/_shared_robot.py b/src/physicalai/robot/transport/_shared_robot.py index a42e3716..5d0f4389 100644 --- a/src/physicalai/robot/transport/_shared_robot.py +++ b/src/physicalai/robot/transport/_shared_robot.py @@ -194,7 +194,7 @@ class SharedRobot: Opted into :func:`~physicalai.config.export_config` as a **construction recipe** only (name, nested ``robot`` Config, transport knobs). Connection / Zenoh session / publisher state is never part of - :func:`~physicalai.config.Config.from_instance`. + :meth:`~physicalai.config.Config.from_instance`. Args: name: Required logical name — keys the Zenoh topics directly. Two From fd096260e0557312025853c548b515dc0954b801 Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Mon, 21 Sep 2026 11:23:25 +0100 Subject: [PATCH 3/4] fix(config): preserve public dotted-path import Signed-off-by: Samet Akcay --- src/physicalai/config/__init__.py | 2 ++ tests/unit/config/test_importing.py | 36 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 tests/unit/config/test_importing.py diff --git a/src/physicalai/config/__init__.py b/src/physicalai/config/__init__.py index 860c16cb..4e3c55c4 100644 --- a/src/physicalai/config/__init__.py +++ b/src/physicalai/config/__init__.py @@ -28,6 +28,7 @@ is_config_exportable, resolve_public_class_path, ) +from ._importing import import_dotted_path from ._instantiate import instantiate as _strict_instantiate from ._normalize import validate_config from ._types import ConfigValue, JsonScalar, JsonValue @@ -43,6 +44,7 @@ "JsonScalar", "JsonValue", "export_config", + "import_dotted_path", "instantiate", "is_config_exportable", "resolve_public_class_path", diff --git a/tests/unit/config/test_importing.py b/tests/unit/config/test_importing.py new file mode 100644 index 00000000..be9b9285 --- /dev/null +++ b/tests/unit/config/test_importing.py @@ -0,0 +1,36 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 +# ruff: file-ignore[undocumented-public-module, undocumented-public-function, assert] + +from __future__ import annotations + +import physicalai.config +from physicalai.config import import_dotted_path + + +class _Target: + pass + + +def test_import_dotted_path_remains_public() -> None: + resolved = import_dotted_path("tests.unit.config.test_importing._Target") + + assert resolved is _Target + assert "import_dotted_path" in physicalai.config.__all__ + + +def test_deprecated_configuration_shims_are_not_restored() -> None: + removed_names = { + "FromConfig", + "from_config", + "import_class", + "instantiate_obj", + "load_yaml", + "normalize_config", + "save_yaml", + "to_config", + "to_yaml", + "validate_envelope", + } + + assert removed_names.isdisjoint(vars(physicalai.config)) From cd39f60203ce3e3a3fd4d93ad2c64f6b22c9c430 Mon Sep 17 00:00:00 2001 From: Samet Akcay Date: Mon, 21 Sep 2026 12:04:05 +0100 Subject: [PATCH 4/4] feat(config): add Config.to_yaml Signed-off-by: Samet Akcay --- src/physicalai/config/base.py | 12 ++++++++++++ tests/unit/config/test_config.py | 11 +++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/physicalai/config/base.py b/src/physicalai/config/base.py index 454a5584..c7a331ae 100644 --- a/src/physicalai/config/base.py +++ b/src/physicalai/config/base.py @@ -238,6 +238,18 @@ def to_jsonargparse(self) -> JsonArgparseEnvelope: "init_args": cast("dict[str, JsonValue]", _plain_value(self)), } + def to_yaml(self) -> str: + """Convert this config to its YAML serialization form. + + Direct recipes use the ``class_path``/``init_args`` recipe envelope; + typed dataclass configs use their jsonargparse envelope. + + Returns: + A YAML string suitable for :meth:`Config.load`. + """ + data = self.to_dict() if type(self) is Config else self.to_jsonargparse() + return yaml.safe_dump(data, sort_keys=False, default_flow_style=False) + def save( self, path: str | Path, diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index fbcf802c..4dbd5a5b 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -9,6 +9,8 @@ from pathlib import Path import pytest +import yaml + from physicalai.config import Config, ConfigError, export_config @@ -46,6 +48,13 @@ def test_direct_recipe_round_trip(tmp_path: Path) -> None: config.save(path) restored = Config.load(path) + assert config.to_yaml() == path.read_text() + assert config.to_yaml() == ( + f"class_path: {__name__}.Target\n" + "init_args:\n" + " value: 7\n" + ) + assert Config.load(yaml.safe_load(config.to_yaml())).to_dict() == config.to_dict() assert restored.to_dict() == config.to_dict() assert isinstance(restored.instantiate(), Target) @@ -59,6 +68,8 @@ def test_typed_dataclass_semantics(tmp_path: Path) -> None: saved_text = path.read_text() assert restored == config + assert saved_text == config.to_yaml() + assert TypedConfig.load(yaml.safe_load(config.to_yaml())) == config assert config.to_dict() == {"nested": {"value": 3}, "mode": "FAST", "shape": [2, 4]} assert config.to_jsonargparse()["init_args"] == config.to_dict() assert "class_path:" in saved_text