From da06b44179de892b6ea71d5c73ce03bf98676581 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Thu, 10 Sep 2026 16:38:21 +0000 Subject: [PATCH 1/4] Split BlueapiClient, Devices, and plans --- src/blueapi/client/client.py | 215 +------------------------ src/blueapi/client/devices.py | 58 +++++++ src/blueapi/client/plans.py | 154 ++++++++++++++++++ src/blueapi/client/protocols.py | 18 +++ tests/unit_tests/client/test_client.py | 11 +- 5 files changed, 240 insertions(+), 216 deletions(-) create mode 100644 src/blueapi/client/devices.py create mode 100644 src/blueapi/client/plans.py create mode 100644 src/blueapi/client/protocols.py diff --git a/src/blueapi/client/client.py b/src/blueapi/client/client.py index 66a79d8dd..1c32660bf 100644 --- a/src/blueapi/client/client.py +++ b/src/blueapi/client/client.py @@ -6,14 +6,11 @@ from contextlib import suppress from functools import cached_property from pathlib import Path -from typing import Any, Self +from typing import Self from bluesky_stomp.messaging import MessageContext, StompClient from bluesky_stomp.models import Broker -from observability_utils.tracing import ( - get_tracer, - start_as_current_span, -) +from observability_utils.tracing import get_tracer, start_as_current_span from blueapi.config import ( ApplicationConfig, @@ -38,15 +35,17 @@ ) from blueapi.utils import deprecated from blueapi.worker import WorkerEvent, WorkerState -from blueapi.worker.event import ProgressEvent, TaskError, TaskResult, TaskStatus +from blueapi.worker.event import ProgressEvent, TaskStatus from blueapi.worker.task_worker import TrackableTask +from .devices import DeviceCache from .event_bus import AnyEvent, EventBusClient, OnAnyEvent +from .plans import PlanCache +from .protocols import ClientProtocol from .rest import ( BlueapiRestClient, BlueskyRemoteControlError, BlueskyRequestError, - NotFoundError, ServiceUnavailableError, ) @@ -55,187 +54,12 @@ log = logging.getLogger(__name__) -_REPR_MAX_LENGTH = 100 -_REPR_MAX_ARGS_INLINE = 3 -_JSON_TYPE_MAP = { - "string": "str", - "integer": "int", - "boolean": "bool", - "number": "float", - "object": "dict", -} - class MissingInstrumentSessionError(Exception): pass -class PlanCache: - def __init__(self, client: "BlueapiClient", plans: list[PlanModel]): - self._cache = { - model.name: Plan(name=model.name, model=model, client=client) - for model in plans - } - for name, plan in self._cache.items(): - if name.startswith("_"): - continue - setattr(self, name, plan) - - def __getitem__(self, name: str) -> "Plan": - return self._cache[name] - - def __getattr__(self, name: str) -> "Plan": - raise AttributeError(f"No plan named '{name}' available") - - def __iter__(self): - return iter(self._cache.values()) - - def __repr__(self) -> str: - return f"PlanCache({len(self._cache)} plans)" - - -class DeviceCache: - def __init__(self, rest: BlueapiRestClient): - self._rest = rest - self._cache = { - model.name: DeviceRef(name=model.name, cache=self, model=model) - for model in rest.get_devices().devices - } - for name, device in self._cache.items(): - if name.startswith("_"): - continue - setattr(self, name, device) - - def __getitem__(self, name: str) -> "DeviceRef": - if dev := self._cache.get(name): - return dev - try: - model = self._rest.get_device(name) - device = DeviceRef(name=name, cache=self, model=model) - self._cache[name] = device - setattr(self, model.name, device) - return device - except NotFoundError as e: - raise AttributeError(f"No device named '{name}' available") from e - - def __getattr__(self, name: str) -> "DeviceRef": - if name.startswith("_"): - return super().__getattribute__(name) - return self[name] - - def __iter__(self): - return iter(self._cache.values()) - - def __repr__(self) -> str: - return f"DeviceCache({len(self._cache)} devices)" - - -class DeviceRef: - name: str - model: DeviceModel - _cache: DeviceCache - - def __init__(self, name: str, cache: DeviceCache, model: DeviceModel): - self.name = name - self.model = model - self._cache = cache - - def __getattr__(self, name) -> "DeviceRef": - if name.startswith("_"): - raise AttributeError(f"No child device named {name}") - return self._cache[f"{self.name}.{name}"] - - def __repr__(self): - return f"Device({self.name})" - - -class Plan: - def __init__(self, name, model: PlanModel, client: "BlueapiClient"): - self.name = name - self.model = model - self._client = client - self.__doc__ = model.description - - def __call__(self, *args, **kwargs) -> Any: - req = TaskRequest( - name=self.name, - params=self._build_args(*args, **kwargs), - instrument_session=self._client.instrument_session, - ) - match self._client.run_task(req): - case TaskStatus(result=TaskResult(result=res)): - return res - case TaskStatus(result=TaskError(type=typ, message=msg)): - raise PlanFailedError(typ, msg) - - @property - def help_text(self) -> str: - return self.model.description or f"Plan {self!r}" - - @property - def properties(self) -> dict[str, Any]: - return self.model.parameter_schema.get("properties", {}) - - @property - def required(self) -> list[str]: - return self.model.parameter_schema.get("required", []) - - def _build_args(self, *args, **kwargs): - log.info( - "Building args for %s, using %s and %s", - "[" + ",".join(self.properties) + "]", - args, - kwargs, - ) - - if len(args) > len(self.properties): - raise TypeError(f"{self.name} got too many arguments") - if extra := {k for k in kwargs if k not in self.properties}: - raise TypeError(f"{self.name} got unexpected arguments: {extra}") - - params = {} - # Initially fill parameters using positional args assuming the order - # from the parameter_schema - for req, arg in zip(self.properties, args, strict=False): - params[req] = arg - - # Then append any values given via kwargs - for key, value in kwargs.items(): - # If we've already assumed a positional arg was this value, bail out - if key in params: - raise TypeError(f"{self.name} got multiple values for {key}") - params[key] = value - - if missing := {k for k in self.required if k not in params}: - raise TypeError(f"Missing argument(s) for {missing}") - return params - - def __repr__(self) -> str: - required = set(self.required) - - def _format_arg(name: str, info: dict[str, Any]) -> str: - typ = _pretty_type(info) - default = info.get("default") - - if name in required: - return f"{name}: {typ}" - if default := info.get("default"): - return f"{name}: {typ} = {default!r}" - return f"{name}: {typ} | None = None" - - args = [_format_arg(name, info) for name, info in self.properties.items()] - single_line = f"{self.name}({', '.join(args)})" - - if len(single_line) <= _REPR_MAX_LENGTH and len(args) <= _REPR_MAX_ARGS_INLINE: - return single_line - - indent = " " - # Fall back to multiline if too many arguments or too long. - multiline_args = ",\n".join(f"{indent}{arg}" for arg in args) - return f"{self.name}(\n{multiline_args}\n)" - - -class BlueapiClient: +class BlueapiClient(ClientProtocol): """Unified client for controlling blueapi""" _rest: BlueapiRestClient @@ -828,28 +652,3 @@ def logout(self): if sm := self._rest.session_manager: sm.logout() self._rest.session_manager = None - - -class PlanFailedError(Exception): - def __init__(self, typ: str, message: str): - super().__init__(message) - self._type = typ - - -def _pretty_type(schema: dict[str, Any]) -> str: - if "$ref" in schema: - return schema["$ref"].split("/")[-1] - - if schema.get("type") == "array": - item_schema = schema.get("items", {}) - inner = _pretty_type(item_schema) - return f"list[{inner}]" - - if "anyOf" in schema: - return " | ".join(_pretty_type(s) for s in schema["anyOf"]) - - json_type = schema.get("type") - if isinstance(json_type, str): - return _JSON_TYPE_MAP.get(json_type, json_type.split(".")[-1]) - - return "Any" diff --git a/src/blueapi/client/devices.py b/src/blueapi/client/devices.py new file mode 100644 index 000000000..a528fed9f --- /dev/null +++ b/src/blueapi/client/devices.py @@ -0,0 +1,58 @@ +from blueapi.service.model import DeviceModel + +from .rest import BlueapiRestClient, NotFoundError + + +class DeviceCache: + def __init__(self, rest: BlueapiRestClient): + self._rest = rest + self._cache = { + model.name: DeviceRef(name=model.name, cache=self, model=model) + for model in rest.get_devices().devices + } + for name, device in self._cache.items(): + if name.startswith("_"): + continue + setattr(self, name, device) + + def __getitem__(self, name: str) -> "DeviceRef": + if dev := self._cache.get(name): + return dev + try: + model = self._rest.get_device(name) + device = DeviceRef(name=name, cache=self, model=model) + self._cache[name] = device + setattr(self, model.name, device) + return device + except NotFoundError as e: + raise AttributeError(f"No device named '{name}' available") from e + + def __getattr__(self, name: str) -> "DeviceRef": + if name.startswith("_"): + return super().__getattribute__(name) + return self[name] + + def __iter__(self): + return iter(self._cache.values()) + + def __repr__(self) -> str: + return f"DeviceCache({len(self._cache)} devices)" + + +class DeviceRef: + name: str + model: DeviceModel + _cache: DeviceCache + + def __init__(self, name: str, cache: DeviceCache, model: DeviceModel): + self.name = name + self.model = model + self._cache = cache + + def __getattr__(self, name) -> "DeviceRef": + if name.startswith("_"): + raise AttributeError(f"No child device named {name}") + return self._cache[f"{self.name}.{name}"] + + def __repr__(self): + return f"Device({self.name})" diff --git a/src/blueapi/client/plans.py b/src/blueapi/client/plans.py new file mode 100644 index 000000000..25967257d --- /dev/null +++ b/src/blueapi/client/plans.py @@ -0,0 +1,154 @@ +import logging +from typing import Any + +from blueapi.service.model import PlanModel, TaskRequest +from blueapi.worker.event import TaskError, TaskResult, TaskStatus + +from .protocols import ClientProtocol + +_REPR_MAX_LENGTH = 100 +_REPR_MAX_ARGS_INLINE = 3 +_JSON_TYPE_MAP = { + "string": "str", + "integer": "int", + "boolean": "bool", + "number": "float", + "object": "dict", +} + +log = logging.getLogger(__name__) + + +class PlanFailedError(Exception): + def __init__(self, typ: str, message: str): + super().__init__(message) + self._type = typ + + +class PlanCache: + def __init__(self, client: ClientProtocol, plans: list[PlanModel]): + self._cache = { + model.name: Plan(name=model.name, model=model, client=client) + for model in plans + } + for name, plan in self._cache.items(): + if name.startswith("_"): + continue + setattr(self, name, plan) + + def __getitem__(self, name: str) -> "Plan": + return self._cache[name] + + def __getattr__(self, name: str) -> "Plan": + raise AttributeError(f"No plan named '{name}' available") + + def __iter__(self): + return iter(self._cache.values()) + + def __repr__(self) -> str: + return f"PlanCache({len(self._cache)} plans)" + + +class Plan: + def __init__(self, name, model: PlanModel, client: ClientProtocol): + self.name = name + self.model = model + self._client = client + self.__doc__ = model.description + + def __call__(self, *args, **kwargs) -> Any: + req = TaskRequest( + name=self.name, + params=self._build_args(*args, **kwargs), + instrument_session=self._client.instrument_session, + ) + match self._client.run_task(req): + case TaskStatus(result=TaskResult(result=res)): + return res + case TaskStatus(result=TaskError(type=typ, message=msg)): + raise PlanFailedError(typ, msg) + + @property + def help_text(self) -> str: + return self.model.description or f"Plan {self!r}" + + @property + def properties(self) -> dict[str, Any]: + return self.model.parameter_schema.get("properties", {}) + + @property + def required(self) -> list[str]: + return self.model.parameter_schema.get("required", []) + + def _build_args(self, *args, **kwargs): + log.info( + "Building args for %s, using %s and %s", + "[" + ",".join(self.properties) + "]", + args, + kwargs, + ) + + if len(args) > len(self.properties): + raise TypeError(f"{self.name} got too many arguments") + if extra := {k for k in kwargs if k not in self.properties}: + raise TypeError(f"{self.name} got unexpected arguments: {extra}") + + params = {} + # Initially fill parameters using positional args assuming the order + # from the parameter_schema + for req, arg in zip(self.properties, args, strict=False): + params[req] = arg + + # Then append any values given via kwargs + for key, value in kwargs.items(): + # If we've already assumed a positional arg was this value, bail out + if key in params: + raise TypeError(f"{self.name} got multiple values for {key}") + params[key] = value + + if missing := {k for k in self.required if k not in params}: + raise TypeError(f"Missing argument(s) for {missing}") + return params + + def __repr__(self) -> str: + required = set(self.required) + + def _format_arg(name: str, info: dict[str, Any]) -> str: + typ = _pretty_type(info) + default = info.get("default") + + if name in required: + return f"{name}: {typ}" + if default := info.get("default"): + return f"{name}: {typ} = {default!r}" + return f"{name}: {typ} | None = None" + + args = [_format_arg(name, info) for name, info in self.properties.items()] + single_line = f"{self.name}({', '.join(args)})" + + if len(single_line) <= _REPR_MAX_LENGTH and len(args) <= _REPR_MAX_ARGS_INLINE: + return single_line + + indent = " " + # Fall back to multiline if too many arguments or too long. + multiline_args = ",\n".join(f"{indent}{arg}" for arg in args) + return f"{self.name}(\n{multiline_args}\n)" + + +def _pretty_type(schema: dict[str, Any]) -> str: + if "$ref" in schema: + return schema["$ref"].split("/")[-1] + + if schema.get("type") == "array": + item_schema = schema.get("items", {}) + inner = _pretty_type(item_schema) + return f"list[{inner}]" + + if "anyOf" in schema: + return " | ".join(_pretty_type(s) for s in schema["anyOf"]) + + json_type = schema.get("type") + if isinstance(json_type, str): + return _JSON_TYPE_MAP.get(json_type, json_type.split(".")[-1]) + + return "Any" diff --git a/src/blueapi/client/protocols.py b/src/blueapi/client/protocols.py new file mode 100644 index 000000000..d89efb8cc --- /dev/null +++ b/src/blueapi/client/protocols.py @@ -0,0 +1,18 @@ +from typing import Protocol + +from blueapi.service.model import TaskRequest +from blueapi.worker.event import TaskStatus + +from .event_bus import OnAnyEvent + + +class ClientProtocol(Protocol): + def run_task( + self, + task: TaskRequest, + on_event: OnAnyEvent | None = None, + timeout: float | None = None, + ) -> TaskStatus: ... + + @property + def instrument_session(self) -> str: ... diff --git a/tests/unit_tests/client/test_client.py b/tests/unit_tests/client/test_client.py index d5b0493ad..6ce09c1e0 100644 --- a/tests/unit_tests/client/test_client.py +++ b/tests/unit_tests/client/test_client.py @@ -12,15 +12,10 @@ from pydantic import HttpUrl from blueapi.client import BlueapiClient -from blueapi.client.client import ( - DeviceCache, - DeviceRef, - MissingInstrumentSessionError, - Plan, - PlanCache, - PlanFailedError, -) +from blueapi.client.client import DeviceCache, MissingInstrumentSessionError, PlanCache +from blueapi.client.devices import DeviceRef from blueapi.client.event_bus import AnyEvent, EventBusClient +from blueapi.client.plans import Plan, PlanFailedError from blueapi.client.rest import ( BlueapiRestClient, BlueskyRemoteControlError, From def367c331763de3687b8d0e47671d7e4cd807c3 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Fri, 11 Sep 2026 09:42:50 +0000 Subject: [PATCH 2/4] Break circular dependencies, make tests mirror src --- src/blueapi/client/client.py | 4 +- .../client/{devices.py => device_cache.py} | 41 ++- .../client/{plans.py => plan_cache.py} | 20 +- src/blueapi/client/protocols.py | 12 +- src/blueapi/client/rest.py | 6 +- tests/unit_tests/client/conftest.py | 48 +++ tests/unit_tests/client/constants.py | 66 ++++ tests/unit_tests/client/test_client.py | 332 +----------------- tests/unit_tests/client/test_device_cache.py | 53 +++ tests/unit_tests/client/test_plan_cache.py | 174 +++++++++ tests/unit_tests/client/test_rest.py | 6 +- 11 files changed, 418 insertions(+), 344 deletions(-) rename src/blueapi/client/{devices.py => device_cache.py} (53%) rename src/blueapi/client/{plans.py => plan_cache.py} (87%) create mode 100644 tests/unit_tests/client/conftest.py create mode 100644 tests/unit_tests/client/constants.py create mode 100644 tests/unit_tests/client/test_device_cache.py create mode 100644 tests/unit_tests/client/test_plan_cache.py diff --git a/src/blueapi/client/client.py b/src/blueapi/client/client.py index 1c32660bf..e393da659 100644 --- a/src/blueapi/client/client.py +++ b/src/blueapi/client/client.py @@ -38,9 +38,9 @@ from blueapi.worker.event import ProgressEvent, TaskStatus from blueapi.worker.task_worker import TrackableTask -from .devices import DeviceCache +from .device_cache import DeviceCache from .event_bus import AnyEvent, EventBusClient, OnAnyEvent -from .plans import PlanCache +from .plan_cache import PlanCache from .protocols import ClientProtocol from .rest import ( BlueapiRestClient, diff --git a/src/blueapi/client/devices.py b/src/blueapi/client/device_cache.py similarity index 53% rename from src/blueapi/client/devices.py rename to src/blueapi/client/device_cache.py index a528fed9f..ffdf6da71 100644 --- a/src/blueapi/client/devices.py +++ b/src/blueapi/client/device_cache.py @@ -1,13 +1,16 @@ from blueapi.service.model import DeviceModel -from .rest import BlueapiRestClient, NotFoundError +from .protocols import ClientObjectRef, RestClientProtocol +from .rest import NotFoundError class DeviceCache: - def __init__(self, rest: BlueapiRestClient): + """Cache and lazily resolve devices from a Blueapi server.""" + + def __init__(self, rest: RestClientProtocol): self._rest = rest self._cache = { - model.name: DeviceRef(name=model.name, cache=self, model=model) + model.name: DeviceRef(cache=self, model=model) for model in rest.get_devices().devices } for name, device in self._cache.items(): @@ -16,11 +19,25 @@ def __init__(self, rest: BlueapiRestClient): setattr(self, name, device) def __getitem__(self, name: str) -> "DeviceRef": + """Get a device by name, fetching it from the server if necessary. + + Cached devices are returned directly. If the device has not yet been + cached, it is retrieved from the server and added to the cache. + + Args: + name: The fully-qualified name of the device. + + Returns: + A reference to the requested device. + + Raises: + AttributeError: If no device with the given name exists. + """ if dev := self._cache.get(name): return dev try: model = self._rest.get_device(name) - device = DeviceRef(name=name, cache=self, model=model) + device = DeviceRef(cache=self, model=model) self._cache[name] = device setattr(self, model.name, device) return device @@ -39,20 +56,24 @@ def __repr__(self) -> str: return f"DeviceCache({len(self._cache)} devices)" -class DeviceRef: - name: str +class DeviceRef(ClientObjectRef): + """Reference to a device exposed by Blueapi. + + Child devices can be accessed using attribute-style access, for example + ``devices.my_device.child``. + """ + model: DeviceModel _cache: DeviceCache - def __init__(self, name: str, cache: DeviceCache, model: DeviceModel): - self.name = name + def __init__(self, cache: DeviceCache, model: DeviceModel): self.model = model self._cache = cache def __getattr__(self, name) -> "DeviceRef": if name.startswith("_"): raise AttributeError(f"No child device named {name}") - return self._cache[f"{self.name}.{name}"] + return self._cache[f"{self.model.name}.{name}"] def __repr__(self): - return f"Device({self.name})" + return f"Device({self.model.name})" diff --git a/src/blueapi/client/plans.py b/src/blueapi/client/plan_cache.py similarity index 87% rename from src/blueapi/client/plans.py rename to src/blueapi/client/plan_cache.py index 25967257d..70d8fdd50 100644 --- a/src/blueapi/client/plans.py +++ b/src/blueapi/client/plan_cache.py @@ -27,10 +27,7 @@ def __init__(self, typ: str, message: str): class PlanCache: def __init__(self, client: ClientProtocol, plans: list[PlanModel]): - self._cache = { - model.name: Plan(name=model.name, model=model, client=client) - for model in plans - } + self._cache = {model.name: Plan(model=model, client=client) for model in plans} for name, plan in self._cache.items(): if name.startswith("_"): continue @@ -50,15 +47,14 @@ def __repr__(self) -> str: class Plan: - def __init__(self, name, model: PlanModel, client: ClientProtocol): - self.name = name + def __init__(self, model: PlanModel, client: ClientProtocol): self.model = model self._client = client self.__doc__ = model.description def __call__(self, *args, **kwargs) -> Any: req = TaskRequest( - name=self.name, + name=self.model.name, params=self._build_args(*args, **kwargs), instrument_session=self._client.instrument_session, ) @@ -89,9 +85,9 @@ def _build_args(self, *args, **kwargs): ) if len(args) > len(self.properties): - raise TypeError(f"{self.name} got too many arguments") + raise TypeError(f"{self.model.name} got too many arguments") if extra := {k for k in kwargs if k not in self.properties}: - raise TypeError(f"{self.name} got unexpected arguments: {extra}") + raise TypeError(f"{self.model.name} got unexpected arguments: {extra}") params = {} # Initially fill parameters using positional args assuming the order @@ -103,7 +99,7 @@ def _build_args(self, *args, **kwargs): for key, value in kwargs.items(): # If we've already assumed a positional arg was this value, bail out if key in params: - raise TypeError(f"{self.name} got multiple values for {key}") + raise TypeError(f"{self.model.name} got multiple values for {key}") params[key] = value if missing := {k for k in self.required if k not in params}: @@ -124,7 +120,7 @@ def _format_arg(name: str, info: dict[str, Any]) -> str: return f"{name}: {typ} | None = None" args = [_format_arg(name, info) for name, info in self.properties.items()] - single_line = f"{self.name}({', '.join(args)})" + single_line = f"{self.model.name}({', '.join(args)})" if len(single_line) <= _REPR_MAX_LENGTH and len(args) <= _REPR_MAX_ARGS_INLINE: return single_line @@ -132,7 +128,7 @@ def _format_arg(name: str, info: dict[str, Any]) -> str: indent = " " # Fall back to multiline if too many arguments or too long. multiline_args = ",\n".join(f"{indent}{arg}" for arg in args) - return f"{self.name}(\n{multiline_args}\n)" + return f"{self.model.name}(\n{multiline_args}\n)" def _pretty_type(schema: dict[str, Any]) -> str: diff --git a/src/blueapi/client/protocols.py b/src/blueapi/client/protocols.py index d89efb8cc..3d6fd3159 100644 --- a/src/blueapi/client/protocols.py +++ b/src/blueapi/client/protocols.py @@ -1,6 +1,6 @@ from typing import Protocol -from blueapi.service.model import TaskRequest +from blueapi.service.model import DeviceModel, DeviceResponse, TaskRequest from blueapi.worker.event import TaskStatus from .event_bus import OnAnyEvent @@ -16,3 +16,13 @@ def run_task( @property def instrument_session(self) -> str: ... + + +class RestClientProtocol(Protocol): + def get_devices(self) -> DeviceResponse: ... + + def get_device(self, name: str) -> DeviceModel: ... + + +class ClientObjectRef: + model: DeviceModel diff --git a/src/blueapi/client/rest.py b/src/blueapi/client/rest.py index 6e03040c4..4aa2510c1 100644 --- a/src/blueapi/client/rest.py +++ b/src/blueapi/client/rest.py @@ -16,7 +16,7 @@ from websockets.sync.client import connect from blueapi import __version__ -from blueapi.client import client +from blueapi.client.protocols import ClientObjectRef from blueapi.config import RestConfig from blueapi.core.bluesky_types import DataEvent from blueapi.service.authentication import JWTAuth, SessionManager @@ -431,6 +431,6 @@ class ServiceUnavailableError(Exception): def _task_model_fallback(obj: Any) -> Any: """Fallback method for serializing TaskRequests""" - if isinstance(obj, client.DeviceRef): - return obj.name + if isinstance(obj, ClientObjectRef): + return obj.model.name raise PydanticSerializationError(f"Object of type {type(obj)} not serializable") diff --git a/tests/unit_tests/client/conftest.py b/tests/unit_tests/client/conftest.py new file mode 100644 index 000000000..0849daff0 --- /dev/null +++ b/tests/unit_tests/client/conftest.py @@ -0,0 +1,48 @@ +from unittest.mock import Mock + +import pytest +from tests.unit_tests.client.constants import ( + ACTIVE_TASK, + DEVICES, + ENV, + ENVIRONMENT_ID, + PLANS, + TASK, + TASKS, +) + +from blueapi.client import BlueapiClient +from blueapi.client.rest import BlueapiRestClient, NotFoundError +from blueapi.service.model import EnvironmentResponse +from blueapi.worker import WorkerState + + +@pytest.fixture +def mock_rest() -> BlueapiRestClient: + mock = Mock(spec=BlueapiRestClient) + + mock.get_plans.return_value = PLANS + mock.get_plan.side_effect = lambda n: {p.name: p for p in PLANS.plans}[n] + mock.get_devices.return_value = DEVICES + device_map = {d.name: d for d in DEVICES.devices} + + def get_device(n: str): + if n not in device_map: + raise NotFoundError(404, "") + return device_map[n] + + mock.get_device.side_effect = get_device + mock.get_state.return_value = WorkerState.IDLE + mock.get_task.return_value = TASK + mock.get_all_tasks.return_value = TASKS + mock.get_active_task.return_value = ACTIVE_TASK + mock.get_environment.return_value = ENV + mock.delete_environment.return_value = EnvironmentResponse( + environment_id=ENVIRONMENT_ID, initialized=False + ) + return mock + + +@pytest.fixture +def client(mock_rest: Mock) -> BlueapiClient: + return BlueapiClient(rest=mock_rest) diff --git a/tests/unit_tests/client/constants.py b/tests/unit_tests/client/constants.py new file mode 100644 index 000000000..fad6057c6 --- /dev/null +++ b/tests/unit_tests/client/constants.py @@ -0,0 +1,66 @@ +import uuid + +from blueapi.service.model import ( + DeviceModel, + DeviceResponse, + EnvironmentResponse, + PlanModel, + PlanResponse, + TasksListResponse, + WorkerTask, +) +from blueapi.worker import Task, TrackableTask, WorkerEvent, WorkerState +from blueapi.worker.event import TaskError, TaskResult, TaskStatus + +PLANS = PlanResponse( + plans=[ + PlanModel(name="foo"), + PlanModel(name="bar"), + ] +) +PLAN = PlanModel(name="foo") +FULL_PLAN = PlanModel( + name="foobar", + description="Description of plan foobar", + schema={ + "title": "foobar", + "description": "Model description of plan foobar", + "properties": { + "one": {}, + "two": {}, + }, + "required": ["one"], + }, +) +DEVICES = DeviceResponse( + devices=[ + DeviceModel(name="foo", protocols=[]), + DeviceModel(name="bar", protocols=[]), + ] +) +DEVICE = DeviceModel(name="foo", protocols=[]) +TASK = TrackableTask(task_id="foo", task=Task(name="bar", params={})) +TASKS = TasksListResponse(tasks=[TASK]) +ACTIVE_TASK = WorkerTask(task_id="bar") +ENVIRONMENT_ID = uuid.uuid4() +NEW_ENVIRONMENT_ID = uuid.uuid4() +ENV = EnvironmentResponse(environment_id=ENVIRONMENT_ID, initialized=True) +NEW_ENV = EnvironmentResponse(environment_id=NEW_ENVIRONMENT_ID, initialized=True) +COMPLETE_EVENT = WorkerEvent( + state=WorkerState.IDLE, + task_status=TaskStatus( + task_id="foo", + task_complete=True, + task_failed=False, + result=TaskResult(type="NoneType", result=None), + ), +) +FAILED_EVENT = WorkerEvent( + state=WorkerState.IDLE, + task_status=TaskStatus( + task_id="foo", + task_complete=True, + task_failed=True, + result=TaskError(type="PlanFailure", message="The plan failed"), + ), +) diff --git a/tests/unit_tests/client/test_client.py b/tests/unit_tests/client/test_client.py index 6ce09c1e0..9e5503d14 100644 --- a/tests/unit_tests/client/test_client.py +++ b/tests/unit_tests/client/test_client.py @@ -1,26 +1,30 @@ -import uuid from collections.abc import Callable -from textwrap import dedent from unittest.mock import MagicMock, Mock, call, patch import pytest from bluesky_stomp.messaging import MessageContext -from observability_utils.tracing import ( - JsonObjectSpanExporter, - asserting_span_exporter, -) +from observability_utils.tracing import JsonObjectSpanExporter, asserting_span_exporter from pydantic import HttpUrl +from tests.unit_tests.client.constants import ( + ACTIVE_TASK, + COMPLETE_EVENT, + DEVICE, + DEVICES, + ENV, + ENVIRONMENT_ID, + FAILED_EVENT, + NEW_ENV, + PLAN, + PLANS, +) from blueapi.client import BlueapiClient -from blueapi.client.client import DeviceCache, MissingInstrumentSessionError, PlanCache -from blueapi.client.devices import DeviceRef +from blueapi.client.client import MissingInstrumentSessionError from blueapi.client.event_bus import AnyEvent, EventBusClient -from blueapi.client.plans import Plan, PlanFailedError +from blueapi.client.plan_cache import Plan, PlanFailedError from blueapi.client.rest import ( - BlueapiRestClient, BlueskyRemoteControlError, BlueskyRequestError, - NotFoundError, ServiceUnavailableError, ) from blueapi.config import MissingStompConfigurationError @@ -34,91 +38,11 @@ ProtocolInfo, TaskRequest, TaskResponse, - TasksListResponse, WorkerTask, ) -from blueapi.worker import ProgressEvent, Task, TrackableTask, WorkerEvent, WorkerState +from blueapi.worker import ProgressEvent, WorkerEvent, WorkerState from blueapi.worker.event import TaskError, TaskResult, TaskStatus -PLANS = PlanResponse( - plans=[ - PlanModel(name="foo"), - PlanModel(name="bar"), - ] -) -PLAN = PlanModel(name="foo") -FULL_PLAN = PlanModel( - name="foobar", - description="Description of plan foobar", - schema={ - "title": "foobar", - "description": "Model description of plan foobar", - "properties": { - "one": {}, - "two": {}, - }, - "required": ["one"], - }, -) -DEVICES = DeviceResponse( - devices=[ - DeviceModel(name="foo", protocols=[]), - DeviceModel(name="bar", protocols=[]), - ] -) -DEVICE = DeviceModel(name="foo", protocols=[]) -TASK = TrackableTask(task_id="foo", task=Task(name="bar", params={})) -TASKS = TasksListResponse(tasks=[TASK]) -ACTIVE_TASK = WorkerTask(task_id="bar") -ENVIRONMENT_ID = uuid.uuid4() -NEW_ENVIRONMENT_ID = uuid.uuid4() -ENV = EnvironmentResponse(environment_id=ENVIRONMENT_ID, initialized=True) -NEW_ENV = EnvironmentResponse(environment_id=NEW_ENVIRONMENT_ID, initialized=True) -COMPLETE_EVENT = WorkerEvent( - state=WorkerState.IDLE, - task_status=TaskStatus( - task_id="foo", - task_complete=True, - task_failed=False, - result=TaskResult(type="NoneType", result=None), - ), -) -FAILED_EVENT = WorkerEvent( - state=WorkerState.IDLE, - task_status=TaskStatus( - task_id="foo", - task_complete=True, - task_failed=True, - result=TaskError(type="PlanFailure", message="The plan failed"), - ), -) - - -@pytest.fixture -def mock_rest() -> BlueapiRestClient: - mock = Mock(spec=BlueapiRestClient) - - mock.get_plans.return_value = PLANS - mock.get_plan.side_effect = lambda n: {p.name: p for p in PLANS.plans}[n] - mock.get_devices.return_value = DEVICES - device_map = {d.name: d for d in DEVICES.devices} - - def get_device(n: str): - if n not in device_map: - raise NotFoundError(404, "") - return device_map[n] - - mock.get_device.side_effect = get_device - mock.get_state.return_value = WorkerState.IDLE - mock.get_task.return_value = TASK - mock.get_all_tasks.return_value = TASKS - mock.get_active_task.return_value = ACTIVE_TASK - mock.get_environment.return_value = ENV - mock.delete_environment.return_value = EnvironmentResponse( - environment_id=ENVIRONMENT_ID, initialized=False - ) - return mock - @pytest.fixture def mock_events() -> EventBusClient: @@ -129,11 +53,6 @@ def mock_events() -> EventBusClient: return mock_events -@pytest.fixture -def client(mock_rest: Mock) -> BlueapiClient: - return BlueapiClient(rest=mock_rest) - - @pytest.fixture def client_with_events(mock_rest: Mock, mock_events: MagicMock): return BlueapiClient(rest=mock_rest, events=mock_events) @@ -198,9 +117,9 @@ def test_get_child_device(mock_rest: Mock, client: BlueapiClient): else None ) foo = client.devices.foo - assert foo.name == "foo" + assert foo.model.name == "foo" x = client.devices.foo.x - assert x.name == "foo.x" + assert x.model.name == "foo.x" def test_state_property(client: BlueapiClient): @@ -574,7 +493,6 @@ def test_scripting_interface_returns_result(): result=TaskResult(result=42, type="int"), ) demo_plan = Plan( - "demo", client=client, model=PlanModel(name="demo", description="Demo plan", schema={}), ) @@ -590,7 +508,6 @@ def test_scripting_interface_raises_exceptions(): result=TaskError(type="ValueError", message="Plan failed"), ) demo_plan = Plan( - "demo", client=client, model=PlanModel(name="demo", description="Demo plan", schema={}), ) @@ -733,219 +650,6 @@ def test_fluent_instrument_session_setter(client): assert client.instrument_session == "cm12345-3" -def test_plan_cache_ignores_underscores(client): - cache = PlanCache(client, [PlanModel(name="_ignored"), PlanModel(name="used")]) - with pytest.raises(AttributeError, match="_ignored"): - _ = cache._ignored - - -def test_plan_cache_repr(client): - assert repr(client.plans) == "PlanCache(2 plans)" - - -def test_device_cache_ignores_underscores(): - rest = Mock() - rest.get_devices.return_value = DeviceResponse( - devices=[ - DeviceModel(name="_ignored", protocols=[]), - ] - ) - cache = DeviceCache(rest) - with pytest.raises(AttributeError, match="_ignored"): - _ = cache._ignored - - rest.get_devices.reset_mock() - with pytest.raises(AttributeError, match="_anything"): - _ = cache._anything - rest.get_device.assert_not_called() - - -def test_devices_are_cached(mock_rest): - cache = DeviceCache(mock_rest) - _ = cache.foo - mock_rest.get_device.assert_not_called() - _ = cache["foo"] - mock_rest.get_device.assert_not_called() - - -def test_device_cache_repr(client): - assert repr(client.devices) == "DeviceCache(2 devices)" - - -def test_device_repr(): - cache = Mock() - model = Mock() - dev = DeviceRef(name="foo", cache=cache, model=model) - assert repr(dev) == "Device(foo)" - - -def test_device_ignores_underscores(): - cache = MagicMock() - model = Mock() - dev = DeviceRef(name="foo", cache=cache, model=model) - with pytest.raises(AttributeError, match="_underscore"): - _ = dev._underscore - cache.__getitem__.assert_not_called() - - -def test_plan_help_text(client): - plan = Plan("foo", PlanModel(name="foo", description="help for foo"), client) - assert plan.help_text == "help for foo" - - -def test_plan_fallback_help_text(client): - plan = Plan( - "foo", - PlanModel( - name="foo", - schema={"properties": {"one": {}, "two": {}}, "required": ["one"]}, - ), - client, - ) - assert plan.help_text == "Plan foo(one: Any, two: Any | None = None)" - - -def test_plan_multi_parameter_fallback_help_text(client): - plan = Plan( - "foo", - PlanModel( - name="foo", - schema={ - "properties": { - "one": {}, - "two": { - "anyOf": [{"items": {}, "type": "array"}, {"type": "boolean"}], - }, - "three": {"default": 3}, - "four": {"default": None}, - }, - "required": ["one", "two"], - }, - ), - client, - ) - assert plan.help_text == dedent("""\ - Plan foo( - one: Any, - two: list[Any] | bool, - three: Any = 3, - four: Any | None = None - )""") - - -def test_plan_help_text_with_ref(client): - schema = { - "$defs": { - "Spec": { - "properties": { - "foo": {"type": "integer"}, - "bar": {"$ref": "#/$defs/InnerSpec"}, - }, - "required": ["foo", "bar"], - }, - "InnerSpec": { - "properties": { - "x": {"type": "number"}, - "y": {"default": 10, "type": "number"}, - }, - "required": ["x"], - }, - }, - "properties": { - "spec": {"$ref": "#/$defs/Spec"}, - "meta": {"type": "string", "default": "abc"}, - }, - "required": ["spec"], - } - - plan = Plan( - "ref_plan", - PlanModel(name="ref_plan", schema=schema), - client, - ) - - expected = "Plan ref_plan(spec: Spec, meta: str = 'abc')" - - assert plan.help_text == expected - - -def test_plan_properties(client): - plan = Plan( - "foo", - PlanModel( - name="foo", - schema={"properties": {"one": {}, "two": {}}, "required": ["one"]}, - ), - client, - ) - assert plan.properties == {"one": {}, "two": {}} - assert plan.required == ["one"] - - -def test_plan_empty_fallback_help_text(client): - plan = Plan( - "foo", PlanModel(name="foo", schema={"properties": {}, "required": []}), client - ) - assert plan.help_text == "Plan foo()" - - -p = pytest.param - - -@pytest.mark.parametrize( - "args,kwargs,params", - [ - p((1,), {}, {"one": 1}, id="required_as_positional"), - p((), {"one": 7}, {"one": 7}, id="required_as_keyword"), - p((1,), {"two": 23}, {"one": 1, "two": 23}, id="all_as_mixed_args_kwargs"), - p((1, 2), {}, {"one": 1, "two": 2}, id="all_as_positional"), - p((), {"one": 21, "two": 42}, {"one": 21, "two": 42}, id="all_as_keyword"), - ], -) -def test_plan_param_mapping(args, kwargs, params): - client = Mock() - client.instrument_session = "cm12345-1" - plan = Plan( - FULL_PLAN.name, - FULL_PLAN, - client, - ) - - plan(*args, **kwargs) - client.run_task.assert_called_once_with( - TaskRequest(name="foobar", instrument_session="cm12345-1", params=params) - ) - - -@pytest.mark.parametrize( - "args,kwargs,msg", - [ - p((), {}, r"Missing argument\(s\) for \{'one'\}", id="missing_required"), - p((1,), {"one": 7}, "multiple values for one", id="duplicate_required"), - p((1, 2), {"two": 23}, "multiple values for two", id="duplicate_optional"), - p((1, 2, 3), {}, "too many arguments", id="too_many_args"), - p( - (), - {"unknown_key": 42}, - r"got unexpected arguments: \{'unknown_key'\}", - id="unknown_arg", - ), - ], -) -def test_plan_invalid_param_mapping(args, kwargs, msg): - client = Mock() - client.instrument_session = "cm12345-1" - plan = Plan( - FULL_PLAN.name, - FULL_PLAN, - client, - ) - - with pytest.raises(TypeError, match=msg): - plan(*args, **kwargs) - client.run_task.assert_not_called() - - def test_adding_removing_callback(client): def callback(*a, **kw): pass diff --git a/tests/unit_tests/client/test_device_cache.py b/tests/unit_tests/client/test_device_cache.py new file mode 100644 index 000000000..cb1f75273 --- /dev/null +++ b/tests/unit_tests/client/test_device_cache.py @@ -0,0 +1,53 @@ +from unittest.mock import MagicMock, Mock + +import pytest + +from blueapi.client.device_cache import DeviceCache, DeviceRef +from blueapi.service.model import DeviceModel, DeviceResponse + + +def test_device_cache_ignores_underscores(): + rest = Mock() + rest.get_devices.return_value = DeviceResponse( + devices=[ + DeviceModel(name="_ignored", protocols=[]), + ] + ) + cache = DeviceCache(rest) + with pytest.raises(AttributeError, match="_ignored"): + _ = cache._ignored + + rest.get_devices.reset_mock() + with pytest.raises(AttributeError, match="_anything"): + _ = cache._anything + rest.get_device.assert_not_called() + + +def test_devices_are_cached(mock_rest): + cache = DeviceCache(mock_rest) + _ = cache.foo + mock_rest.get_device.assert_not_called() + _ = cache["foo"] + mock_rest.get_device.assert_not_called() + + +def test_device_cache_repr(client): + assert repr(client.devices) == "DeviceCache(2 devices)" + + +def test_device_repr(): + cache = Mock() + model = Mock() + model.name = "foo" + dev = DeviceRef(cache=cache, model=model) + assert repr(dev) == "Device(foo)" + + +def test_device_ignores_underscores(): + cache = MagicMock() + model = Mock() + model.name = "foo" + dev = DeviceRef(cache=cache, model=model) + with pytest.raises(AttributeError, match="_underscore"): + _ = dev._underscore + cache.__getitem__.assert_not_called() diff --git a/tests/unit_tests/client/test_plan_cache.py b/tests/unit_tests/client/test_plan_cache.py new file mode 100644 index 000000000..4ee5105ab --- /dev/null +++ b/tests/unit_tests/client/test_plan_cache.py @@ -0,0 +1,174 @@ +from textwrap import dedent +from unittest.mock import Mock + +import pytest + +from blueapi.client.client import PlanCache +from blueapi.client.plan_cache import Plan +from blueapi.service.model import PlanModel, TaskRequest + +FULL_PLAN = PlanModel( + name="foobar", + description="Description of plan foobar", + schema={ + "title": "foobar", + "description": "Model description of plan foobar", + "properties": { + "one": {}, + "two": {}, + }, + "required": ["one"], + }, +) + + +def test_plan_cache_ignores_underscores(client): + cache = PlanCache(client, [PlanModel(name="_ignored"), PlanModel(name="used")]) + with pytest.raises(AttributeError, match="_ignored"): + _ = cache._ignored + + +def test_plan_cache_repr(client): + assert repr(client.plans) == "PlanCache(2 plans)" + + +def test_plan_help_text(client): + plan = Plan(PlanModel(name="foo", description="help for foo"), client) + assert plan.help_text == "help for foo" + + +def test_plan_fallback_help_text(client): + plan = Plan( + PlanModel( + name="foo", + schema={"properties": {"one": {}, "two": {}}, "required": ["one"]}, + ), + client, + ) + assert plan.help_text == "Plan foo(one: Any, two: Any | None = None)" + + +def test_plan_multi_parameter_fallback_help_text(client): + plan = Plan( + PlanModel( + name="foo", + schema={ + "properties": { + "one": {}, + "two": { + "anyOf": [{"items": {}, "type": "array"}, {"type": "boolean"}], + }, + "three": {"default": 3}, + "four": {"default": None}, + }, + "required": ["one", "two"], + }, + ), + client, + ) + assert plan.help_text == dedent("""\ + Plan foo( + one: Any, + two: list[Any] | bool, + three: Any = 3, + four: Any | None = None + )""") + + +def test_plan_help_text_with_ref(client): + schema = { + "$defs": { + "Spec": { + "properties": { + "foo": {"type": "integer"}, + "bar": {"$ref": "#/$defs/InnerSpec"}, + }, + "required": ["foo", "bar"], + }, + "InnerSpec": { + "properties": { + "x": {"type": "number"}, + "y": {"default": 10, "type": "number"}, + }, + "required": ["x"], + }, + }, + "properties": { + "spec": {"$ref": "#/$defs/Spec"}, + "meta": {"type": "string", "default": "abc"}, + }, + "required": ["spec"], + } + + plan = Plan(PlanModel(name="ref_plan", schema=schema), client) + expected = "Plan ref_plan(spec: Spec, meta: str = 'abc')" + + assert plan.help_text == expected + + +def test_plan_properties(client): + plan = Plan( + PlanModel( + name="foo", + schema={"properties": {"one": {}, "two": {}}, "required": ["one"]}, + ), + client, + ) + assert plan.properties == {"one": {}, "two": {}} + assert plan.required == ["one"] + + +def test_plan_empty_fallback_help_text(client): + plan = Plan( + PlanModel(name="foo", schema={"properties": {}, "required": []}), client + ) + assert plan.help_text == "Plan foo()" + + +p = pytest.param + + +@pytest.mark.parametrize( + "args,kwargs,params", + [ + p((1,), {}, {"one": 1}, id="required_as_positional"), + p((), {"one": 7}, {"one": 7}, id="required_as_keyword"), + p((1,), {"two": 23}, {"one": 1, "two": 23}, id="all_as_mixed_args_kwargs"), + p((1, 2), {}, {"one": 1, "two": 2}, id="all_as_positional"), + p((), {"one": 21, "two": 42}, {"one": 21, "two": 42}, id="all_as_keyword"), + ], +) +def test_plan_param_mapping(args, kwargs, params): + client = Mock() + client.instrument_session = "cm12345-1" + plan = Plan(FULL_PLAN, client) + + plan(*args, **kwargs) + client.run_task.assert_called_once_with( + TaskRequest(name="foobar", instrument_session="cm12345-1", params=params) + ) + + +@pytest.mark.parametrize( + "args,kwargs,msg", + [ + p((), {}, r"Missing argument\(s\) for \{'one'\}", id="missing_required"), + p((1,), {"one": 7}, "multiple values for one", id="duplicate_required"), + p((1, 2), {"two": 23}, "multiple values for two", id="duplicate_optional"), + p((1, 2, 3), {}, "too many arguments", id="too_many_args"), + p( + (), + {"unknown_key": 42}, + r"got unexpected arguments: \{'unknown_key'\}", + id="unknown_arg", + ), + ], +) +def test_plan_invalid_param_mapping(args, kwargs, msg): + client = Mock() + client.instrument_session = "cm12345-1" + plan = Plan(FULL_PLAN, client) + + with pytest.raises(TypeError, match=msg): + plan(*args, **kwargs) + client.run_task.assert_not_called() diff --git a/tests/unit_tests/client/test_rest.py b/tests/unit_tests/client/test_rest.py index 6ecfbaa76..a558e518f 100644 --- a/tests/unit_tests/client/test_rest.py +++ b/tests/unit_tests/client/test_rest.py @@ -13,7 +13,7 @@ from websockets import Headers, InvalidStatus, Response from blueapi import __version__ -from blueapi.client.client import DeviceRef +from blueapi.client.device_cache import DeviceRef from blueapi.client.rest import ( USER_AGENT, BlueapiRestClient, @@ -91,10 +91,12 @@ def test_rest_error_code( def test_create_task_serialization(): rest = Mock(spec=BlueapiRestClient) + model = Mock() + model.name = "foo" request = TaskRequest( name="demo", instrument_session="cm12345-1", - params={"devices": [DeviceRef(name="foo", cache=Mock(), model=Mock())]}, + params={"devices": [DeviceRef(cache=Mock(), model=model)]}, ) BlueapiRestClient.create_task(rest, request) From 7659a6b0fd33f04551d4dffd075c8e1d75f0b44e Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Fri, 11 Sep 2026 09:52:58 +0000 Subject: [PATCH 3/4] Add doc strings to plan --- src/blueapi/client/plan_cache.py | 42 +++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/blueapi/client/plan_cache.py b/src/blueapi/client/plan_cache.py index 70d8fdd50..127c71cbd 100644 --- a/src/blueapi/client/plan_cache.py +++ b/src/blueapi/client/plan_cache.py @@ -26,6 +26,8 @@ def __init__(self, typ: str, message: str): class PlanCache: + """Collection of plans that can be accessed by name or attribute.""" + def __init__(self, client: ClientProtocol, plans: list[PlanModel]): self._cache = {model.name: Plan(model=model, client=client) for model in plans} for name, plan in self._cache.items(): @@ -47,12 +49,27 @@ def __repr__(self) -> str: class Plan: + """Callable client-side reference to a registered BlueAPI plan.""" + def __init__(self, model: PlanModel, client: ClientProtocol): self.model = model self._client = client self.__doc__ = model.description def __call__(self, *args, **kwargs) -> Any: + """Execute the plan with the supplied arguments. + + Positional arguments are mapped to parameters in the order defined by + the plan's parameter schema. Keyword arguments are passed by name. + + Returns: + The result returned by the plan. + + Raises: + PlanFailedError: If plan execution fails on the server. + TypeError: If the supplied arguments do not match the plan + parameter schema. + """ req = TaskRequest( name=self.model.name, params=self._build_args(*args, **kwargs), @@ -76,7 +93,18 @@ def properties(self) -> dict[str, Any]: def required(self) -> list[str]: return self.model.parameter_schema.get("required", []) - def _build_args(self, *args, **kwargs): + def _build_args(self, *args, **kwargs) -> dict[str, Any]: + """Build a parameter mapping from positional and keyword arguments. + + Positional arguments are assigned to parameters according to their + order in the plan's parameter schema. Keyword arguments are then + added by name. + + Raises: + TypeError: If too many positional arguments, unexpected keyword + arguments, duplicate arguments, or required arguments are + supplied incorrectly. + """ log.info( "Building args for %s, using %s and %s", "[" + ",".join(self.properties) + "]", @@ -107,6 +135,12 @@ def _build_args(self, *args, **kwargs): return params def __repr__(self) -> str: + """Return a signature-like representation of the plan. + + The representation includes parameter types and defaults derived from + the plan's JSON schema. Short signatures are rendered on one line; + longer signatures are formatted across multiple lines. + """ required = set(self.required) def _format_arg(name: str, info: dict[str, Any]) -> str: @@ -132,6 +166,12 @@ def _format_arg(name: str, info: dict[str, Any]) -> str: def _pretty_type(schema: dict[str, Any]) -> str: + """Convert a JSON schema type definition into a readable Python type. + + Handles references, arrays, unions, and primitive JSON schema types. + Unknown or unsupported schemas fall back to ``Any`` where no useful type + information is available. + """ if "$ref" in schema: return schema["$ref"].split("/")[-1] From 9f607d1f2a22936c054bd05c16bc6a85e76a55d9 Mon Sep 17 00:00:00 2001 From: Oli Wenman Date: Fri, 11 Sep 2026 10:01:00 +0000 Subject: [PATCH 4/4] Add missing name typing --- src/blueapi/client/device_cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blueapi/client/device_cache.py b/src/blueapi/client/device_cache.py index ffdf6da71..8c6841315 100644 --- a/src/blueapi/client/device_cache.py +++ b/src/blueapi/client/device_cache.py @@ -70,7 +70,7 @@ def __init__(self, cache: DeviceCache, model: DeviceModel): self.model = model self._cache = cache - def __getattr__(self, name) -> "DeviceRef": + def __getattr__(self, name: str) -> "DeviceRef": if name.startswith("_"): raise AttributeError(f"No child device named {name}") return self._cache[f"{self.model.name}.{name}"]