diff --git a/tests/integration/test_external_variant_construction.py b/tests/integration/test_external_variant_construction.py new file mode 100644 index 000000000..f76a2846e --- /dev/null +++ b/tests/integration/test_external_variant_construction.py @@ -0,0 +1,150 @@ +"""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", # 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") + 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 + 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) 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/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/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/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/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_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/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/tests/registry/test_external_registry_discovery.py b/tests/registry/test_external_registry_discovery.py new file mode 100644 index 000000000..1bb2edda7 --- /dev/null +++ b/tests/registry/test_external_registry_discovery.py @@ -0,0 +1,61 @@ +"""Plan 09 Task 1: external registry path discovery.""" + +from pathlib import Path + +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(): + 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 == [] + + +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/tests/registry/test_initializer.py b/tests/registry/test_initializer.py new file mode 100644 index 000000000..33eaec4e5 --- /dev/null +++ b/tests/registry/test_initializer.py @@ -0,0 +1,89 @@ +"""Plan 09 Task 3: initializer resolver.""" + +from unilabos.registry.initializer import build_instance_from_registry_entry, resolve_init_kwargs + + +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" + + +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/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/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/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/tests/registry/test_yaml_ref.py b/tests/registry/test_yaml_ref.py new file mode 100644 index 000000000..bedcb0a73 --- /dev/null +++ b/tests/registry/test_yaml_ref.py @@ -0,0 +1,43 @@ +"""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_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") + 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/app/main.py b/unilabos/app/main.py index 7ee6bbb85..af167cc94 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -692,6 +692,29 @@ def main(): # 社区包设备直接以 community.. 注册(扫描期命名空间化),不做 alias 桥接 args_dict["_community_namespaces"] = community_result.namespaces + # 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 验证 devices_dirs = args_dict.get("devices", None) @@ -705,6 +728,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/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" 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/registry/external_registry_discovery.py b/unilabos/registry/external_registry_discovery.py new file mode 100644 index 000000000..26dfcc5ac --- /dev/null +++ b/unilabos/registry/external_registry_discovery.py @@ -0,0 +1,99 @@ +"""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 + +import logging +from importlib.metadata import entry_points +from pathlib import Path +from typing import Any + +try: + import tomllib +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() + 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 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(): + 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 diff --git a/unilabos/registry/initializer.py b/unilabos/registry/initializer.py new file mode 100644 index 000000000..b1cc9e09e --- /dev/null +++ b/unilabos/registry/initializer.py @@ -0,0 +1,110 @@ +"""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 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}") + 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 diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 94a437f50..357934d64 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, @@ -122,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" ) @@ -1836,7 +1847,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, [] @@ -2495,6 +2509,7 @@ def build_registry( complete_registry=False, external_only=False, community_namespaces=None, + external_registry_paths=None, ): """ 构建或获取Registry单例实例 @@ -2515,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 设备模块) diff --git a/unilabos/registry/yaml_ref.py b/unilabos/registry/yaml_ref.py new file mode 100644 index 000000000..6fede43f3 --- /dev/null +++ b/unilabos/registry/yaml_ref.py @@ -0,0 +1,86 @@ +"""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"}: + 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): + 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 diff --git a/unilabos/ros/initialize_device.py b/unilabos/ros/initialize_device.py index 675814adc..9d6113fe2 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}") @@ -47,9 +55,24 @@ 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)} + # 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 = 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