From 4b754645996dbeadd62cd78ebe8d4d5f648e8592 Mon Sep 17 00:00:00 2001 From: Skyzuo9 <3101065459@qq.com> Date: Sat, 20 Jun 2026 22:07:46 +0800 Subject: [PATCH 01/15] feat(ext-registry): T1 discover external registry paths (pyproject + fallback) Co-authored-by: Cursor (cherry picked from commit 2add15886e7cbf9a53a335463c58f44ca62cd4b0) --- tests/registry/__init__.py | 0 tests/registry/fixtures/__init__.py | 0 .../external_variant_package/pyproject.toml | 6 ++ .../unilabos_registry/package.yaml | 3 + .../test_external_registry_discovery.py | 28 +++++++++ .../registry/external_registry_discovery.py | 59 +++++++++++++++++++ 6 files changed, 96 insertions(+) create mode 100644 tests/registry/__init__.py create mode 100644 tests/registry/fixtures/__init__.py create mode 100644 tests/registry/fixtures/external_variant_package/pyproject.toml create mode 100644 tests/registry/fixtures/external_variant_package/unilabos_registry/package.yaml create mode 100644 tests/registry/test_external_registry_discovery.py create mode 100644 unilabos/registry/external_registry_discovery.py diff --git a/tests/registry/__init__.py b/tests/registry/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/registry/fixtures/__init__.py b/tests/registry/fixtures/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/registry/fixtures/external_variant_package/pyproject.toml b/tests/registry/fixtures/external_variant_package/pyproject.toml new file mode 100644 index 000000000..ef31ff647 --- /dev/null +++ b/tests/registry/fixtures/external_variant_package/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "external-variant-package" +version = "0.1.0" + +[tool.unilabos.registry] +paths = ["unilabos_registry"] diff --git a/tests/registry/fixtures/external_variant_package/unilabos_registry/package.yaml b/tests/registry/fixtures/external_variant_package/unilabos_registry/package.yaml new file mode 100644 index 000000000..a9d830046 --- /dev/null +++ b/tests/registry/fixtures/external_variant_package/unilabos_registry/package.yaml @@ -0,0 +1,3 @@ +package: + name: external-variant-package + version: 0.1.0 diff --git a/tests/registry/test_external_registry_discovery.py b/tests/registry/test_external_registry_discovery.py new file mode 100644 index 000000000..2c2fe734a --- /dev/null +++ b/tests/registry/test_external_registry_discovery.py @@ -0,0 +1,28 @@ +"""Plan 09 Task 1: external registry path discovery.""" + +from pathlib import Path + +from unilabos.registry.external_registry_discovery import discover_registry_paths_from_project + + +def test_discover_registry_paths_from_pyproject_tool_section(): + project_root = Path(__file__).parent / "fixtures" / "external_variant_package" + + paths = discover_registry_paths_from_project(project_root) + + assert paths == [(project_root / "unilabos_registry").resolve()] + + +def test_discover_registry_paths_falls_back_to_unilabos_registry_directory(tmp_path): + registry_dir = tmp_path / "unilabos_registry" + registry_dir.mkdir() + + paths = discover_registry_paths_from_project(tmp_path) + + assert paths == [registry_dir.resolve()] + + +def test_discover_registry_paths_returns_empty_when_no_registry_exists(tmp_path): + paths = discover_registry_paths_from_project(tmp_path) + + assert paths == [] diff --git a/unilabos/registry/external_registry_discovery.py b/unilabos/registry/external_registry_discovery.py new file mode 100644 index 000000000..44de59937 --- /dev/null +++ b/unilabos/registry/external_registry_discovery.py @@ -0,0 +1,59 @@ +"""Discover external package registry directories (Plan 09 Task 1). + +An external package may expose its Uni-Lab-OS registry YAML via: +1. ``pyproject.toml`` ``[tool.unilabos.registry] paths = [...]`` +2. (future) entry point group ``unilabos.registry`` +3. fallback ``unilabos_registry/`` directory at the package/repo root. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - py<3.11 + import tomli as tomllib + + +def discover_registry_paths_from_project(project_root: Path | str) -> list[Path]: + root = Path(project_root).resolve() + pyproject_paths = _read_pyproject_registry_paths(root) + if pyproject_paths: + return pyproject_paths + + fallback = root / "unilabos_registry" + if fallback.is_dir(): + return [fallback] + + return [] + + +def _read_pyproject_registry_paths(project_root: Path) -> list[Path]: + pyproject = project_root / "pyproject.toml" + if not pyproject.is_file(): + return [] + + data = _load_toml(pyproject) + registry_config = data.get("tool", {}).get("unilabos", {}).get("registry", {}) + raw_paths = registry_config.get("paths", []) + if not isinstance(raw_paths, list): + return [] + + paths: list[Path] = [] + for raw_path in raw_paths: + if not isinstance(raw_path, str): + continue + registry_path = (project_root / raw_path).resolve() + if registry_path.is_dir(): + paths.append(registry_path) + return paths + + +def _load_toml(path: Path) -> dict[str, Any]: + with path.open("rb") as file: + data = tomllib.load(file) + if not isinstance(data, dict): + return {} + return data From dacdf2281f31ab74f81293e2433f27ceafa50f16 Mon Sep 17 00:00:00 2001 From: Skyzuo9 <3101065459@qq.com> Date: Sat, 20 Jun 2026 22:07:46 +0800 Subject: [PATCH 02/15] feat(ext-registry): T2 resolve YAML $ref (relative path + json pointer + cycle detect) Co-authored-by: Cursor (cherry picked from commit dfa523c9bc8cb17927ced6c7d0bafecb70e92747) --- .../contracts/liquid_handler.yaml | 20 +++++ .../ref_registry/devices/opentrons_flex.yaml | 7 ++ tests/registry/test_yaml_ref.py | 28 +++++++ unilabos/registry/yaml_ref.py | 79 +++++++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 tests/registry/fixtures/ref_registry/contracts/liquid_handler.yaml create mode 100644 tests/registry/fixtures/ref_registry/devices/opentrons_flex.yaml create mode 100644 tests/registry/test_yaml_ref.py create mode 100644 unilabos/registry/yaml_ref.py diff --git a/tests/registry/fixtures/ref_registry/contracts/liquid_handler.yaml b/tests/registry/fixtures/ref_registry/contracts/liquid_handler.yaml new file mode 100644 index 000000000..0fc1f284d --- /dev/null +++ b/tests/registry/fixtures/ref_registry/contracts/liquid_handler.yaml @@ -0,0 +1,20 @@ +actions: + setup: + goal: {} + feedback: {} + result: + success: success + schema: + type: object + properties: + goal: + type: object + result: + type: object + properties: + success: + type: boolean + goal_default: {} + handles: {} +status_types: + initialized: bool diff --git a/tests/registry/fixtures/ref_registry/devices/opentrons_flex.yaml b/tests/registry/fixtures/ref_registry/devices/opentrons_flex.yaml new file mode 100644 index 000000000..5cca0bd57 --- /dev/null +++ b/tests/registry/fixtures/ref_registry/devices/opentrons_flex.yaml @@ -0,0 +1,7 @@ +pylabrobot.lh.opentrons_flex: + class: + module: pylabrobot.liquid_handling.liquid_handler:LiquidHandler + action_value_mappings: + $ref: ../contracts/liquid_handler.yaml#/actions + status_types: + $ref: ../contracts/liquid_handler.yaml#/status_types diff --git a/tests/registry/test_yaml_ref.py b/tests/registry/test_yaml_ref.py new file mode 100644 index 000000000..4def462a6 --- /dev/null +++ b/tests/registry/test_yaml_ref.py @@ -0,0 +1,28 @@ +"""Plan 09 Task 2: YAML $ref resolution.""" + +from pathlib import Path + +import pytest +import yaml + +from unilabos.registry.yaml_ref import YamlRefCycleError, resolve_yaml_refs + + +def test_resolve_yaml_refs_loads_relative_file_and_json_pointer(): + file_path = Path(__file__).parent / "fixtures" / "ref_registry" / "devices" / "opentrons_flex.yaml" + raw_data = yaml.safe_load(file_path.read_text(encoding="utf-8")) + + resolved = resolve_yaml_refs(raw_data, base_file=file_path) + + device = resolved["pylabrobot.lh.opentrons_flex"] + assert "setup" in device["class"]["action_value_mappings"] + assert device["class"]["status_types"] == {"initialized": "bool"} + + +def test_resolve_yaml_refs_detects_cycles(tmp_path): + cycle_file = tmp_path / "cycle.yaml" + cycle_file.write_text("value:\n $ref: cycle.yaml#/value\n", encoding="utf-8") + raw_data = yaml.safe_load(cycle_file.read_text(encoding="utf-8")) + + with pytest.raises(YamlRefCycleError): + resolve_yaml_refs(raw_data, base_file=cycle_file) diff --git a/unilabos/registry/yaml_ref.py b/unilabos/registry/yaml_ref.py new file mode 100644 index 000000000..40e3908f5 --- /dev/null +++ b/unilabos/registry/yaml_ref.py @@ -0,0 +1,79 @@ +"""Resolve YAML ``$ref`` pointers in external registry files (Plan 09 Task 2). + +Supports ``relative/path.yaml#/json/pointer`` references so multiple device +variants can share one action/status contract. Detects reference cycles. +""" + +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +from typing import Any + +import yaml + + +class YamlRefError(ValueError): + pass + + +class YamlRefCycleError(YamlRefError): + pass + + +def resolve_yaml_refs(data: Any, base_file: Path | str) -> Any: + return _resolve_node(deepcopy(data), Path(base_file).resolve(), seen=set()) + + +def _resolve_node(node: Any, base_file: Path, seen: set[str]) -> Any: + if isinstance(node, dict): + if set(node.keys()) == {"$ref"}: + return _resolve_ref(str(node["$ref"]), base_file, seen) + return {key: _resolve_node(value, base_file, seen) for key, value in node.items()} + + if isinstance(node, list): + return [_resolve_node(item, base_file, seen) for item in node] + + return node + + +def _resolve_ref(ref: str, base_file: Path, seen: set[str]) -> Any: + path_part, pointer = _split_ref(ref) + ref_file = (base_file.parent / path_part).resolve() + cycle_key = f"{ref_file}#{pointer}" + if cycle_key in seen: + raise YamlRefCycleError(f"YAML $ref cycle detected: {cycle_key}") + + seen.add(cycle_key) + try: + with ref_file.open(encoding="utf-8") as file: + ref_data = yaml.safe_load(file) or {} + target = _select_json_pointer(ref_data, pointer) + return _resolve_node(deepcopy(target), ref_file, seen) + finally: + seen.remove(cycle_key) + + +def _split_ref(ref: str) -> tuple[str, str]: + if "#" not in ref: + return ref, "" + path_part, pointer = ref.split("#", 1) + return path_part, pointer + + +def _select_json_pointer(data: Any, pointer: str) -> Any: + if pointer in ("", "/"): + return data + if not pointer.startswith("/"): + raise YamlRefError(f"Invalid JSON pointer: {pointer}") + + current = data + for raw_part in pointer.strip("/").split("/"): + part = raw_part.replace("~1", "/").replace("~0", "~") + if isinstance(current, dict): + current = current[part] + elif isinstance(current, list): + current = current[int(part)] + else: + raise YamlRefError(f"Cannot select '{part}' from non-container value") + return current From 6ac41265d8121326f5c51df3a58cd1405dcc2a63 Mon Sep 17 00:00:00 2001 From: Skyzuo9 <3101065459@qq.com> Date: Sat, 20 Jun 2026 22:07:46 +0800 Subject: [PATCH 03/15] feat(ext-registry): T3 build instances from class.init (factory/value/placeholders) Co-authored-by: Cursor (cherry picked from commit 7e3f75d7a4c2b41739ba5a0a430abc1ee5d9a105) --- .../registry/fixtures/initializer_drivers.py | 20 ++++ tests/registry/test_initializer.py | 57 ++++++++++++ unilabos/registry/initializer.py | 93 +++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 tests/registry/fixtures/initializer_drivers.py create mode 100644 tests/registry/test_initializer.py create mode 100644 unilabos/registry/initializer.py diff --git a/tests/registry/fixtures/initializer_drivers.py b/tests/registry/fixtures/initializer_drivers.py new file mode 100644 index 000000000..df4d7cb7e --- /dev/null +++ b/tests/registry/fixtures/initializer_drivers.py @@ -0,0 +1,20 @@ +"""Plan 09 Task 3: mock drivers for initializer tests.""" + + +class MockBackend: + def __init__(self, host: str, port: int): + self.host = host + self.port = port + + +class MockDeck: + def __init__(self, name: str): + self.name = name + + +class SharedDevice: + def __init__(self, backend: MockBackend, deck: MockDeck, name: str, channels: int): + self.backend = backend + self.deck = deck + self.name = name + self.channels = channels diff --git a/tests/registry/test_initializer.py b/tests/registry/test_initializer.py new file mode 100644 index 000000000..80dbf6b39 --- /dev/null +++ b/tests/registry/test_initializer.py @@ -0,0 +1,57 @@ +"""Plan 09 Task 3: initializer resolver.""" + +from unilabos.registry.initializer import build_instance_from_registry_entry + + +def test_build_instance_from_registry_entry_constructs_nested_factories(): + entry = { + "class": { + "module": "tests.registry.fixtures.initializer_drivers:SharedDevice", + "init": { + "kwargs": { + "backend": { + "factory": "tests.registry.fixtures.initializer_drivers:MockBackend", + "kwargs": { + "host": "${config.host}", + "port": "${config.port}", + }, + }, + "deck": { + "factory": "tests.registry.fixtures.initializer_drivers:MockDeck", + "kwargs": { + "name": "opentrons-flex", + }, + }, + "name": "${node.id}", + "channels": 96, + } + }, + } + } + node = {"id": "lh1", "name": "Liquid Handler 1"} + config = {"host": "127.0.0.1", "port": 31950} + + device = build_instance_from_registry_entry(entry, node=node, config=config) + + assert device.name == "lh1" + assert device.channels == 96 + assert device.backend.host == "127.0.0.1" + assert device.backend.port == 31950 + assert device.deck.name == "opentrons-flex" + + +def test_build_instance_from_registry_entry_supports_explicit_constant_value(): + entry = { + "class": { + "module": "tests.registry.fixtures.initializer_drivers:MockDeck", + "init": { + "kwargs": { + "name": {"value": "constant-deck"}, + } + }, + } + } + + deck = build_instance_from_registry_entry(entry, node={"id": "deck1"}, config={}) + + assert deck.name == "constant-deck" diff --git a/unilabos/registry/initializer.py b/unilabos/registry/initializer.py new file mode 100644 index 000000000..6731313a6 --- /dev/null +++ b/unilabos/registry/initializer.py @@ -0,0 +1,93 @@ +"""Construct device instances from registry ``class.init`` specs (Plan 09 Task 3). + +Lets multiple registry entries share one Python class but pass different init +parameters (e.g. different ``backend`` factory). Value rules: +- scalars pass through +- ``${config.x}`` / ``${node.id}`` / ``${node.name}`` inject from node/config +- ``factory: module:Callable`` builds the value via that callable + its args/kwargs +- ``value: ...`` passes an explicit constant (disambiguates from factory) +""" + +from __future__ import annotations + +import importlib +import re +from typing import Any, Callable + +_PLACEHOLDER_PATTERN = re.compile(r"^\$\{([^}]+)\}$") + + +class RegistryInitializerError(ValueError): + pass + + +def build_instance_from_registry_entry(entry: dict[str, Any], node: dict[str, Any], config: dict[str, Any]) -> Any: + class_config = entry.get("class", {}) + class_ref = class_config.get("module") + if not isinstance(class_ref, str) or not class_ref: + raise RegistryInitializerError("Registry entry class.module is required") + + target = import_ref(class_ref) + init_config = class_config.get("init", {}) or {} + args = [_resolve_value(value, node=node, config=config) for value in init_config.get("args", [])] + kwargs = { + key: _resolve_value(value, node=node, config=config) + for key, value in init_config.get("kwargs", {}).items() + } + return target(*args, **kwargs) + + +def import_ref(ref: str) -> Callable[..., Any] | type: + if ":" not in ref: + raise RegistryInitializerError(f"Import ref must use 'module:attr' format: {ref}") + module_name, attr_name = ref.split(":", 1) + module = importlib.import_module(module_name) + current: Any = module + for part in attr_name.split("."): + current = getattr(current, part) + return current + + +def _resolve_value(value: Any, node: dict[str, Any], config: dict[str, Any]) -> Any: + if isinstance(value, str): + return _resolve_string(value, node=node, config=config) + + if isinstance(value, list): + return [_resolve_value(item, node=node, config=config) for item in value] + + if isinstance(value, dict): + if "value" in value and set(value.keys()) == {"value"}: + return value["value"] + if "factory" in value: + factory = import_ref(value["factory"]) + args = [_resolve_value(item, node=node, config=config) for item in value.get("args", [])] + kwargs = { + key: _resolve_value(item, node=node, config=config) + for key, item in value.get("kwargs", {}).items() + } + return factory(*args, **kwargs) + return {key: _resolve_value(item, node=node, config=config) for key, item in value.items()} + + return value + + +def _resolve_string(value: str, node: dict[str, Any], config: dict[str, Any]) -> Any: + match = _PLACEHOLDER_PATTERN.match(value) + if not match: + return value + + expression = match.group(1) + if expression.startswith("config."): + return _select_path(config, expression.removeprefix("config.")) + if expression.startswith("node."): + return _select_path(node, expression.removeprefix("node.")) + raise RegistryInitializerError(f"Unsupported initializer placeholder: {value}") + + +def _select_path(data: dict[str, Any], path: str) -> Any: + current: Any = data + for part in path.split("."): + if not isinstance(current, dict) or part not in current: + raise RegistryInitializerError(f"Missing initializer value: {path}") + current = current[part] + return current From 44e4088783761498fe0b0ae09eee9a741dd960ec Mon Sep 17 00:00:00 2001 From: Skyzuo9 <3101065459@qq.com> Date: Sat, 20 Jun 2026 22:31:29 +0800 Subject: [PATCH 04/15] feat(ext-registry): T4 expand $ref in device YAML loading + multi-variant test Co-authored-by: Cursor (cherry picked from commit d0aa6b643f32f9914612e8b60f959512e636ad25) --- .../contracts/liquid_handler.yaml | 20 +++++++ .../devices/liquid_handlers.yaml | 53 +++++++++++++++++++ .../test_registry_external_variant_yaml.py | 34 ++++++++++++ unilabos/registry/registry.py | 6 ++- 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/registry/fixtures/external_variant_registry/contracts/liquid_handler.yaml create mode 100644 tests/registry/fixtures/external_variant_registry/devices/liquid_handlers.yaml create mode 100644 tests/registry/test_registry_external_variant_yaml.py diff --git a/tests/registry/fixtures/external_variant_registry/contracts/liquid_handler.yaml b/tests/registry/fixtures/external_variant_registry/contracts/liquid_handler.yaml new file mode 100644 index 000000000..0fc1f284d --- /dev/null +++ b/tests/registry/fixtures/external_variant_registry/contracts/liquid_handler.yaml @@ -0,0 +1,20 @@ +actions: + setup: + goal: {} + feedback: {} + result: + success: success + schema: + type: object + properties: + goal: + type: object + result: + type: object + properties: + success: + type: boolean + goal_default: {} + handles: {} +status_types: + initialized: bool diff --git a/tests/registry/fixtures/external_variant_registry/devices/liquid_handlers.yaml b/tests/registry/fixtures/external_variant_registry/devices/liquid_handlers.yaml new file mode 100644 index 000000000..f9f26cf64 --- /dev/null +++ b/tests/registry/fixtures/external_variant_registry/devices/liquid_handlers.yaml @@ -0,0 +1,53 @@ +vendor.lh.model_a: + version: 1.0.0 + category: [liquid_handler, vendor] + implementation: + family: vendor.liquid_handler + variant: model_a + class_ref: tests.registry.fixtures.initializer_drivers:SharedDevice + class: + module: tests.registry.fixtures.initializer_drivers:SharedDevice + init: + kwargs: + backend: + factory: tests.registry.fixtures.initializer_drivers:MockBackend + kwargs: + host: ${config.host} + port: ${config.port} + deck: + factory: tests.registry.fixtures.initializer_drivers:MockDeck + kwargs: + name: model-a-deck + name: ${node.id} + channels: 8 + action_value_mappings: + $ref: ../contracts/liquid_handler.yaml#/actions + status_types: + $ref: ../contracts/liquid_handler.yaml#/status_types + +vendor.lh.model_b: + version: 1.0.0 + category: [liquid_handler, vendor] + implementation: + family: vendor.liquid_handler + variant: model_b + class_ref: tests.registry.fixtures.initializer_drivers:SharedDevice + class: + module: tests.registry.fixtures.initializer_drivers:SharedDevice + init: + kwargs: + backend: + factory: tests.registry.fixtures.initializer_drivers:MockBackend + kwargs: + host: ${config.host} + port: ${config.port} + deck: + factory: tests.registry.fixtures.initializer_drivers:MockDeck + kwargs: + name: model-b-deck + name: ${node.id} + channels: 96 + action_value_mappings: + $ref: ../contracts/liquid_handler.yaml#/actions + status_types: + $ref: ../contracts/liquid_handler.yaml#/status_types diff --git a/tests/registry/test_registry_external_variant_yaml.py b/tests/registry/test_registry_external_variant_yaml.py new file mode 100644 index 000000000..c43847ba6 --- /dev/null +++ b/tests/registry/test_registry_external_variant_yaml.py @@ -0,0 +1,34 @@ +"""Plan 09 Task 4: registry loads multiple variants sharing one class, with $ref. + +Adapted to real Registry: @singleton + load_device_types(DIR) + needs executor + +device_type_registry stores runtime data (status_types may become class objects). +""" + +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from unilabos.registry.registry import Registry + +FIX = Path(__file__).parent / "fixtures" / "external_variant_registry" + + +def test_registry_loads_multiple_variants_sharing_same_class(): + reg = Registry() # singleton (needs unilabos_msgs -> run on full env / 4090) + if reg._startup_executor is None: + reg._startup_executor = ThreadPoolExecutor(max_workers=2) + + reg.load_device_types(FIX, complete_registry=False) # DIR, not a single file + + a = reg.device_type_registry["vendor.lh.model_a"] + b = reg.device_type_registry["vendor.lh.model_b"] + + assert a["class"]["module"].endswith(":SharedDevice") + assert b["class"]["module"].endswith(":SharedDevice") + assert a["implementation"]["variant"] == "model_a" + assert b["implementation"]["variant"] == "model_b" + # class.init preserved (not stripped during normalization) + assert a["class"]["init"]["kwargs"]["channels"] == 8 + assert b["class"]["init"]["kwargs"]["channels"] == 96 + # $ref expanded into the shared contract + assert "setup" in a["class"]["action_value_mappings"] + assert "initialized" in b["class"]["status_types"] diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 94a437f50..a51384356 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -36,6 +36,7 @@ NodeType, normalize_enum_value, ) +from unilabos.registry.yaml_ref import resolve_yaml_refs from unilabos.registry.utils import ( ROSMsgNotFound, parse_docstring, @@ -1836,7 +1837,10 @@ def _load_single_device_file( """ try: with open(file, encoding="utf-8", mode="r") as f: - data = yaml.safe_load(io.StringIO(f.read())) + raw_data = yaml.safe_load(io.StringIO(f.read())) + # Plan 09 Task 4: expand external-registry YAML $ref (shared contracts) + # before per-device normalization. No-op for files without $ref. + data = resolve_yaml_refs(raw_data, base_file=file) except Exception as e: logger.warning(f"[UniLab Registry] 读取设备文件失败: {file}, 错误: {e}") return {}, {}, False, [] From dc67da806723431e81758acf508fd2ae6858f24c Mon Sep 17 00:00:00 2001 From: Skyzuo9 <3101065459@qq.com> Date: Sat, 20 Jun 2026 22:33:55 +0800 Subject: [PATCH 05/15] feat(ext-registry): T5 wire external registry paths into setup/build_registry/startup Co-authored-by: Cursor (cherry picked from commit e4c0fbb44ab6f0c20c56d05f50d67eb90b2d0864) --- .../registry/test_registry_setup_external_paths.py | 14 ++++++++++++++ unilabos/app/main.py | 14 ++++++++++++++ unilabos/registry/registry.py | 14 +++++++++++++- 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tests/registry/test_registry_setup_external_paths.py diff --git a/tests/registry/test_registry_setup_external_paths.py b/tests/registry/test_registry_setup_external_paths.py new file mode 100644 index 000000000..1c31ab969 --- /dev/null +++ b/tests/registry/test_registry_setup_external_paths.py @@ -0,0 +1,14 @@ +"""Plan 09 Task 5: external variant fixture registry path is discoverable (locks the +fixture before it is wired into startup via build_registry/setup).""" + +from pathlib import Path + +from unilabos.registry.external_registry_discovery import discover_registry_paths_from_project + + +def test_external_variant_fixture_registry_path_is_discoverable(): + project_root = Path(__file__).parent / "fixtures" / "external_variant_package" + + paths = discover_registry_paths_from_project(project_root) + + assert paths == [(project_root / "unilabos_registry").resolve()] diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 7ee6bbb85..4fc8ae269 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -692,6 +692,19 @@ def main(): # 社区包设备直接以 community.. 注册(扫描期命名空间化),不做 alias 桥接 args_dict["_community_namespaces"] = community_result.namespaces + # Plan 09 Task 5: 从已下载的社区/外源包根目录发现 registry 目录,并入 build_registry。 + try: + from unilabos.registry.external_registry_discovery import discover_registry_paths_from_project + + ext_paths: list = [] + for package_root in getattr(community_result, "package_roots", []) or []: + ext_paths.extend(discover_registry_paths_from_project(package_root)) + if ext_paths: + args_dict["_external_registry_paths"] = [str(p) for p in ext_paths] + print_status(f"发现 {len(ext_paths)} 个外源 registry 目录", "info") + except Exception as _ext_exc: # noqa: BLE001 + logger.warning(f"[ext-registry] 外源 registry 发现跳过: {_ext_exc}") + # Step 0: AST 分析优先 + YAML 注册表加载 # check_mode 和 upload_registry 都会执行实际 import 验证 devices_dirs = args_dict.get("devices", None) @@ -705,6 +718,7 @@ def main(): check_mode=check_mode, complete_registry=complete_registry, external_only=external_only, + external_registry_paths=args_dict.get("_external_registry_paths"), ) # Check mode: 注册表验证完成后直接退出 diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index a51384356..357934d64 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -123,12 +123,22 @@ def setup( complete_registry=False, external_only=False, community_namespaces=None, + external_registry_paths=None, ): - """统一构建注册表入口。""" + """统一构建注册表入口。 + + external_registry_paths: 外源包发现到的 registry 目录(Plan 09),会并入 YAML 加载路径。 + """ if self._setup_called: logger.critical("[UniLab Registry] setup方法已被调用过,不允许多次调用") return + if external_registry_paths: + for _p in external_registry_paths: + _pp = Path(_p) + if _pp not in self.registry_paths: + self.registry_paths.append(_pp) + self._startup_executor = ThreadPoolExecutor( max_workers=8, thread_name_prefix="RegistryStartup" ) @@ -2499,6 +2509,7 @@ def build_registry( complete_registry=False, external_only=False, community_namespaces=None, + external_registry_paths=None, ): """ 构建或获取Registry单例实例 @@ -2519,6 +2530,7 @@ def build_registry( complete_registry=complete_registry, external_only=external_only, community_namespaces=community_namespaces, + external_registry_paths=external_registry_paths, ) # 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块) From 303732f2804f65296875bd499d38553a886e38b4 Mon Sep 17 00:00:00 2001 From: Skyzuo9 <3101065459@qq.com> Date: Sat, 20 Jun 2026 22:33:55 +0800 Subject: [PATCH 06/15] feat(ext-registry): T7 community alias resolution + graph lookup fallback Co-authored-by: Cursor (cherry picked from commit 478e6c02b28a6a06b47608b6e5d69394480f6c46) --- tests/registry/test_community_alias.py | 30 +++++++++++++++++++++++ unilabos/registry/community_alias.py | 33 ++++++++++++++++++++++++++ unilabos/ros/initialize_device.py | 10 +++++++- 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/registry/test_community_alias.py create mode 100644 unilabos/registry/community_alias.py diff --git a/tests/registry/test_community_alias.py b/tests/registry/test_community_alias.py new file mode 100644 index 000000000..af63fcbbf --- /dev/null +++ b/tests/registry/test_community_alias.py @@ -0,0 +1,30 @@ +"""Plan 09 Task 7: community alias resolution.""" + +import pytest + +from unilabos.registry.community_alias import ( + CommunityAliasError, + normalize_community_class, + resolve_community_alias, +) + + +def test_normalize_community_class_strips_prefix(): + assert normalize_community_class("community.pylabrobot.lh.opentrons_flex") == "pylabrobot.lh.opentrons_flex" + + +def test_normalize_community_class_leaves_local_class_unchanged(): + assert normalize_community_class("pylabrobot.lh.opentrons_flex") == "pylabrobot.lh.opentrons_flex" + + +def test_resolve_community_alias_requires_registry_entry(): + registry = {"pylabrobot.lh.opentrons_flex": {"class": {"module": "x:Y"}}} + + resolved = resolve_community_alias("community.pylabrobot.lh.opentrons_flex", registry) + + assert resolved == "pylabrobot.lh.opentrons_flex" + + +def test_resolve_community_alias_raises_when_missing(): + with pytest.raises(CommunityAliasError): + resolve_community_alias("community.unknown.device", {}) diff --git a/unilabos/registry/community_alias.py b/unilabos/registry/community_alias.py new file mode 100644 index 000000000..8a69fc129 --- /dev/null +++ b/unilabos/registry/community_alias.py @@ -0,0 +1,33 @@ +"""Community registry alias resolution (Plan 09 Task 7). + +Graph ``class`` may reference a community variant id as ``community.``. After +the community package is downloaded/mounted, the registry holds the local id +````; this module strips the prefix and validates the registry entry exists. +Complements the existing ``community_packages.apply_community_aliases`` (which +mutates the registry); these helpers are pure and used at graph->device lookup. +""" + +from __future__ import annotations + +from typing import Any + +COMMUNITY_PREFIX = "community." + + +class CommunityAliasError(ValueError): + pass + + +def normalize_community_class(class_name: str) -> str: + if class_name.startswith(COMMUNITY_PREFIX): + return class_name[len(COMMUNITY_PREFIX):] + return class_name + + +def resolve_community_alias(class_name: str, device_registry: dict[str, Any]) -> str: + normalized = normalize_community_class(class_name) + if normalized not in device_registry: + raise CommunityAliasError( + f"Community class '{class_name}' resolved to '{normalized}', but no registry entry exists" + ) + return normalized diff --git a/unilabos/ros/initialize_device.py b/unilabos/ros/initialize_device.py index 675814adc..49abc8e03 100644 --- a/unilabos/ros/initialize_device.py +++ b/unilabos/ros/initialize_device.py @@ -30,7 +30,15 @@ def initialize_device_from_dict(device_id, device_config: ResourceDictInstance) if len(device_class_config) == 0: raise DeviceClassInvalid(f"Device [{device_id}] class cannot be an empty string. {device_config}") if device_class_config not in lab_registry.device_type_registry: - raise DeviceClassInvalid(f"Device [{device_id}] class {device_class_config} not found. {device_config}") + # Plan 09 Task 7: graph 可能引用 community 变体 id(community.); + # 若带前缀的 id 未注册,回退到归一化后的本地 id。 + from unilabos.registry.community_alias import normalize_community_class + + normalized = normalize_community_class(device_class_config) + if normalized in lab_registry.device_type_registry: + device_class_config = normalized + else: + raise DeviceClassInvalid(f"Device [{device_id}] class {device_class_config} not found. {device_config}") device_class_config = lab_registry.device_type_registry[device_class_config]["class"] elif isinstance(device_class_config, dict): raise DeviceClassInvalid(f"Device [{device_id}] class config should be type 'str' but 'dict' got. {device_config}") From 5540f2a0fa62c8c2d7ebb53b2d3c9b9fb1725951 Mon Sep 17 00:00:00 2001 From: Skyzuo9 <3101065459@qq.com> Date: Sat, 20 Jun 2026 22:35:59 +0800 Subject: [PATCH 07/15] feat(ext-registry): T6 resolve class.init into driver_params (ROS wrapper kept) Co-authored-by: Cursor (cherry picked from commit dede8fbd07030af4a6a90f1f272bfb8e104dd969) --- tests/registry/test_initializer.py | 34 +++++++++++++++++++++++++++++- unilabos/registry/initializer.py | 17 +++++++++++++++ unilabos/ros/initialize_device.py | 15 ++++++++++++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/tests/registry/test_initializer.py b/tests/registry/test_initializer.py index 80dbf6b39..33eaec4e5 100644 --- a/tests/registry/test_initializer.py +++ b/tests/registry/test_initializer.py @@ -1,6 +1,6 @@ """Plan 09 Task 3: initializer resolver.""" -from unilabos.registry.initializer import build_instance_from_registry_entry +from unilabos.registry.initializer import build_instance_from_registry_entry, resolve_init_kwargs def test_build_instance_from_registry_entry_constructs_nested_factories(): @@ -55,3 +55,35 @@ def test_build_instance_from_registry_entry_supports_explicit_constant_value(): deck = build_instance_from_registry_entry(entry, node={"id": "deck1"}, config={}) assert deck.name == "constant-deck" + + +def test_resolve_init_kwargs_builds_factories_without_device_class(): + """Task 6: resolve init kwargs (factory objects + placeholders) without building the device.""" + entry = { + "class": { + "module": "tests.registry.fixtures.initializer_drivers:SharedDevice", + "init": { + "kwargs": { + "backend": { + "factory": "tests.registry.fixtures.initializer_drivers:MockBackend", + "kwargs": {"host": "${config.host}", "port": "${config.port}"}, + }, + "name": "${node.id}", + "channels": 384, + } + }, + } + } + resolved = resolve_init_kwargs( + entry, node={"id": "lh-runtime", "name": "Runtime LH"}, config={"host": "10.0.0.2", "port": 1234} + ) + assert resolved["args"] == [] + kwargs = resolved["kwargs"] + assert kwargs["name"] == "lh-runtime" + assert kwargs["channels"] == 384 + assert kwargs["backend"].host == "10.0.0.2" # MockBackend built, device class NOT built + assert kwargs["backend"].port == 1234 + + +def test_resolve_init_kwargs_empty_when_no_init(): + assert resolve_init_kwargs({"class": {"module": "x:Y"}}, node={"id": "a"}, config={}) == {"args": [], "kwargs": {}} diff --git a/unilabos/registry/initializer.py b/unilabos/registry/initializer.py index 6731313a6..b1cc9e09e 100644 --- a/unilabos/registry/initializer.py +++ b/unilabos/registry/initializer.py @@ -37,6 +37,23 @@ def build_instance_from_registry_entry(entry: dict[str, Any], node: dict[str, An return target(*args, **kwargs) +def resolve_init_kwargs(entry: dict[str, Any], node: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]: + """Resolve ``class.init`` into concrete args/kwargs WITHOUT instantiating the + device class itself (Plan 09 Task 6). + + Used by the ROS device construction path: the resolved kwargs (with factory + objects built and ${config.*}/${node.*} injected) are merged into driver_params + so the existing ROS2DeviceNode wrapper / creator still builds the device. + """ + init_config = (entry.get("class", {}) or {}).get("init", {}) or {} + args = [_resolve_value(value, node=node, config=config) for value in init_config.get("args", [])] + kwargs = { + key: _resolve_value(value, node=node, config=config) + for key, value in init_config.get("kwargs", {}).items() + } + return {"args": args, "kwargs": kwargs} + + def import_ref(ref: str) -> Callable[..., Any] | type: if ":" not in ref: raise RegistryInitializerError(f"Import ref must use 'module:attr' format: {ref}") diff --git a/unilabos/ros/initialize_device.py b/unilabos/ros/initialize_device.py index 49abc8e03..35b7371ec 100644 --- a/unilabos/ros/initialize_device.py +++ b/unilabos/ros/initialize_device.py @@ -55,9 +55,22 @@ def initialize_device_from_dict(device_id, device_config: ResourceDictInstance) {"name": "hardware_interface", "write": "send_command", "read": "read_data", "extra_info": []}, ) ) + effective_params = device_config.res_content.config + # Plan 09 Task 6: external variant registry entries declare class.init; resolve it + # (build factory objects + inject ${config.*}/${node.*}) and merge into driver_params, + # keeping the existing ROS2DeviceNode wrapper/creator construction path. + if device_class_config.get("init"): + from unilabos.registry.initializer import resolve_init_kwargs + + node_meta = {"id": device_id, "name": getattr(device_config.res_content, "name", device_id)} + resolved = resolve_init_kwargs({"class": device_class_config}, node=node_meta, config=effective_params or {}) + effective_params = {**(effective_params or {}), **resolved["kwargs"]} try: d = DEVICE( - device_id=device_id, device_uuid=uid, driver_is_ros=device_class_config["type"] == "ros2", driver_params=device_config.res_content.config + device_id=device_id, + device_uuid=uid, + driver_is_ros=device_class_config["type"] == "ros2", + driver_params=effective_params, ) except DeviceInitError as ex: return d From a44a8d59a0b29fe52b838d7033a483743e45c10f Mon Sep 17 00:00:00 2001 From: Skyzuo9 <3101065459@qq.com> Date: Sat, 20 Jun 2026 22:43:11 +0800 Subject: [PATCH 08/15] =?UTF-8?q?test(ext-registry):=20T6=20integration=20?= =?UTF-8?q?=E2=80=94=20class.init=20built=20via=20real=20creator=20+=20ROS?= =?UTF-8?q?=20device=20node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor (cherry picked from commit 6eef71882754f98467b8921a62687b842deef8c6) --- .../test_external_variant_construction.py | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/integration/test_external_variant_construction.py diff --git a/tests/integration/test_external_variant_construction.py b/tests/integration/test_external_variant_construction.py new file mode 100644 index 000000000..3672e9fb9 --- /dev/null +++ b/tests/integration/test_external_variant_construction.py @@ -0,0 +1,83 @@ +"""Plan 09 Task 6 (integration): class.init is resolved and fed to the real device +construction machinery, building the shared Python class with a factory backend. + +- Creator-level test exercises resolve_init_kwargs -> DeviceClassCreator -> + create_instance_from_config -> cls(**kwargs) (the exact surface T6 touches). +- ROS-level test goes through _instantiate_device_node under an rclpy context and + asserts the wrapped node's driver_instance got the factory-built backend. +""" + +import pytest + +ENTRY = { + "class": { + "module": "tests.registry.fixtures.initializer_drivers:SharedDevice", + "type": "python", + "init": { + "kwargs": { + "backend": { + "factory": "tests.registry.fixtures.initializer_drivers:MockBackend", + "kwargs": {"host": "${config.host}", "port": "${config.port}"}, + }, + "deck": { + "factory": "tests.registry.fixtures.initializer_drivers:MockDeck", + "kwargs": {"name": "runtime-deck"}, + }, + "name": "${node.id}", + "channels": 384, + } + }, + "status_types": {}, + "action_value_mappings": {}, + } +} +NODE = {"id": "lh-runtime", "name": "Runtime LH"} +CONFIG = {"host": "10.0.0.2", "port": 1234} + + +@pytest.mark.integration +def test_class_init_built_via_real_creator(): + """resolve_init_kwargs output flows through the real DeviceClassCreator.""" + from unilabos.registry.initializer import resolve_init_kwargs + from unilabos.resources.resource_tracker import DeviceNodeResourceTracker + from unilabos.ros.utils.driver_creator import DeviceClassCreator + from tests.registry.fixtures.initializer_drivers import SharedDevice + + resolved = resolve_init_kwargs(ENTRY, node=NODE, config=CONFIG) + creator = DeviceClassCreator(SharedDevice, children=[], resource_tracker=DeviceNodeResourceTracker()) + device = creator.create_instance(resolved["kwargs"]) + + assert isinstance(device, SharedDevice) + assert device.backend.host == "10.0.0.2" + assert device.backend.port == 1234 + assert device.deck.name == "runtime-deck" + assert device.name == "lh-runtime" + assert device.channels == 384 + + +@pytest.mark.integration +def test_class_init_via_instantiate_device_node(ros_context): + """Full edge path: registry entry with class.init -> _instantiate_device_node -> + ROS2DeviceNode whose driver_instance is the factory-constructed SharedDevice.""" + from unilabos.registry.registry import lab_registry + from unilabos.resources.resource_tracker import ResourceDictInstance + from unilabos.ros.initialize_device import _instantiate_device_node + + lab_registry.device_type_registry["vendor.lh.model_a"] = dict(ENTRY) + try: + device_config = ResourceDictInstance.get_resource_instance_from_dict({ + "name": "lh-runtime", + "type": "device", + "class": "vendor.lh.model_a", + "config": CONFIG, + }) + node = _instantiate_device_node("lh-runtime", device_config, "vendor.lh.model_a") + assert node is not None + driver = getattr(node, "driver_instance", None) + assert driver is not None + assert driver.backend.host == "10.0.0.2" + assert driver.backend.port == 1234 + assert driver.deck.name == "runtime-deck" + assert driver.channels == 384 + finally: + lab_registry.device_type_registry.pop("vendor.lh.model_a", None) From 72eda2857b900a01aee3e227f741687452cc88cf Mon Sep 17 00:00:00 2001 From: Skyzuo9 <3101065459@qq.com> Date: Sat, 20 Jun 2026 22:43:59 +0800 Subject: [PATCH 09/15] fix(ext-registry): T6 use resolved init kwargs as driver_params (config only feeds placeholders) Co-authored-by: Cursor (cherry picked from commit 28a18791071bf3dcc4ac0587f58db6f2f733d362) --- unilabos/ros/initialize_device.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/unilabos/ros/initialize_device.py b/unilabos/ros/initialize_device.py index 35b7371ec..9d6113fe2 100644 --- a/unilabos/ros/initialize_device.py +++ b/unilabos/ros/initialize_device.py @@ -63,8 +63,10 @@ def initialize_device_from_dict(device_id, device_config: ResourceDictInstance) from unilabos.registry.initializer import resolve_init_kwargs node_meta = {"id": device_id, "name": getattr(device_config.res_content, "name", device_id)} + # class.init fully defines the constructor kwargs; the raw config is only the + # source for ${config.*} placeholders, so it replaces (not merges into) params. resolved = resolve_init_kwargs({"class": device_class_config}, node=node_meta, config=effective_params or {}) - effective_params = {**(effective_params or {}), **resolved["kwargs"]} + effective_params = resolved["kwargs"] try: d = DEVICE( device_id=device_id, From ca41d9413b1d32537c7bab90fa733fcbb4a7cb43 Mon Sep 17 00:00:00 2001 From: Skyzuo9 <3101065459@qq.com> Date: Sat, 20 Jun 2026 22:44:33 +0800 Subject: [PATCH 10/15] test(ext-registry): T6 integration use valid ROS node name (no hyphen) Co-authored-by: Cursor (cherry picked from commit 764cd31d8be5320ca6423167a5176f76269ba9e1) --- tests/integration/test_external_variant_construction.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_external_variant_construction.py b/tests/integration/test_external_variant_construction.py index 3672e9fb9..b749af6f1 100644 --- a/tests/integration/test_external_variant_construction.py +++ b/tests/integration/test_external_variant_construction.py @@ -66,12 +66,12 @@ def test_class_init_via_instantiate_device_node(ros_context): lab_registry.device_type_registry["vendor.lh.model_a"] = dict(ENTRY) try: device_config = ResourceDictInstance.get_resource_instance_from_dict({ - "name": "lh-runtime", + "name": "lh_runtime", # ROS2 node name: no hyphens "type": "device", "class": "vendor.lh.model_a", "config": CONFIG, }) - node = _instantiate_device_node("lh-runtime", device_config, "vendor.lh.model_a") + node = _instantiate_device_node("lh_runtime", device_config, "vendor.lh.model_a") assert node is not None driver = getattr(node, "driver_instance", None) assert driver is not None @@ -79,5 +79,6 @@ def test_class_init_via_instantiate_device_node(ros_context): assert driver.backend.port == 1234 assert driver.deck.name == "runtime-deck" assert driver.channels == 384 + assert driver.name == "lh_runtime" # ${node.id} injected finally: lab_registry.device_type_registry.pop("vendor.lh.model_a", None) From 8dbccee5f411dbfa7b14288ef2be652e6f76d101 Mon Sep 17 00:00:00 2001 From: Skyzuo9 <3101065459@qq.com> Date: Sun, 21 Jun 2026 19:53:30 +0800 Subject: [PATCH 11/15] fix(ext-registry): yaml $ref only expands cross-file refs; preserve same-doc JSON-Schema $ref (#/$defs) Co-authored-by: Cursor (cherry picked from commit 54b8a8f09139dbd57ea178afa9ca80fb774db06e) --- tests/registry/test_yaml_ref.py | 15 +++++++++++++++ unilabos/registry/yaml_ref.py | 9 ++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/registry/test_yaml_ref.py b/tests/registry/test_yaml_ref.py index 4def462a6..bedcb0a73 100644 --- a/tests/registry/test_yaml_ref.py +++ b/tests/registry/test_yaml_ref.py @@ -19,6 +19,21 @@ def test_resolve_yaml_refs_loads_relative_file_and_json_pointer(): assert device["class"]["status_types"] == {"initialized": "bool"} +def test_resolve_yaml_refs_preserves_same_document_json_schema_refs(): + """JSON-Schema same-document refs (#/$defs/...) must be left intact, not expanded + or treated as file refs (regression: real registry init_param_schema uses these).""" + data = { + "init_param_schema": { + "type": "object", + "properties": {"deck": {"$ref": "#/$defs/ResourceDict"}}, + "$defs": {"ResourceDict": {"type": "object"}}, + } + } + resolved = resolve_yaml_refs(data, base_file="/some/registry/devices/x.yaml") + # untouched: the $ref dict survives verbatim (no IsADirectoryError, no inlining) + assert resolved["init_param_schema"]["properties"]["deck"] == {"$ref": "#/$defs/ResourceDict"} + + def test_resolve_yaml_refs_detects_cycles(tmp_path): cycle_file = tmp_path / "cycle.yaml" cycle_file.write_text("value:\n $ref: cycle.yaml#/value\n", encoding="utf-8") diff --git a/unilabos/registry/yaml_ref.py b/unilabos/registry/yaml_ref.py index 40e3908f5..6fede43f3 100644 --- a/unilabos/registry/yaml_ref.py +++ b/unilabos/registry/yaml_ref.py @@ -28,7 +28,14 @@ def resolve_yaml_refs(data: Any, base_file: Path | str) -> Any: def _resolve_node(node: Any, base_file: Path, seen: set[str]) -> Any: if isinstance(node, dict): if set(node.keys()) == {"$ref"}: - return _resolve_ref(str(node["$ref"]), base_file, seen) + ref = str(node["$ref"]) + path_part, _pointer = _split_ref(ref) + # Same-document refs (e.g. JSON-Schema `#/$defs/Foo` inside init_param_schema) + # are NOT external-registry contract refs: leave them intact for the schema + # consumer. Only cross-file refs (with a file path part) are expanded here. + if not path_part: + return node + return _resolve_ref(ref, base_file, seen) return {key: _resolve_node(value, base_file, seen) for key, value in node.items()} if isinstance(node, list): From 9479c31be98b5a5cfd704ab4d46eff68eea5807f Mon Sep 17 00:00:00 2001 From: Skyzuo9 <3101065459@qq.com> Date: Sun, 21 Jun 2026 20:21:28 +0800 Subject: [PATCH 12/15] feat(ext-registry): D discover registry paths from unilabos.registry entry points Co-authored-by: Cursor (cherry picked from commit b95a4633826a8468ca737b836cc3fdc1d2d96a6a) --- .../test_external_registry_discovery.py | 35 +++++++++++++++- .../registry/external_registry_discovery.py | 40 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/tests/registry/test_external_registry_discovery.py b/tests/registry/test_external_registry_discovery.py index 2c2fe734a..1bb2edda7 100644 --- a/tests/registry/test_external_registry_discovery.py +++ b/tests/registry/test_external_registry_discovery.py @@ -2,7 +2,11 @@ from pathlib import Path -from unilabos.registry.external_registry_discovery import discover_registry_paths_from_project +import unilabos.registry.external_registry_discovery as discovery +from unilabos.registry.external_registry_discovery import ( + discover_registry_paths_from_entry_points, + discover_registry_paths_from_project, +) def test_discover_registry_paths_from_pyproject_tool_section(): @@ -26,3 +30,32 @@ def test_discover_registry_paths_returns_empty_when_no_registry_exists(tmp_path) paths = discover_registry_paths_from_project(tmp_path) assert paths == [] + + +def test_discover_from_entry_points(monkeypatch, tmp_path): + reg = tmp_path / "unilabos_registry" + reg.mkdir() + + class _FakeEP: + name = "my_package" + + def load(self): + return lambda: [str(reg)] + + monkeypatch.setattr(discovery, "entry_points", lambda group=None: [_FakeEP()]) + + paths = discover_registry_paths_from_entry_points() + + assert paths == [reg.resolve()] + + +def test_discover_from_entry_points_isolates_bad_entry(monkeypatch): + class _BadEP: + name = "broken" + + def load(self): + raise ImportError("boom") + + monkeypatch.setattr(discovery, "entry_points", lambda group=None: [_BadEP()]) + + assert discover_registry_paths_from_entry_points() == [] diff --git a/unilabos/registry/external_registry_discovery.py b/unilabos/registry/external_registry_discovery.py index 44de59937..26dfcc5ac 100644 --- a/unilabos/registry/external_registry_discovery.py +++ b/unilabos/registry/external_registry_discovery.py @@ -8,6 +8,8 @@ from __future__ import annotations +import logging +from importlib.metadata import entry_points from pathlib import Path from typing import Any @@ -16,6 +18,10 @@ except ModuleNotFoundError: # pragma: no cover - py<3.11 import tomli as tomllib +logger = logging.getLogger(__name__) + +ENTRY_POINT_GROUP = "unilabos.registry" + def discover_registry_paths_from_project(project_root: Path | str) -> list[Path]: root = Path(project_root).resolve() @@ -30,6 +36,40 @@ def discover_registry_paths_from_project(project_root: Path | str) -> list[Path] return [] +def discover_registry_paths_from_entry_points() -> list[Path]: + """Discover registry dirs from installed packages declaring the ``unilabos.registry`` + entry point group (Plan 09 §2.3 #2). + + Each entry point resolves to a callable returning a path or list of paths (e.g. + ``my_package.unilabos_registry:registry_paths``); a non-callable path value is also + accepted. Per-entry failures are isolated. + """ + paths: list[Path] = [] + try: + eps = entry_points(group=ENTRY_POINT_GROUP) + except TypeError: # pragma: no cover - importlib.metadata < 3.10 API + eps = entry_points().get(ENTRY_POINT_GROUP, []) # type: ignore[attr-defined] + for ep in eps: + try: + obj = ep.load() + result = obj() if callable(obj) else obj + items = result if isinstance(result, (list, tuple)) else [result] + for item in items: + candidate = Path(item).resolve() + if candidate.is_dir(): + paths.append(candidate) + except Exception as exc: # noqa: BLE001 - one bad package must not abort discovery + logger.warning("failed to load registry entry point %r: %s", getattr(ep, "name", ep), exc) + # de-duplicate, order-preserving + seen: set[Path] = set() + unique: list[Path] = [] + for p in paths: + if p not in seen: + seen.add(p) + unique.append(p) + return unique + + def _read_pyproject_registry_paths(project_root: Path) -> list[Path]: pyproject = project_root / "pyproject.toml" if not pyproject.is_file(): From 0c172b399629ff20bb5cadf7ea062bfcf5baad57 Mon Sep 17 00:00:00 2001 From: Skyzuo9 <3101065459@qq.com> Date: Sun, 21 Jun 2026 20:29:14 +0800 Subject: [PATCH 13/15] test(ext-registry): F real pylabrobot LiquidHandler via class.init (lib + edge wrapper) Co-authored-by: Cursor (cherry picked from commit 80cfa8c1fd1e8c9f6bf95a8b91bc7d3d14628a9a) --- .../test_external_variant_construction.py | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/integration/test_external_variant_construction.py b/tests/integration/test_external_variant_construction.py index b749af6f1..f76a2846e 100644 --- a/tests/integration/test_external_variant_construction.py +++ b/tests/integration/test_external_variant_construction.py @@ -82,3 +82,69 @@ def test_class_init_via_instantiate_device_node(ros_context): assert driver.name == "lh_runtime" # ${node.id} injected finally: lab_registry.device_type_registry.pop("vendor.lh.model_a", None) + + +# --- Plan 09 T6: real pylabrobot LiquidHandler via class.init (F) ----------------- + +PLR_ENTRY = { + "class": { + "module": "pylabrobot.liquid_handling.liquid_handler:LiquidHandler", + "type": "python", + "init": { + "kwargs": { + "backend": { + "factory": "pylabrobot.liquid_handling.backends.chatterbox:LiquidHandlerChatterboxBackend", + "kwargs": {"num_channels": 8}, + }, + "deck": { + "factory": "pylabrobot.resources:Deck", + "kwargs": {"size_x": 100.0, "size_y": 100.0, "size_z": 10.0}, + }, + "name": "${node.id}", + } + }, + "status_types": {}, + "action_value_mappings": {}, + } +} + + +@pytest.mark.integration +def test_pylabrobot_liquidhandler_built_via_class_init(): + """Two registry entries can share pylabrobot LiquidHandler but pick different + backends via class.init — proven by constructing a real LiquidHandler.""" + pytest.importorskip("pylabrobot") + from unilabos.registry.initializer import build_instance_from_registry_entry + + lh = build_instance_from_registry_entry(PLR_ENTRY, node={"id": "lh_plr", "name": "LH"}, config={}) + + from pylabrobot.liquid_handling.liquid_handler import LiquidHandler + + assert isinstance(lh, LiquidHandler) + assert lh.backend.num_channels == 8 + assert lh.name == "lh_plr" + + +@pytest.mark.integration +def test_pylabrobot_via_instantiate_device_node(ros_context): + """Full edge path for a pylabrobot driver (goes through PyLabRobotCreator).""" + pytest.importorskip("pylabrobot") + from unilabos.registry.registry import lab_registry + from unilabos.resources.resource_tracker import ResourceDictInstance + from unilabos.ros.initialize_device import _instantiate_device_node + + lab_registry.device_type_registry["pylabrobot.lh.chatterbox"] = dict(PLR_ENTRY) + try: + device_config = ResourceDictInstance.get_resource_instance_from_dict({ + "name": "lh_plr_node", + "type": "device", + "class": "pylabrobot.lh.chatterbox", + "config": {}, + }) + node = _instantiate_device_node("lh_plr_node", device_config, "pylabrobot.lh.chatterbox") + assert node is not None + driver = getattr(node, "driver_instance", None) + assert driver is not None + assert driver.backend.num_channels == 8 + finally: + lab_registry.device_type_registry.pop("pylabrobot.lh.chatterbox", None) From 856df44688978b05ad4a93f8246741698cf1c851 Mon Sep 17 00:00:00 2001 From: Skyzuo9 <3101065459@qq.com> Date: Sun, 21 Jun 2026 22:56:38 +0800 Subject: [PATCH 14/15] feat(ext-registry): A wire external registry discovery into startup (devices roots + entry points); B local external-package full-chain test Co-authored-by: Cursor (cherry picked from commit f74eec51ce6f7391cdc567d0443fa02f27cf73db) --- .../external_variant_pkg/pyproject.toml | 6 +++ .../contracts/liquid_handler.yaml | 20 +++++++ .../devices/liquid_handlers.yaml | 53 +++++++++++++++++++ .../test_external_package_full_chain.py | 45 ++++++++++++++++ unilabos/app/main.py | 34 +++++++----- 5 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 tests/registry/fixtures/external_variant_pkg/pyproject.toml create mode 100644 tests/registry/fixtures/external_variant_pkg/unilabos_registry/contracts/liquid_handler.yaml create mode 100644 tests/registry/fixtures/external_variant_pkg/unilabos_registry/devices/liquid_handlers.yaml create mode 100644 tests/registry/test_external_package_full_chain.py diff --git a/tests/registry/fixtures/external_variant_pkg/pyproject.toml b/tests/registry/fixtures/external_variant_pkg/pyproject.toml new file mode 100644 index 000000000..4fea9ca4d --- /dev/null +++ b/tests/registry/fixtures/external_variant_pkg/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "example-variant-pkg" +version = "0.1.0" + +[tool.unilabos.registry] +paths = ["unilabos_registry"] diff --git a/tests/registry/fixtures/external_variant_pkg/unilabos_registry/contracts/liquid_handler.yaml b/tests/registry/fixtures/external_variant_pkg/unilabos_registry/contracts/liquid_handler.yaml new file mode 100644 index 000000000..0fc1f284d --- /dev/null +++ b/tests/registry/fixtures/external_variant_pkg/unilabos_registry/contracts/liquid_handler.yaml @@ -0,0 +1,20 @@ +actions: + setup: + goal: {} + feedback: {} + result: + success: success + schema: + type: object + properties: + goal: + type: object + result: + type: object + properties: + success: + type: boolean + goal_default: {} + handles: {} +status_types: + initialized: bool diff --git a/tests/registry/fixtures/external_variant_pkg/unilabos_registry/devices/liquid_handlers.yaml b/tests/registry/fixtures/external_variant_pkg/unilabos_registry/devices/liquid_handlers.yaml new file mode 100644 index 000000000..f9f26cf64 --- /dev/null +++ b/tests/registry/fixtures/external_variant_pkg/unilabos_registry/devices/liquid_handlers.yaml @@ -0,0 +1,53 @@ +vendor.lh.model_a: + version: 1.0.0 + category: [liquid_handler, vendor] + implementation: + family: vendor.liquid_handler + variant: model_a + class_ref: tests.registry.fixtures.initializer_drivers:SharedDevice + class: + module: tests.registry.fixtures.initializer_drivers:SharedDevice + init: + kwargs: + backend: + factory: tests.registry.fixtures.initializer_drivers:MockBackend + kwargs: + host: ${config.host} + port: ${config.port} + deck: + factory: tests.registry.fixtures.initializer_drivers:MockDeck + kwargs: + name: model-a-deck + name: ${node.id} + channels: 8 + action_value_mappings: + $ref: ../contracts/liquid_handler.yaml#/actions + status_types: + $ref: ../contracts/liquid_handler.yaml#/status_types + +vendor.lh.model_b: + version: 1.0.0 + category: [liquid_handler, vendor] + implementation: + family: vendor.liquid_handler + variant: model_b + class_ref: tests.registry.fixtures.initializer_drivers:SharedDevice + class: + module: tests.registry.fixtures.initializer_drivers:SharedDevice + init: + kwargs: + backend: + factory: tests.registry.fixtures.initializer_drivers:MockBackend + kwargs: + host: ${config.host} + port: ${config.port} + deck: + factory: tests.registry.fixtures.initializer_drivers:MockDeck + kwargs: + name: model-b-deck + name: ${node.id} + channels: 96 + action_value_mappings: + $ref: ../contracts/liquid_handler.yaml#/actions + status_types: + $ref: ../contracts/liquid_handler.yaml#/status_types diff --git a/tests/registry/test_external_package_full_chain.py b/tests/registry/test_external_package_full_chain.py new file mode 100644 index 000000000..6ec6c487f --- /dev/null +++ b/tests/registry/test_external_package_full_chain.py @@ -0,0 +1,45 @@ +"""Plan 09 (B): full local external-package chain — +discover unilabos_registry/ -> load_device_types ($ref expanded) -> construct a +variant via class.init. Mirrors what startup wiring does for `--devices `. +""" + +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from unilabos.registry.external_registry_discovery import discover_registry_paths_from_project +from unilabos.registry.initializer import build_instance_from_registry_entry +from unilabos.registry.registry import Registry + +PKG = Path(__file__).parent / "fixtures" / "external_variant_pkg" + + +def test_external_package_discover_load_construct(): + # 1) discover the package's unilabos_registry/ via pyproject [tool.unilabos.registry] + paths = discover_registry_paths_from_project(PKG) + assert paths == [(PKG / "unilabos_registry").resolve()] + + # 2) load it (the real registry loader, with $ref expansion) + reg = Registry() # singleton (needs unilabos_msgs -> 4090) + if reg._startup_executor is None: + reg._startup_executor = ThreadPoolExecutor(max_workers=2) + reg.load_device_types(paths[0], complete_registry=False) + + a = reg.device_type_registry["vendor.lh.model_a"] + b = reg.device_type_registry["vendor.lh.model_b"] + assert a["class"]["module"].endswith(":SharedDevice") + assert b["class"]["module"].endswith(":SharedDevice") # same class, two entries + assert a["class"]["init"]["kwargs"]["channels"] == 8 + assert b["class"]["init"]["kwargs"]["channels"] == 96 + assert "setup" in a["class"]["action_value_mappings"] # $ref expanded + assert "initialized" in b["class"]["status_types"] + + # 3) construct a variant via class.init (shared class, injected config) + dev_a = build_instance_from_registry_entry(a, node={"id": "lh_a"}, config={"host": "10.0.0.9", "port": 7}) + assert dev_a.name == "lh_a" + assert dev_a.channels == 8 + assert dev_a.backend.host == "10.0.0.9" + assert dev_a.deck.name == "model-a-deck" + + dev_b = build_instance_from_registry_entry(b, node={"id": "lh_b"}, config={"host": "10.0.0.9", "port": 7}) + assert dev_b.channels == 96 + assert dev_b.deck.name == "model-b-deck" diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 4fc8ae269..af167cc94 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -692,18 +692,28 @@ def main(): # 社区包设备直接以 community.. 注册(扫描期命名空间化),不做 alias 桥接 args_dict["_community_namespaces"] = community_result.namespaces - # Plan 09 Task 5: 从已下载的社区/外源包根目录发现 registry 目录,并入 build_registry。 - try: - from unilabos.registry.external_registry_discovery import discover_registry_paths_from_project - - ext_paths: list = [] - for package_root in getattr(community_result, "package_roots", []) or []: - ext_paths.extend(discover_registry_paths_from_project(package_root)) - if ext_paths: - args_dict["_external_registry_paths"] = [str(p) for p in ext_paths] - print_status(f"发现 {len(ext_paths)} 个外源 registry 目录", "info") - except Exception as _ext_exc: # noqa: BLE001 - logger.warning(f"[ext-registry] 外源 registry 发现跳过: {_ext_exc}") + # Plan 09 Task 5: 发现外源包的 unilabos_registry/ 目录,并入 build_registry。 + # 来源:device dirs(社区包 + 显式 --devices)各自的包根 + `unilabos.registry` entry points。 + try: + from pathlib import Path as _Path + + from unilabos.registry.external_registry_discovery import ( + discover_registry_paths_from_entry_points, + discover_registry_paths_from_project, + ) + + _ext: list = [] + for _d in args_dict.get("devices") or []: + _dp = _Path(_d) + _ext.extend(discover_registry_paths_from_project(_dp)) + _ext.extend(discover_registry_paths_from_project(_dp.parent)) + _ext.extend(discover_registry_paths_from_entry_points()) + _ext_str = list(dict.fromkeys(str(p) for p in _ext)) + if _ext_str: + args_dict["_external_registry_paths"] = _ext_str + print_status(f"发现 {len(_ext_str)} 个外源 registry 目录", "info") + except Exception as _ext_exc: # noqa: BLE001 + logger.warning(f"[ext-registry] 外源 registry 发现跳过: {_ext_exc}") # Step 0: AST 分析优先 + YAML 注册表加载 # check_mode 和 upload_registry 都会执行实际 import 验证 From bd2645e552e387a84be669904b63b8670ac93446 Mon Sep 17 00:00:00 2001 From: audit Date: Thu, 25 Jun 2026 17:21:54 +0800 Subject: [PATCH 15/15] feat(ext-registry): package mgmt discovers folder-based unilabos_registry/ devices inspect/upload previously only saw @device decorators and a root-level registry.yaml. Add read_external_registry_devices() (reuses Plan-09 discover_registry_paths_from_project + resolve_yaml_refs, reads devices/*.yaml like the runtime registry) and wire it into inspect_package source priority: root registry.yaml > unilabos_registry/ folder > @device AST. Co-Authored-By: Claude Opus 4.7 --- .../test_package_cli_folder_registry.py | 47 +++++++++++++++ unilabos/app/package_cli.py | 60 ++++++++++++++++++- 2 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 tests/registry/test_package_cli_folder_registry.py diff --git a/tests/registry/test_package_cli_folder_registry.py b/tests/registry/test_package_cli_folder_registry.py new file mode 100644 index 000000000..b5c2cf882 --- /dev/null +++ b/tests/registry/test_package_cli_folder_registry.py @@ -0,0 +1,47 @@ +"""Plan 09 / Plan 04: package-management 必须支持"文件夹式"外部注册表 +(unilabos_registry/devices/*.yaml),而不仅是 @device 装饰器与根目录 registry.yaml。 + +复用 external_variant_pkg fixture(两个变体共享一个 Python class,contracts 经 $ref 复用)。 +""" + +from pathlib import Path + +from unilabos.app.package_cli import ( + inspect_package, + read_external_registry_devices, + read_registry_yaml_devices, +) + +PKG = Path(__file__).parent / "fixtures" / "external_variant_pkg" + + +def test_read_external_registry_devices_discovers_folder_layout(): + # 包根没有 registry.yaml —— 旧的根目录读取器应为空 + assert read_registry_yaml_devices(PKG) == {} + + # 新增的文件夹式读取器应发现 devices/ 下的两个变体 + entries = read_external_registry_devices(PKG) + assert set(entries) == {"vendor.lh.model_a", "vendor.lh.model_b"} + + a, b = entries["vendor.lh.model_a"], entries["vendor.lh.model_b"] + # 同一个 class,不同 init 参数 + assert a["class"]["module"] == b["class"]["module"] + assert a["class"]["init"]["kwargs"]["channels"] == 8 + assert b["class"]["init"]["kwargs"]["channels"] == 96 + # $ref 已展开:contracts/liquid_handler.yaml 的 action/status 已并入条目 + assert "setup" in a["class"]["action_value_mappings"] + assert "initialized" in b["class"]["status_types"] + + +def test_inspect_package_uses_folder_registry_source(tmp_path): + info = inspect_package(str(PKG), namespace=None, out_dir=str(tmp_path)) + + assert sorted(info["devices"]) == ["vendor.lh.model_a", "vendor.lh.model_b"] + assert info["class_namespace"] == "community.example_variant_pkg" + + by_id = {r["id"]: r for r in info["resources"]} + # source_registry 保留各自不同的 class.init(同 class 不同初始化参数) + init_a = by_id["vendor.lh.model_a"]["source_registry"]["class"]["init"]["kwargs"] + init_b = by_id["vendor.lh.model_b"]["source_registry"]["class"]["init"]["kwargs"] + assert init_a["channels"] == 8 + assert init_b["channels"] == 96 diff --git a/unilabos/app/package_cli.py b/unilabos/app/package_cli.py index f288c97f0..bfff2e22b 100644 --- a/unilabos/app/package_cli.py +++ b/unilabos/app/package_cli.py @@ -147,6 +147,54 @@ def read_registry_yaml_devices(pkg_dir: Path) -> Dict[str, Dict[str, Any]]: return entries +def read_external_registry_devices(pkg_dir: Path) -> Dict[str, Dict[str, Any]]: + """读取包内"文件夹式"外部注册表的设备条目,返回 {device_id: entry}。 + + 遵循 Plan 09 外部包注册表约定(与运行时 Registry.load_device_types 同构): + - 注册表根来自 pyproject ``[tool.unilabos.registry] paths``,否则回退 ``unilabos_registry/``; + - 每个根下的 ``devices/*.yaml`` 即设备文件; + - 逐文件用 ``resolve_yaml_refs`` 展开跨文件 ``$ref``(共享 contracts),与运行时一致。 + + 与根目录 ``registry.yaml`` 互补:不要求把条目摊平到包根,目录化注册表即可被纳管。 + """ + try: + import yaml + except ModuleNotFoundError: + logger.warning("[package] 未安装 pyyaml,跳过外部注册表读取") + return {} + + from unilabos.registry.external_registry_discovery import discover_registry_paths_from_project + from unilabos.registry.yaml_ref import resolve_yaml_refs + + registry_roots = discover_registry_paths_from_project(pkg_dir) + if not registry_roots: + return {} + + entries: Dict[str, Dict[str, Any]] = {} + for root in registry_roots: + devices_dir = root / "devices" + if not devices_dir.is_dir(): + continue + for yaml_path in sorted(list(devices_dir.glob("*.yaml")) + list(devices_dir.glob("*.yml"))): + try: + raw = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) + data = resolve_yaml_refs(raw, base_file=yaml_path) + except Exception as exc: + logger.warning(f"[package] 解析外部注册表 {yaml_path} 失败: {exc}") + continue + if not isinstance(data, dict): + continue + for device_id, entry in data.items(): + if not isinstance(entry, dict): + continue + cls = entry.get("class") if isinstance(entry.get("class"), dict) else {} + # devices/ 目录下条目天然是设备;接受带 class.module 或显式 resource_type=device 的条目 + is_device = bool(cls.get("module")) or entry.get("resource_type") == "device" + if is_device: + entries[str(device_id)] = entry + return entries + + def build_archive(pkg_dir: Path, archive_path: Path) -> str: """把包目录打包为 tar.gz,跳过缓存/版本控制目录,返回 "sha256:"。""" archive_path.parent.mkdir(parents=True, exist_ok=True) @@ -367,10 +415,16 @@ def inspect_package( package_info = build_package_info(project, class_namespace, sha256) - # 设备来源优先级:registry.yaml(含完整 action_value_mappings)> @device AST 扫描 + # 设备来源优先级:根目录 registry.yaml > 文件夹式外部注册表(unilabos_registry/) > @device AST 扫描 + # 前两者条目均自带完整 class.action_value_mappings,可直接作为 source_registry。 yaml_entries = read_registry_yaml_devices(pkg_dir) + if not yaml_entries: + yaml_entries = read_external_registry_devices(pkg_dir) + registry_source = "unilabos_registry/" + else: + registry_source = "registry.yaml" if yaml_entries: - device_source = "registry.yaml" + device_source = registry_source device_ids = sorted(yaml_entries) resources = build_resources_from_registry(yaml_entries, package_info) else: @@ -380,7 +434,7 @@ def inspect_package( resources = build_resources(ast_devices, package_info) devices = {rid: None for rid in device_ids} if not resources: - print_status(f"警告:{pkg_dir} 未发现 registry.yaml 或 @device 设备,仅生成 package_info", "warning") + print_status(f"警告:{pkg_dir} 未发现 registry.yaml / unilabos_registry/ 或 @device 设备,仅生成 package_info", "warning") package_info_path = out_path / "package_info.json" resources_path = out_path / "resources.json"