Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
4b75464
feat(ext-registry): T1 discover external registry paths (pyproject + …
Skyzuo9 Jun 20, 2026
dacdf22
feat(ext-registry): T2 resolve YAML $ref (relative path + json pointe…
Skyzuo9 Jun 20, 2026
6ac4126
feat(ext-registry): T3 build instances from class.init (factory/value…
Skyzuo9 Jun 20, 2026
44e4088
feat(ext-registry): T4 expand $ref in device YAML loading + multi-var…
Skyzuo9 Jun 20, 2026
dc67da8
feat(ext-registry): T5 wire external registry paths into setup/build_…
Skyzuo9 Jun 20, 2026
303732f
feat(ext-registry): T7 community alias resolution + graph lookup fall…
Skyzuo9 Jun 20, 2026
5540f2a
feat(ext-registry): T6 resolve class.init into driver_params (ROS wra…
Skyzuo9 Jun 20, 2026
a44a8d5
test(ext-registry): T6 integration — class.init built via real creato…
Skyzuo9 Jun 20, 2026
72eda28
fix(ext-registry): T6 use resolved init kwargs as driver_params (conf…
Skyzuo9 Jun 20, 2026
ca41d94
test(ext-registry): T6 integration use valid ROS node name (no hyphen)
Skyzuo9 Jun 20, 2026
8dbccee
fix(ext-registry): yaml $ref only expands cross-file refs; preserve s…
Skyzuo9 Jun 21, 2026
9479c31
feat(ext-registry): D discover registry paths from unilabos.registry …
Skyzuo9 Jun 21, 2026
0c172b3
test(ext-registry): F real pylabrobot LiquidHandler via class.init (l…
Skyzuo9 Jun 21, 2026
856df44
feat(ext-registry): A wire external registry discovery into startup (…
Skyzuo9 Jun 21, 2026
bd2645e
feat(ext-registry): package mgmt discovers folder-based unilabos_regi…
Jun 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions tests/integration/test_external_variant_construction.py
Original file line number Diff line number Diff line change
@@ -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)
Empty file added tests/registry/__init__.py
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[project]
name = "external-variant-package"
version = "0.1.0"

[tool.unilabos.registry]
paths = ["unilabos_registry"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package:
name: external-variant-package
version: 0.1.0
6 changes: 6 additions & 0 deletions tests/registry/fixtures/external_variant_pkg/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[project]
name = "example-variant-pkg"
version = "0.1.0"

[tool.unilabos.registry]
paths = ["unilabos_registry"]
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions tests/registry/fixtures/initializer_drivers.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions tests/registry/fixtures/ref_registry/contracts/liquid_handler.yaml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions tests/registry/test_community_alias.py
Original file line number Diff line number Diff line change
@@ -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", {})
Loading
Loading