Skip to content

Commit a4ce435

Browse files
refactor: Wrap Devices in BlueapiDevice (#1744)
This PR follows #1739. Devices in `BlueskyContext` are now wrapped in a `BlueapiDevice` dataclass. This is similar to #1743. Summary of changes: - `BlueskyContext.devices` was `dict[str, Device]` where Device represented bluesky protocols. Now `BlueskyContext.devices` is a `dict[str, BlueapiDevice]` - `DeviceModel` and the REST side are unchanged - `ctx.devices` is a dictionary of `BlueapiDevice` wrappers - `find_device` will look up the first part of the address (`stage` in `stage.x`) in `ctx.devices` and returns a `Device` - `find_component` is no longer a standalone function but has been moved to `BlueapiDevice`. This functions follows from `find_device`, e.g. finds the `x` on `stage` to give `stage.x` --------- Co-authored-by: Peter Holloway <peter.holloway@diamond.ac.uk>
1 parent 4bb49a9 commit a4ce435

5 files changed

Lines changed: 58 additions & 78 deletions

File tree

‎src/blueapi/core/bluesky_types.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,22 @@ def watch(self, __func: Callable) -> None:
131131
"""
132132

133133
...
134+
135+
136+
@dataclass
137+
class BlueapiDevice:
138+
"""Wrapper around Device held in the context.
139+
Also contains find_component function to look up child devices.
140+
At the moment this just holds the Device but will later hold child device trees"""
141+
142+
device: Device
143+
144+
def find_component(self, addr: list[str]) -> Device | None:
145+
dev = self.device
146+
for name in addr:
147+
dev = getattr(dev, name, None)
148+
if dev is None:
149+
return None
150+
elif not is_bluesky_compatible_device(dev):
151+
raise ValueError(f"Found {dev} but it is not a device")
152+
return dev

‎src/blueapi/core/context.py‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,13 @@
4242
from .bluesky_types import (
4343
BLUESKY_PROTOCOLS,
4444
AsyncDevice,
45+
BlueapiDevice,
4546
Device,
4647
Plan,
4748
PlanGenerator,
4849
is_bluesky_compatible_device,
4950
is_bluesky_plan_generator,
5051
)
51-
from .device_lookup import find_component
5252

5353
LOGGER = logging.getLogger(__name__)
5454

@@ -128,7 +128,7 @@ class BlueskyContext:
128128
numtracker: NumtrackerClient | None = field(default=None, init=False, repr=False)
129129
path_provider: PathProvider | None = None
130130
plans: dict[str, Plan] = field(default_factory=dict)
131-
devices: dict[str, Device] = field(default_factory=dict)
131+
devices: dict[str, BlueapiDevice] = field(default_factory=dict)
132132

133133
_reference_cache: dict[type, type] = field(default_factory=dict)
134134
_warned_name_clash: bool = field(default=False, init=False, repr=False)
@@ -196,10 +196,12 @@ def find_device(self, addr: str | list[str]) -> Device | None:
196196
"""
197197

198198
if isinstance(addr, str):
199-
list_addr = list(addr.split("."))
200-
return self.find_device(list_addr)
201-
else:
202-
return find_component(self.devices, addr)
199+
return self.find_device(addr.split("."))
200+
201+
head, *tail = addr
202+
if entry := self.devices.get(head):
203+
return entry.find_component(tail)
204+
return None
203205

204206
def with_config(self, config: EnvironmentConfig) -> None:
205207
if config.metadata is not None:
@@ -381,8 +383,8 @@ def _register_children(self, device: Device) -> None:
381383
def _add_device(self, name: str, device: Device) -> bool:
382384
"""Add a device unless a different one is already registered as name."""
383385

384-
existing = self.devices.setdefault(name, device)
385-
if existing is not device:
386+
existing = self.devices.setdefault(name, BlueapiDevice(device))
387+
if existing.device is not device:
386388
# Expected for e.g. a motor's user_readback, which has the motor's name.
387389
# The caller skips its children too, as their names start with this one
388390
if not self._warned_name_clash:
@@ -448,11 +450,12 @@ def __get_pydantic_json_schema__(
448450
json_schema = handler(core_schema)
449451
json_schema = handler.resolve_ref_schema(json_schema)
450452
json_schema["type"] = qualified_name(target)
453+
# devices are wrapped in BlueapiDevice, so check entry.device
451454
json_schema["enum"] = sorted(
452455
[
453456
name
454-
for name, device in self.devices.items()
455-
if isinstance(device, cls.origin or target)
457+
for name, entry in self.devices.items()
458+
if isinstance(entry.device, cls.origin or target)
456459
]
457460
)
458461
if cls.args:

‎src/blueapi/core/device_lookup.py‎

Lines changed: 0 additions & 47 deletions
This file was deleted.

‎src/blueapi/service/interface.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,9 @@ def get_plan(name: str) -> PlanModel:
191191

192192
def get_devices() -> list[DeviceModel]:
193193
"""Get all available devices in the BlueskyContext"""
194-
return [DeviceModel.from_device(device) for device in context().devices.values()]
194+
return [
195+
DeviceModel.from_device(entry.device) for entry in context().devices.values()
196+
]
195197

196198

197199
def get_device(name: str) -> DeviceModel:

‎tests/unit_tests/core/test_context.py‎

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
TiledConfig,
5151
)
5252
from blueapi.core import BlueskyContext, is_bluesky_compatible_device
53+
from blueapi.core.bluesky_types import BlueapiDevice
5354
from blueapi.core.context import generic_bounds, qualified_name
5455
from blueapi.core.protocols import DeviceConnectResult, DeviceManager
5556
from blueapi.utils.invalid_config_error import InvalidConfigError
@@ -213,8 +214,8 @@ def demo_plan(foo: int, mov: Movable) -> MsgGenerator:
213214
# Which child signals exist depends on the upstream Motor class
214215
expected_movables = sorted(
215216
name
216-
for name, device in devicey_context.devices.items()
217-
if isinstance(device, Movable)
217+
for name, entry in devicey_context.devices.items()
218+
if isinstance(entry.device, Movable)
218219
)
219220
assert {"sim", "sim-velocity"} <= set(expected_movables)
220221
assert schema["properties"] == {
@@ -277,14 +278,14 @@ def test_only_plans_from_all_in_module_detected(empty_context: BlueskyContext):
277278

278279
def test_add_named_device(empty_context: BlueskyContext, sim_motor: Motor):
279280
empty_context.register_device(sim_motor)
280-
assert empty_context.devices[SIM_MOTOR_NAME] is sim_motor
281+
assert empty_context.devices[SIM_MOTOR_NAME].device is sim_motor
281282

282283

283284
def test_add_nameless_device(
284285
empty_context: BlueskyContext, some_configurable: SomeConfigurable
285286
):
286287
empty_context.register_device(some_configurable, "conf")
287-
assert empty_context.devices["conf"] is some_configurable
288+
assert empty_context.devices["conf"].device is some_configurable
288289

289290

290291
def test_add_nameless_device_without_override(
@@ -297,7 +298,7 @@ def test_add_nameless_device_without_override(
297298

298299
def test_override_device_name(empty_context: BlueskyContext, sim_motor: Motor):
299300
empty_context.register_device(sim_motor, "foo")
300-
assert empty_context.devices["foo"] is sim_motor
301+
assert empty_context.devices["foo"].device is sim_motor
301302

302303

303304
class Axis(Device):
@@ -336,9 +337,11 @@ def test_register_device_registers_children_recursively(
336337
"table-mount-y-position",
337338
"table-mount-y-velocity",
338339
}
339-
assert empty_context.devices["table-mount"] is table.mount
340-
assert empty_context.devices["table-mount-x"] is table.mount.x
341-
assert empty_context.devices["table-mount-x-velocity"] is table.mount.x.velocity
340+
assert empty_context.devices["table-mount"].device is table.mount
341+
assert empty_context.devices["table-mount-x"].device is table.mount.x
342+
assert (
343+
empty_context.devices["table-mount-x-velocity"].device is table.mount.x.velocity
344+
)
342345

343346

344347
def test_child_sharing_parent_name_is_skipped_and_warned_once(
@@ -350,7 +353,7 @@ def test_child_sharing_parent_name_is_skipped_and_warned_once(
350353
empty_context.register_device(motor)
351354
empty_context.register_device(SimMotor(name="other"))
352355

353-
assert empty_context.devices["motor"] is motor
356+
assert empty_context.devices["motor"].device is motor
354357
assert motor.user_readback not in empty_context.devices.values()
355358
assert empty_context.find_device("motor.user_readback") is motor.user_readback
356359
records = caplog.get_records("call")
@@ -373,9 +376,9 @@ def test_device_vector_children_are_registered(empty_context: BlueskyContext):
373376
bank = AxisBank(name="bank")
374377
empty_context.register_device(bank)
375378

376-
assert empty_context.devices["bank-axes"] is bank.axes
377-
assert empty_context.devices["bank-axes-1"] is bank.axes[1]
378-
assert empty_context.devices["bank-axes-2-velocity"] is bank.axes[2].velocity
379+
assert empty_context.devices["bank-axes"].device is bank.axes
380+
assert empty_context.devices["bank-axes-1"].device is bank.axes[1]
381+
assert empty_context.devices["bank-axes-2-velocity"].device is bank.axes[2].velocity
379382

380383

381384
def test_overridden_registration_name_does_not_rename_children(
@@ -386,9 +389,9 @@ def test_overridden_registration_name_does_not_rename_children(
386389
motor = SimMotor(name="motor")
387390
empty_context.register_device(motor, name="foo")
388391

389-
assert empty_context.devices["foo"] is motor
390-
assert empty_context.devices["motor"] is motor.user_readback
391-
assert empty_context.devices["motor-velocity"] is motor.velocity
392+
assert empty_context.devices["foo"].device is motor
393+
assert empty_context.devices["motor"].device is motor.user_readback
394+
assert empty_context.devices["motor-velocity"].device is motor.velocity
392395

393396

394397
def test_unrelated_name_collision_keeps_existing_device_and_logs(
@@ -401,9 +404,9 @@ def test_unrelated_name_collision_keeps_existing_device_and_logs(
401404
stage = Stage(name="stage")
402405
empty_context.register_device(stage)
403406

404-
assert empty_context.devices["stage-x"] is sentinel
407+
assert empty_context.devices["stage-x"].device is sentinel
405408
assert "stage-x-position" not in empty_context.devices
406-
assert empty_context.devices["stage-y"] is stage.y
409+
assert empty_context.devices["stage-y"].device is stage.y
407410
assert any("stage-x" in record.message for record in caplog.get_records("call"))
408411

409412

@@ -417,7 +420,7 @@ def test_top_level_name_collision_keeps_existing_device_and_logs(
417420
motor = SimMotor(name="stage-x")
418421
empty_context.register_device(motor)
419422

420-
assert empty_context.devices["stage-x"] is stage.x
423+
assert empty_context.devices["stage-x"].device is stage.x
421424
assert motor.velocity not in empty_context.devices.values()
422425
assert any("stage-x" in record.message for record in caplog.get_records("call"))
423426

@@ -898,7 +901,7 @@ def test_single_device_manager(empty_context: BlueskyContext):
898901
sdm = StaticDeviceManager(devices={"foo": foo})
899902
empty_context.with_device_manager(sdm)
900903

901-
assert empty_context.devices == {"foo": foo}
904+
assert empty_context.devices == {"foo": BlueapiDevice(foo)}
902905

903906

904907
def test_device_manager_build_error(empty_context: BlueskyContext):
@@ -968,7 +971,7 @@ def test_device_manager_environment_source(empty_context: BlueskyContext):
968971
with patch("blueapi.core.context.import_module") as imp_mod:
969972
imp_mod.side_effect = lambda mod: dev_mod if mod == "foo.bar" else None
970973
empty_context.with_config(env)
971-
assert empty_context.devices == {"foo": foo}
974+
assert empty_context.devices == {"foo": BlueapiDevice(foo)}
972975

973976

974977
def test_non_device_manager_errors(empty_context: BlueskyContext):

0 commit comments

Comments
 (0)