From 3cd2f0780dc47a5be8c030508f3a806d2f175b96 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Fri, 30 Jan 2026 15:26:40 +0000 Subject: [PATCH 1/4] fix: Check if plan parameter is subclass of Device The previous check for whether a parameter was a bluesky device (and would therefore go via the string-to-device lookup) was based on the type being an instance of one of the bluesky protocols. For a device that only extends `Device`, this check would return False and the device could not be injected. Including a check for the type being a subclass of Device allows all device subclasses to be looked up. --- src/blueapi/core/context.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/blueapi/core/context.py b/src/blueapi/core/context.py index 9bf29aec3..796c3e122 100644 --- a/src/blueapi/core/context.py +++ b/src/blueapi/core/context.py @@ -44,6 +44,7 @@ from .bluesky_types import ( BLUESKY_PROTOCOLS, + AsyncDevice, Device, Plan, PlanGenerator, @@ -103,7 +104,11 @@ def qualified_generic_name(target: type) -> str: def is_bluesky_type(typ: type) -> bool: - return typ in BLUESKY_PROTOCOLS or isinstance(typ, BLUESKY_PROTOCOLS) + return ( + typ in BLUESKY_PROTOCOLS + or isinstance(typ, BLUESKY_PROTOCOLS) + or issubclass(typ, AsyncDevice) + ) C = TypeVar("C", covariant=True) From a86086181f772452af5a13ef6ddccb117134bf6b Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Fri, 30 Jan 2026 17:06:04 +0000 Subject: [PATCH 2/4] Check type is a type --- src/blueapi/core/context.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/blueapi/core/context.py b/src/blueapi/core/context.py index 796c3e122..61c597539 100644 --- a/src/blueapi/core/context.py +++ b/src/blueapi/core/context.py @@ -107,7 +107,7 @@ def is_bluesky_type(typ: type) -> bool: return ( typ in BLUESKY_PROTOCOLS or isinstance(typ, BLUESKY_PROTOCOLS) - or issubclass(typ, AsyncDevice) + or (isinstance(typ, type) and issubclass(typ, AsyncDevice)) ) @@ -536,7 +536,7 @@ def _convert_type(self, typ: type | Any, no_default: bool = True) -> type: if typ is NoneType and not no_default: return SkipJsonSchema[NoneType] root = get_origin(typ) - if is_bluesky_type(typ) or (root is not None and is_bluesky_type(root)): + if is_bluesky_type(root or typ): return self._reference(typ) args = get_args(typ) if args: From dfdfdc8f88b9dee62e59b9fdd2f73a7349166e1b Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Fri, 30 Jan 2026 17:23:23 +0000 Subject: [PATCH 3/4] Relax typing on is_bluesky_type get_origin returns 'Any | None' so we should accept the same --- src/blueapi/core/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blueapi/core/context.py b/src/blueapi/core/context.py index 61c597539..26f065b6d 100644 --- a/src/blueapi/core/context.py +++ b/src/blueapi/core/context.py @@ -103,7 +103,7 @@ def qualified_generic_name(target: type) -> str: return f"{qualified_name(target)}{subscript}" -def is_bluesky_type(typ: type) -> bool: +def is_bluesky_type(typ: Any) -> bool: return ( typ in BLUESKY_PROTOCOLS or isinstance(typ, BLUESKY_PROTOCOLS) From 64cd8729ab2d800ba72d22c56082d31126a6d47c Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Mon, 2 Feb 2026 16:59:45 +0000 Subject: [PATCH 4/4] Add regression test for Device lookup --- tests/unit_tests/core/test_context.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/core/test_context.py b/tests/unit_tests/core/test_context.py index 2f31784aa..9cca6fe46 100644 --- a/tests/unit_tests/core/test_context.py +++ b/tests/unit_tests/core/test_context.py @@ -18,8 +18,8 @@ from bluesky.run_engine import RunEngine from bluesky.utils import MsgGenerator from dodal.common import PlanGenerator, inject -from ophyd import Device from ophyd_async.core import ( + Device, PathProvider, StandardDetector, StaticPathProvider, @@ -550,6 +550,20 @@ def demo(named: ConcreteStoppable): ... assert spec["named"][1].default_factory is None +class PlainDevice(Device): + """Class that extends Device without any additional protocols""" + + +def test_device_without_protocol_annotation(empty_context: BlueskyContext): + dev_ref = empty_context._reference(PlainDevice) + + def demo_plan(dev: PlainDevice) -> MsgGenerator: + yield from [] + + spec = empty_context._type_spec_for_function(demo_plan) + assert spec["dev"][0] is dev_ref + + def test_str_default(empty_context: BlueskyContext, sim_motor: Motor, alt_motor: Motor): movable_ref = empty_context._reference(Movable) empty_context.register_device(sim_motor)