From cb7cb68ed60fba89411a6d0a60ea6de7b767a6af Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Thu, 15 Jan 2026 10:34:37 +0000 Subject: [PATCH 01/10] Split caches and refs into their own module Mainly to make supporting type stubs easier --- src/blueapi/client/cache.py | 150 +++++++++++++++++++++++++++++++++ src/blueapi/client/client.py | 158 +++-------------------------------- 2 files changed, 161 insertions(+), 147 deletions(-) create mode 100644 src/blueapi/client/cache.py diff --git a/src/blueapi/client/cache.py b/src/blueapi/client/cache.py new file mode 100644 index 000000000..4dd0a886c --- /dev/null +++ b/src/blueapi/client/cache.py @@ -0,0 +1,150 @@ +import logging +from collections.abc import Callable +from itertools import chain +from typing import Any + +from blueapi.client.rest import BlueapiRestClient +from blueapi.service.model import DeviceModel, PlanModel +from blueapi.worker.event import WorkerEvent + +log = logging.getLogger(__name__) + +PlanRunner = Callable[[str, dict[str, Any]], WorkerEvent] + + +class PlanCache: + def __init__(self, runner: PlanRunner, plans: list[PlanModel]): + self._cache = { + model.name: Plan(name=model.name, model=model, runner=runner) + 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, runner: PlanRunner): + self.name = name + self.model = model + self._runner = runner + self.__doc__ = model.description + + def __call__(self, *args, **kwargs): + self._runner(self.name, self._build_args(*args, **kwargs)) + + @property + def help_text(self) -> str: + return self.model.description or f"Plan {self!r}" + + @property + def properties(self) -> set[str]: + return self.model.parameter_schema.get("properties", {}).keys() + + @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): + opts = [p for p in self.properties if p not in self.required] + params = ", ".join(chain(self.required, (f"{opt}=None" for opt in opts))) + return f"{self.name}({params})" + + +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 KeyError: + pass + raise AttributeError(f"No device named '{name}' available") + + 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(str): + model: DeviceModel + _cache: DeviceCache + + def __new__(cls, name: str, cache: DeviceCache, model: DeviceModel): + instance = super().__new__(cls, name) + instance.model = model + instance._cache = cache + return instance + + def __getattr__(self, name) -> "DeviceRef": + if name.startswith("_"): + raise AttributeError(f"No child device named {name}") + return self._cache[f"{self}.{name}"] + + def __repr__(self): + return f"Device({self})" diff --git a/src/blueapi/client/client.py b/src/blueapi/client/client.py index e6f1e83e3..cdcd5bc67 100644 --- a/src/blueapi/client/client.py +++ b/src/blueapi/client/client.py @@ -4,9 +4,8 @@ from collections.abc import Iterable from concurrent.futures import Future from functools import cached_property -from itertools import chain from pathlib import Path -from typing import Self +from typing import Any, Self from bluesky_stomp.messaging import MessageContext, StompClient from bluesky_stomp.models import Broker @@ -23,10 +22,8 @@ from blueapi.core.bluesky_types import DataEvent from blueapi.service.authentication import SessionManager from blueapi.service.model import ( - DeviceModel, EnvironmentResponse, OIDCConfig, - PlanModel, PythonEnvironmentResponse, SourceInfo, TaskRequest, @@ -36,6 +33,7 @@ from blueapi.worker import WorkerEvent, WorkerState from blueapi.worker.event import ProgressEvent, TaskStatus +from .cache import DeviceCache, PlanCache from .event_bus import AnyEvent, BlueskyStreamingError, EventBusClient, OnAnyEvent from .rest import BlueapiRestClient, BlueskyRemoteControlError @@ -49,149 +47,6 @@ 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 KeyError: - pass - raise AttributeError(f"No device named '{name}' available") - - 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(str): - model: DeviceModel - _cache: DeviceCache - - def __new__(cls, name: str, cache: DeviceCache, model: DeviceModel): - instance = super().__new__(cls, name) - instance.model = model - instance._cache = cache - return instance - - def __getattr__(self, name) -> "DeviceRef": - if name.startswith("_"): - raise AttributeError(f"No child device named {name}") - return self._cache[f"{self}.{name}"] - - def __repr__(self): - return f"Device({self})" - - -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): - req = TaskRequest( - name=self.name, - params=self._build_args(*args, **kwargs), - instrument_session=self._client.instrument_session, - ) - self._client.run_task(req) - - @property - def help_text(self) -> str: - return self.model.description or f"Plan {self!r}" - - @property - def properties(self) -> set[str]: - return self.model.parameter_schema.get("properties", {}).keys() - - @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): - opts = [p for p in self.properties if p not in self.required] - params = ", ".join(chain(self.required, (f"{opt}=None" for opt in opts))) - return f"{self.name}({params})" - - class BlueapiClient: """Unified client for controlling blueapi""" @@ -333,6 +188,15 @@ def active_task(self) -> WorkerTask: return self._rest.get_active_task() + @start_as_current_span(TRACER, "name", "params") + def run_plan(self, name: str, params: dict[str, Any]) -> WorkerEvent: + req = TaskRequest( + name=name, + params=params, + instrument_session=self.instrument_session, + ) + return self.run_task(req) + @start_as_current_span(TRACER, "task", "timeout") def run_task( self, From 2cbe590b9c798408244c11fefab623e58001d9b2 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Thu, 15 Jan 2026 18:53:42 +0000 Subject: [PATCH 02/10] Add client cache stub generation When editing scripts that make use of the dynamic plan/device access in the new client, there is no type checking available by default (as the available plans are unknown. This adds a subcommand to generate a type-stubs package for the currently running server to help write scripts. blueapi generate-stubs /tmp/blueapi-stubs pip install --editable /tmp/blueapi-stubs type checkers and LSPs will then be able to check scripts for correct plan names/arguments/etc as well as providing completions while editing. --- src/blueapi/cli/cli.py | 13 ++++ src/blueapi/cli/stubgen.py | 73 +++++++++++++++++++ src/blueapi/client/cache.py | 4 +- .../stubs/templates/cache_template.pyi | 57 +++++++++++++++ 4 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 src/blueapi/cli/stubgen.py create mode 100644 src/blueapi/stubs/templates/cache_template.pyi diff --git a/src/blueapi/cli/cli.py b/src/blueapi/cli/cli.py index 2ae736cff..6134c6917 100644 --- a/src/blueapi/cli/cli.py +++ b/src/blueapi/cli/cli.py @@ -152,6 +152,19 @@ def start_application(obj: dict): start(config) +@main.command() +@click.pass_obj +@click.argument("target", type=click.Path(file_okay=False)) +def generate_stubs(obj: dict, target: Path): + click.echo(f"Writing stubs to {target}") + + config: ApplicationConfig = obj["config"] + bc = BlueapiClient.from_config(config) + from . import stubgen + + stubgen.generate_stubs(Path(target), list(bc.plans), list(bc.devices)) + + @main.group() @click.option( "-o", diff --git a/src/blueapi/cli/stubgen.py b/src/blueapi/cli/stubgen.py new file mode 100644 index 000000000..fe721e4e0 --- /dev/null +++ b/src/blueapi/cli/stubgen.py @@ -0,0 +1,73 @@ +import logging +from dataclasses import dataclass +from pathlib import Path +from textwrap import dedent +from typing import Self + +from jinja2 import Environment, PackageLoader + +from blueapi.client.cache import DeviceRef, Plan + +log = logging.getLogger(__name__) + + +@dataclass +class ArgSpec: + name: str + type: str + optional: bool + + +@dataclass +class PlanSpec: + name: str + docs: str + args: list[ArgSpec] + + @classmethod + def from_plan(cls, plan: Plan) -> Self: + req = set(plan.required) + args = [ArgSpec(arg, "Any", arg not in req) for arg in plan.properties] + return cls(plan.name, plan.help_text, args) + + +def generate_stubs(target: Path, plans: list[Plan], devices: list[DeviceRef]): + log.info("Generating stubs for %d plans and %d devices", len(plans), len(devices)) + target.mkdir(parents=True, exist_ok=True) + client_dir = target / "src" / "blueapi-stubs" / "client" + client_dir.mkdir(parents=True, exist_ok=True) + stub_file = client_dir / "cache.pyi" + project_file = target / "pyproject.toml" + py_typed = target / "src" / "blueapi-stubs" / "py.typed" + + with open(project_file, "w") as out: + out.write( + dedent(""" + [project] + name = "blueapi-stubs" + version = "0.1.0" + description = "Generated client stubs for a running server" + readme = "README.md" + requires-python = ">=3.11" + + dependencies = [ + "blueapi" + ] + """) + ) + + with open(py_typed, "w") as out: + out.write("partial\n") + + render_stub_file(stub_file, plans, devices) + + +def render_stub_file( + stub_file: Path, plan_models: list[Plan], devices: list[DeviceRef] +): + plans = [PlanSpec.from_plan(p) for p in plan_models] + + env = Environment(loader=PackageLoader("blueapi", package_path="stubs/templates")) + tmpl = env.get_template("cache_template.pyi") + with open(stub_file, "w") as out: + out.write(tmpl.render(plans=plans, devices=devices)) diff --git a/src/blueapi/client/cache.py b/src/blueapi/client/cache.py index 4dd0a886c..1e29b6b36 100644 --- a/src/blueapi/client/cache.py +++ b/src/blueapi/client/cache.py @@ -43,8 +43,8 @@ def __init__(self, name, model: PlanModel, runner: PlanRunner): self._runner = runner self.__doc__ = model.description - def __call__(self, *args, **kwargs): - self._runner(self.name, self._build_args(*args, **kwargs)) + def __call__(self, *args, **kwargs) -> WorkerEvent: + return self._runner(self.name, self._build_args(*args, **kwargs)) @property def help_text(self) -> str: diff --git a/src/blueapi/stubs/templates/cache_template.pyi b/src/blueapi/stubs/templates/cache_template.pyi new file mode 100644 index 000000000..f85e661af --- /dev/null +++ b/src/blueapi/stubs/templates/cache_template.pyi @@ -0,0 +1,57 @@ +from collections.abc import Callable +from typing import Any +from blueapi.client.rest import BlueapiRestClient +from blueapi.service.model import DeviceModel, PlanModel +from blueapi.worker.event import WorkerEvent + +PlanRunner = Callable[[str, dict[str, Any]], WorkerEvent] + +class PlanCache: + def __init__(self, runner: PlanRunner, plans: list[PlanModel]) -> None: ... + def __getitem__(self, name: str) -> Plan: ... + def __iter__(self): # -> Iterator[Plan]: + ... + def __repr__(self) -> str: ... + + {% for item in plans -%} + def {{ item.name }}(self,{% for arg in item.args %} + {{ arg.name }}: {{ arg.type }}{% if arg.optional %} | None = None{% endif %}, + {%- endfor %} + ) -> WorkerEvent: + """{{ item.docs }}""" + ... + {% endfor %} + + +class Plan: + name: str + def __init__(self, name, model: PlanModel, runner: PlanRunner) -> None: ... + def __call__(self, *args, **kwargs): # -> None: + ... + @property + def help_text(self) -> str: ... + @property + def properties(self) -> set[str]: ... + @property + def required(self) -> list[str]: ... + def __repr__(self) -> str: ... + + +class DeviceRef(str): + model: DeviceModel + _cache: DeviceCache + def __new__(cls, name: str, cache: DeviceCache, model: DeviceModel): ... + def __getattr__(self, name) -> DeviceRef: ... + def __repr__(self) -> str: ... + +class DeviceCache: + def __init__(self, rest: BlueapiRestClient) -> None: ... + def __getitem__(self, name: str) -> DeviceRef: ... + def __getattr__(self, name: str) -> DeviceRef: ... + def __iter__(self): # -> Iterator[DeviceRef]: + ... + def __repr__(self) -> str: ... + + {% for item in devices -%} + {{ item }}: DeviceRef + {% endfor %} From 0bd705ed4b3fe23657e02ecbcc0fa5cd0c83def7 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Fri, 16 Jan 2026 12:37:19 +0000 Subject: [PATCH 03/10] Improve cache and stubgen docs --- src/blueapi/cli/stubgen.py | 33 ++++++++++++++- src/blueapi/client/cache.py | 26 ++++++++++++ src/blueapi/client/client.py | 2 +- .../stubs/templates/cache_template.pyi | 9 +++- tests/unit_tests/client/test_client.py | 41 +++++++------------ 5 files changed, 82 insertions(+), 29 deletions(-) diff --git a/src/blueapi/cli/stubgen.py b/src/blueapi/cli/stubgen.py index fe721e4e0..67ef67e0a 100644 --- a/src/blueapi/cli/stubgen.py +++ b/src/blueapi/cli/stubgen.py @@ -7,6 +7,8 @@ from jinja2 import Environment, PackageLoader from blueapi.client.cache import DeviceRef, Plan +from blueapi.core import context +from blueapi.core.bluesky_types import BLUESKY_PROTOCOLS log = logging.getLogger(__name__) @@ -27,10 +29,39 @@ class PlanSpec: @classmethod def from_plan(cls, plan: Plan) -> Self: req = set(plan.required) - args = [ArgSpec(arg, "Any", arg not in req) for arg in plan.properties] + args = [ + ArgSpec(arg, _map_type(spec), arg not in req) + for arg, spec in plan.model.parameter_schema.get("properties", {}).items() + ] return cls(plan.name, plan.help_text, args) +BLUESKY_PROTOCOL_NAMES = {context.qualified_name(proto) for proto in BLUESKY_PROTOCOLS} + + +def _map_type(spec) -> str: + """Best effort attempt at making useful type hints for plans""" + match spec.get("type"): + case "array": + return f"list[{_map_type(spec.get('items'))}]" + case "integer": + return "int" + case "number": + return "float" + case proto if proto in BLUESKY_PROTOCOL_NAMES: + return "DeviceRef" + case "object": + return "dict[str, Any]" + case "string": + return "str" + case "boolean": + return "bool" + case None if opts := spec.get("anyOf"): + return "|".join(_map_type(opt) for opt in opts) + case _: + return "Any" + + def generate_stubs(target: Path, plans: list[Plan], devices: list[DeviceRef]): log.info("Generating stubs for %d plans and %d devices", len(plans), len(devices)) target.mkdir(parents=True, exist_ok=True) diff --git a/src/blueapi/client/cache.py b/src/blueapi/client/cache.py index 1e29b6b36..adcab7182 100644 --- a/src/blueapi/client/cache.py +++ b/src/blueapi/client/cache.py @@ -9,10 +9,18 @@ log = logging.getLogger(__name__) + +# This file should be kept in sync with the type stub template in stubs/templates + + PlanRunner = Callable[[str, dict[str, Any]], WorkerEvent] class PlanCache: + """ + Cache of plans available on the server + """ + def __init__(self, runner: PlanRunner, plans: list[PlanModel]): self._cache = { model.name: Plan(name=model.name, model=model, runner=runner) @@ -37,6 +45,21 @@ def __repr__(self) -> str: class Plan: + """ + An interface to a plan on the blueapi server + + This allows remote plans to be called (mostly) as if they were local + methods when writing user scripts. + + If you are seeing this help while using blueapi as a library, generating + type stubs may be helpful for type checking and plan discovery, eg + + blueapi generate-stubs /tmp/blueapi-stubs + uv add --editable /tmp/blueapi-stubs + """ + + model: PlanModel + def __init__(self, name, model: PlanModel, runner: PlanRunner): self.name = name self.model = model @@ -44,6 +67,9 @@ def __init__(self, name, model: PlanModel, runner: PlanRunner): self.__doc__ = model.description def __call__(self, *args, **kwargs) -> WorkerEvent: + """ + Run the plan on the server mapping the given args into the required parameters + """ return self._runner(self.name, self._build_args(*args, **kwargs)) @property diff --git a/src/blueapi/client/client.py b/src/blueapi/client/client.py index cdcd5bc67..39fbf5054 100644 --- a/src/blueapi/client/client.py +++ b/src/blueapi/client/client.py @@ -69,7 +69,7 @@ def __init__( @cached_property @start_as_current_span(TRACER) def plans(self) -> PlanCache: - return PlanCache(self, self._rest.get_plans().plans) + return PlanCache(self.run_plan, self._rest.get_plans().plans) @cached_property @start_as_current_span(TRACER) diff --git a/src/blueapi/stubs/templates/cache_template.pyi b/src/blueapi/stubs/templates/cache_template.pyi index f85e661af..636243ce9 100644 --- a/src/blueapi/stubs/templates/cache_template.pyi +++ b/src/blueapi/stubs/templates/cache_template.pyi @@ -4,6 +4,13 @@ from blueapi.client.rest import BlueapiRestClient from blueapi.service.model import DeviceModel, PlanModel from blueapi.worker.event import WorkerEvent +{#- + This file is based on the cache.py file in blueapi/client/cache.py and should + be kept in sync with changes there. +#} + +# This file is auto-generated for a live server and should not be modified directly + PlanRunner = Callable[[str, dict[str, Any]], WorkerEvent] class PlanCache: @@ -24,6 +31,7 @@ class PlanCache: class Plan: + model: PlanModel name: str def __init__(self, name, model: PlanModel, runner: PlanRunner) -> None: ... def __call__(self, *args, **kwargs): # -> None: @@ -47,7 +55,6 @@ class DeviceRef(str): class DeviceCache: def __init__(self, rest: BlueapiRestClient) -> None: ... def __getitem__(self, name: str) -> DeviceRef: ... - def __getattr__(self, name: str) -> DeviceRef: ... def __iter__(self): # -> Iterator[DeviceRef]: ... def __repr__(self) -> str: ... diff --git a/tests/unit_tests/client/test_client.py b/tests/unit_tests/client/test_client.py index 98aad7871..4f6d85f61 100644 --- a/tests/unit_tests/client/test_client.py +++ b/tests/unit_tests/client/test_client.py @@ -10,13 +10,10 @@ ) from pydantic import HttpUrl +from blueapi.client.cache import DeviceCache, DeviceRef, Plan, PlanCache from blueapi.client.client import ( BlueapiClient, - DeviceCache, - DeviceRef, MissingInstrumentSessionError, - Plan, - PlanCache, ) from blueapi.client.event_bus import AnyEvent, BlueskyStreamingError, EventBusClient from blueapi.client.rest import BlueapiRestClient, BlueskyRemoteControlError @@ -677,40 +674,40 @@ def test_device_ignores_underscores(): cache.__getitem__.assert_not_called() -def test_plan_help_text(client): - plan = Plan("foo", PlanModel(name="foo", description="help for foo"), client) +def test_plan_help_text(): + plan = Plan("foo", PlanModel(name="foo", description="help for foo"), Mock()) assert plan.help_text == "help for foo" -def test_plan_fallback_help_text(client): +def test_plan_fallback_help_text(): plan = Plan( "foo", PlanModel( name="foo", schema={"properties": {"one": {}, "two": {}}, "required": ["one"]}, ), - client, + Mock(), ) assert plan.help_text == "Plan foo(one, two=None)" -def test_plan_properties(client): +def test_plan_properties(): plan = Plan( "foo", PlanModel( name="foo", schema={"properties": {"one": {}, "two": {}}, "required": ["one"]}, ), - client, + Mock(), ) assert plan.properties == {"one", "two"} assert plan.required == ["one"] -def test_plan_empty_fallback_help_text(client): +def test_plan_empty_fallback_help_text(): plan = Plan( - "foo", PlanModel(name="foo", schema={"properties": {}, "required": []}), client + "foo", PlanModel(name="foo", schema={"properties": {}, "required": []}), Mock() ) assert plan.help_text == "Plan foo()" @@ -729,18 +726,11 @@ def test_plan_empty_fallback_help_text(client): ], ) def test_plan_param_mapping(args, kwargs, params): - client = Mock() - client.instrument_session = "cm12345-1" - plan = Plan( - FULL_PLAN.name, - FULL_PLAN, - client, - ) + runner = Mock() + plan = Plan(FULL_PLAN.name, FULL_PLAN, runner) plan(*args, **kwargs) - client.run_task.assert_called_once_with( - TaskRequest(name="foobar", instrument_session="cm12345-1", params=params) - ) + runner.assert_called_once_with("foobar", params) @pytest.mark.parametrize( @@ -759,17 +749,16 @@ def test_plan_param_mapping(args, kwargs, params): ], ) def test_plan_invalid_param_mapping(args, kwargs, msg): - client = Mock() - client.instrument_session = "cm12345-1" + runner = Mock(spec=Callable) plan = Plan( FULL_PLAN.name, FULL_PLAN, - client, + runner, ) with pytest.raises(TypeError, match=msg): plan(*args, **kwargs) - client.run_task.assert_not_called() + runner.assert_not_called() def test_adding_removing_callback(client): From 6c6a9005ddf568e5db466a31efcdacbbb4c98df3 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Fri, 16 Jan 2026 15:44:11 +0000 Subject: [PATCH 04/10] Tidy up doc strings - add logging - add docs --- src/blueapi/cli/cli.py | 5 ++++ src/blueapi/cli/stubgen.py | 23 +++++++++++++++---- ..._template.pyi => cache_template.pyi.jinja} | 4 +++- 3 files changed, 26 insertions(+), 6 deletions(-) rename src/blueapi/stubs/templates/{cache_template.pyi => cache_template.pyi.jinja} (96%) diff --git a/src/blueapi/cli/cli.py b/src/blueapi/cli/cli.py index 6134c6917..4cc73f6bf 100644 --- a/src/blueapi/cli/cli.py +++ b/src/blueapi/cli/cli.py @@ -156,6 +156,11 @@ def start_application(obj: dict): @click.pass_obj @click.argument("target", type=click.Path(file_okay=False)) def generate_stubs(obj: dict, target: Path): + """ + Generate a type-stubs project for blueapi for the currently running server. + This enables users using blueapi as a library to benefit from type checking + and linting when writing scripts against the BlueapiClient. + """ click.echo(f"Writing stubs to {target}") config: ApplicationConfig = obj["config"] diff --git a/src/blueapi/cli/stubgen.py b/src/blueapi/cli/stubgen.py index 67ef67e0a..7bc0ec77b 100644 --- a/src/blueapi/cli/stubgen.py +++ b/src/blueapi/cli/stubgen.py @@ -1,5 +1,6 @@ import logging from dataclasses import dataclass +from inspect import cleandoc from pathlib import Path from textwrap import dedent from typing import Self @@ -30,7 +31,7 @@ class PlanSpec: def from_plan(cls, plan: Plan) -> Self: req = set(plan.required) args = [ - ArgSpec(arg, _map_type(spec), arg not in req) + ArgSpec(arg, _type_string(spec), arg not in req) for arg, spec in plan.model.parameter_schema.get("properties", {}).items() ] return cls(plan.name, plan.help_text, args) @@ -39,11 +40,11 @@ def from_plan(cls, plan: Plan) -> Self: BLUESKY_PROTOCOL_NAMES = {context.qualified_name(proto) for proto in BLUESKY_PROTOCOLS} -def _map_type(spec) -> str: +def _type_string(spec) -> str: """Best effort attempt at making useful type hints for plans""" match spec.get("type"): case "array": - return f"list[{_map_type(spec.get('items'))}]" + return f"list[{_type_string(spec.get('items'))}]" case "integer": return "int" case "number": @@ -57,7 +58,7 @@ def _map_type(spec) -> str: case "boolean": return "bool" case None if opts := spec.get("anyOf"): - return "|".join(_map_type(opt) for opt in opts) + return " | ".join(_type_string(opt) for opt in opts) case _: return "Any" @@ -66,11 +67,15 @@ def generate_stubs(target: Path, plans: list[Plan], devices: list[DeviceRef]): log.info("Generating stubs for %d plans and %d devices", len(plans), len(devices)) target.mkdir(parents=True, exist_ok=True) client_dir = target / "src" / "blueapi-stubs" / "client" + + log.debug("Making project structure: %s", client_dir) client_dir.mkdir(parents=True, exist_ok=True) + stub_file = client_dir / "cache.pyi" project_file = target / "pyproject.toml" py_typed = target / "src" / "blueapi-stubs" / "py.typed" + log.debug("Writing pyproject.toml to %s", project_file) with open(project_file, "w") as out: out.write( dedent(""" @@ -87,18 +92,26 @@ def generate_stubs(target: Path, plans: list[Plan], devices: list[DeviceRef]): """) ) + log.debug("Writing py.typed file to %s", py_typed) with open(py_typed, "w") as out: out.write("partial\n") render_stub_file(stub_file, plans, devices) +def _docstring(text: str) -> str: + # """Convert a docstring to a format that can be inserted into the template""" + return cleandoc(text).replace('"""', '\\"""') + + def render_stub_file( stub_file: Path, plan_models: list[Plan], devices: list[DeviceRef] ): + log.debug("Writing stub file to %s", stub_file) plans = [PlanSpec.from_plan(p) for p in plan_models] env = Environment(loader=PackageLoader("blueapi", package_path="stubs/templates")) - tmpl = env.get_template("cache_template.pyi") + env.filters["docstring"] = _docstring + tmpl = env.get_template("cache_template.pyi.jinja") with open(stub_file, "w") as out: out.write(tmpl.render(plans=plans, devices=devices)) diff --git a/src/blueapi/stubs/templates/cache_template.pyi b/src/blueapi/stubs/templates/cache_template.pyi.jinja similarity index 96% rename from src/blueapi/stubs/templates/cache_template.pyi rename to src/blueapi/stubs/templates/cache_template.pyi.jinja index 636243ce9..3666b7018 100644 --- a/src/blueapi/stubs/templates/cache_template.pyi +++ b/src/blueapi/stubs/templates/cache_template.pyi.jinja @@ -25,7 +25,9 @@ class PlanCache: {{ arg.name }}: {{ arg.type }}{% if arg.optional %} | None = None{% endif %}, {%- endfor %} ) -> WorkerEvent: - """{{ item.docs }}""" + """ + {{ item.docs | docstring | indent(8) }} + """ ... {% endfor %} From 5d29cb0ce81e961ebd1a9a02c34fb4875b0ea3b2 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Fri, 16 Jan 2026 16:22:53 +0000 Subject: [PATCH 05/10] Add tests for stubgen utility functions --- tests/unit_tests/cli/test_stubgen.py | 78 ++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/unit_tests/cli/test_stubgen.py diff --git a/tests/unit_tests/cli/test_stubgen.py b/tests/unit_tests/cli/test_stubgen.py new file mode 100644 index 000000000..f2c127768 --- /dev/null +++ b/tests/unit_tests/cli/test_stubgen.py @@ -0,0 +1,78 @@ +from types import FunctionType + +import pytest + +from blueapi.cli.stubgen import _docstring, _type_string + + +def single_line(): + """Single line docstring""" + + +def single_line_new_line(): + """ + Single line docstring + """ + + +def multi_line_inline(): + """First line + Second line""" + + +def multi_line_new_line(): + """ + First line + Second line + """ + + +def indented_multi_line(): + """ + First line + indented + """ + + +@pytest.mark.parametrize( + "input,expected", + [ + (single_line, "Single line docstring"), + (single_line_new_line, "Single line docstring"), + (multi_line_inline, "First line\nSecond line"), + (multi_line_new_line, "First line\nSecond line"), + (indented_multi_line, "First line\n indented"), + ], +) +def test_docstring_filter(input: FunctionType, expected: str): + assert input.__doc__ + assert _docstring(input.__doc__) == expected + + +@pytest.mark.parametrize( + "typ,expected", + [ + ({"type": "string"}, "str"), + ({"type": "number"}, "float"), + ({"type": "integer"}, "int"), + ({"type": "object"}, "dict[str, Any]"), + ({"type": "boolean"}, "bool"), + ({"type": "array", "items": {"type": "integer"}}, "list[int]"), + ({"type": "array", "items": {"type": "object"}}, "list[dict[str, Any]]"), + ( + { + "type": "array", + "items": {"anyOf": [{"type": "integer"}, {"type": "boolean"}]}, + }, + "list[int | bool]", + ), + ({"anyOf": [{"type": "object"}, {"type": "string"}]}, "dict[str, Any] | str"), + ({"type": "unknown.other.Type"}, "Any"), + # Special case the bluesky protocols to require device references + ({"type": "bluesky.protocols.Readable"}, "DeviceRef"), + ({}, "Any"), + ], + ids=lambda param: param.get("type") if isinstance(param, dict) else param, +) +def test_type_string(typ: dict, expected: str): + assert _type_string(typ) == expected From 2468e3dbcad820220f854c74c6de2287a868f9aa Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Fri, 16 Jan 2026 16:35:33 +0000 Subject: [PATCH 06/10] Add generated markers to stub file --- src/blueapi/stubs/templates/cache_template.pyi.jinja | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/blueapi/stubs/templates/cache_template.pyi.jinja b/src/blueapi/stubs/templates/cache_template.pyi.jinja index 3666b7018..7e2c5fa02 100644 --- a/src/blueapi/stubs/templates/cache_template.pyi.jinja +++ b/src/blueapi/stubs/templates/cache_template.pyi.jinja @@ -20,7 +20,8 @@ class PlanCache: ... def __repr__(self) -> str: ... - {% for item in plans -%} +### Generated plans +{%- for item in plans %} def {{ item.name }}(self,{% for arg in item.args %} {{ arg.name }}: {{ arg.type }}{% if arg.optional %} | None = None{% endif %}, {%- endfor %} @@ -29,7 +30,8 @@ class PlanCache: {{ item.docs | docstring | indent(8) }} """ ... - {% endfor %} +{% endfor -%} +### End class Plan: @@ -61,6 +63,8 @@ class DeviceCache: ... def __repr__(self) -> str: ... - {% for item in devices -%} +### Generated devices + {%- for item in devices %} {{ item }}: DeviceRef - {% endfor %} + {%- endfor %} +### End From b19ea2ef62a7642a2ad4d28dfa4456a1a0a5872c Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Fri, 16 Jan 2026 16:39:08 +0000 Subject: [PATCH 07/10] Open file outside render method Enable testing with StringIO --- src/blueapi/cli/stubgen.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/blueapi/cli/stubgen.py b/src/blueapi/cli/stubgen.py index 7bc0ec77b..6f6fbf4bd 100644 --- a/src/blueapi/cli/stubgen.py +++ b/src/blueapi/cli/stubgen.py @@ -3,7 +3,7 @@ from inspect import cleandoc from pathlib import Path from textwrap import dedent -from typing import Self +from typing import Self, TextIO from jinja2 import Environment, PackageLoader @@ -96,7 +96,9 @@ def generate_stubs(target: Path, plans: list[Plan], devices: list[DeviceRef]): with open(py_typed, "w") as out: out.write("partial\n") - render_stub_file(stub_file, plans, devices) + log.debug("Writing stub file to %s", stub_file) + with open(stub_file, "w") as out: + render_stub_file(out, plans, devices) def _docstring(text: str) -> str: @@ -105,13 +107,11 @@ def _docstring(text: str) -> str: def render_stub_file( - stub_file: Path, plan_models: list[Plan], devices: list[DeviceRef] + stub_file: TextIO, plan_models: list[Plan], devices: list[DeviceRef] ): - log.debug("Writing stub file to %s", stub_file) plans = [PlanSpec.from_plan(p) for p in plan_models] env = Environment(loader=PackageLoader("blueapi", package_path="stubs/templates")) env.filters["docstring"] = _docstring tmpl = env.get_template("cache_template.pyi.jinja") - with open(stub_file, "w") as out: - out.write(tmpl.render(plans=plans, devices=devices)) + stub_file.write(tmpl.render(plans=plans, devices=devices)) From 76d70fde8b728f4c90740876a6b3d917c39e988c Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Fri, 16 Jan 2026 16:41:33 +0000 Subject: [PATCH 08/10] Add direct jinja2 dependency It was already included via fastapi but if we're using it directly we should probably include it. --- pyproject.toml | 1 + uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index bc63ec921..9d38e5fbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "pyjwt[crypto]", "tomlkit", "graypy>=2.1.0", + "jinja2>=3.1.6", ] dynamic = ["version"] license.file = "LICENSE" diff --git a/uv.lock b/uv.lock index efbb556aa..d44bac795 100644 --- a/uv.lock +++ b/uv.lock @@ -438,6 +438,7 @@ dependencies = [ { name = "fastapi" }, { name = "gitpython" }, { name = "graypy" }, + { name = "jinja2" }, { name = "observability-utils" }, { name = "opentelemetry-distro" }, { name = "opentelemetry-instrumentation-fastapi" }, @@ -495,6 +496,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.112.0" }, { name = "gitpython" }, { name = "graypy", specifier = ">=2.1.0" }, + { name = "jinja2", specifier = ">=3.1.6" }, { name = "observability-utils", specifier = ">=0.1.4" }, { name = "opentelemetry-distro", specifier = ">=0.48b0" }, { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.48b0" }, From 595d2a8adc2131aabdf95c0e13cd0aeeccab3f53 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Mon, 19 Jan 2026 16:25:36 +0000 Subject: [PATCH 09/10] Test things --- src/blueapi/cli/cli.py | 2 +- src/blueapi/client/cache.py | 13 +- .../stubs/templates/cache_template.pyi.jinja | 8 +- tests/unit_tests/cli/test_stubgen.py | 138 +++++++++++++++++- tests/unit_tests/client/test_client.py | 9 +- tests/unit_tests/test_cli.py | 14 ++ 6 files changed, 167 insertions(+), 17 deletions(-) diff --git a/src/blueapi/cli/cli.py b/src/blueapi/cli/cli.py index 4cc73f6bf..523732565 100644 --- a/src/blueapi/cli/cli.py +++ b/src/blueapi/cli/cli.py @@ -39,6 +39,7 @@ from blueapi.service.model import DeviceResponse, PlanResponse, SourceInfo, TaskRequest from blueapi.worker import ProgressEvent, WorkerEvent +from . import stubgen from .scratch import setup_scratch from .updates import CliEventRenderer @@ -165,7 +166,6 @@ def generate_stubs(obj: dict, target: Path): config: ApplicationConfig = obj["config"] bc = BlueapiClient.from_config(config) - from . import stubgen stubgen.generate_stubs(Path(target), list(bc.plans), list(bc.devices)) diff --git a/src/blueapi/client/cache.py b/src/blueapi/client/cache.py index adcab7182..0ec8c4c87 100644 --- a/src/blueapi/client/cache.py +++ b/src/blueapi/client/cache.py @@ -22,10 +22,7 @@ class PlanCache: """ def __init__(self, runner: PlanRunner, plans: list[PlanModel]): - self._cache = { - model.name: Plan(name=model.name, model=model, runner=runner) - for model in plans - } + self._cache = {model.name: Plan(model=model, runner=runner) for model in plans} for name, plan in self._cache.items(): if name.startswith("_"): continue @@ -56,12 +53,12 @@ class Plan: blueapi generate-stubs /tmp/blueapi-stubs uv add --editable /tmp/blueapi-stubs + """ model: PlanModel - def __init__(self, name, model: PlanModel, runner: PlanRunner): - self.name = name + def __init__(self, model: PlanModel, runner: PlanRunner): self.model = model self._runner = runner self.__doc__ = model.description @@ -72,6 +69,10 @@ def __call__(self, *args, **kwargs) -> WorkerEvent: """ return self._runner(self.name, self._build_args(*args, **kwargs)) + @property + def name(self) -> str: + return self.model.name + @property def help_text(self) -> str: return self.model.description or f"Plan {self!r}" diff --git a/src/blueapi/stubs/templates/cache_template.pyi.jinja b/src/blueapi/stubs/templates/cache_template.pyi.jinja index 7e2c5fa02..b06ef3638 100644 --- a/src/blueapi/stubs/templates/cache_template.pyi.jinja +++ b/src/blueapi/stubs/templates/cache_template.pyi.jinja @@ -30,16 +30,18 @@ class PlanCache: {{ item.docs | docstring | indent(8) }} """ ... -{% endfor -%} +{%- endfor %} ### End class Plan: model: PlanModel - name: str - def __init__(self, name, model: PlanModel, runner: PlanRunner) -> None: ... + def __init__(self, model: PlanModel, runner: PlanRunner) -> None: ... def __call__(self, *args, **kwargs): # -> None: ... + + @property + def name(self) -> str: ... @property def help_text(self) -> str: ... @property diff --git a/tests/unit_tests/cli/test_stubgen.py b/tests/unit_tests/cli/test_stubgen.py index f2c127768..766f3e2f0 100644 --- a/tests/unit_tests/cli/test_stubgen.py +++ b/tests/unit_tests/cli/test_stubgen.py @@ -1,8 +1,18 @@ +from io import StringIO +from textwrap import dedent from types import FunctionType +from unittest.mock import Mock import pytest -from blueapi.cli.stubgen import _docstring, _type_string +from blueapi.cli.stubgen import ( + _docstring, + _type_string, + generate_stubs, + render_stub_file, +) +from blueapi.client.cache import DeviceRef, Plan +from blueapi.service.model import DeviceModel, PlanModel def single_line(): @@ -76,3 +86,129 @@ def test_docstring_filter(input: FunctionType, expected: str): ) def test_type_string(typ: dict, expected: str): assert _type_string(typ) == expected + + +def test_render_empty(): + output = StringIO() + + render_stub_file(output, [], []) + plan_text, device_text = _extract_rendered(output) + + assert plan_text == "" + assert device_text == "" + + +FOO = PlanModel(name="empty", description="Doc string for empty", schema={}) + +BAR = PlanModel( + name="two_args", + description="Doc string for two_args", + schema={ + "properties": { + "one": {"type": "integer"}, + "two": {"type": "string"}, + }, + "required": ["one"], + }, +) + + +def test_render_empty_plan_function(): + output = StringIO() + plans = [Plan(model=FOO, runner=Mock())] + render_stub_file(output, plans, []) + plan_text, device_text = _extract_rendered(output) + + assert device_text == "" + + assert ( + plan_text + == """\ + def empty(self, + ) -> WorkerEvent: + \""" + Doc string for empty + \""" + ...\n""" + ) + + +def test_render_multiple_plan_functions(): + output = StringIO() + runner = Mock() + plans = [Plan(FOO, runner), Plan(BAR, runner)] + render_stub_file(output, plans, []) + plan_text, device_text = _extract_rendered(output) + assert device_text == "" + + assert ( + plan_text + == """\ + def empty(self, + ) -> WorkerEvent: + \""" + Doc string for empty + \""" + ... + def two_args(self, + one: int, + two: str | None = None, + ) -> WorkerEvent: + \""" + Doc string for two_args + \""" + ...\n""" + ) + + +def test_device_fields(): + output = StringIO() + cache = Mock() + devices = [ + DeviceRef("one", cache, DeviceModel(name="one", protocols=[])), + DeviceRef("two", cache, DeviceModel(name="two", protocols=[])), + ] + render_stub_file(output, [], devices) + + plan_text, device_text = _extract_rendered(output) + assert plan_text == "" + assert device_text == " one: DeviceRef\n two: DeviceRef\n" + + +def test_package_creation(tmp_path): + generate_stubs(tmp_path / "blueapi-stubs", [], []) + with open(tmp_path / "blueapi-stubs" / "pyproject.toml") as pyproj: + assert pyproj.read().startswith( + dedent(""" + [project] + name = "blueapi-stubs" + version = "0.1.0" + """) + ) + with open( + tmp_path / "blueapi-stubs" / "src" / "blueapi-stubs" / "py.typed" + ) as typed: + assert typed.read() == "partial\n" + + assert ( + tmp_path / "blueapi-stubs" / "src" / "blueapi-stubs" / "client" / "cache.pyi" + ).exists() + + +def _extract_rendered(src: StringIO) -> tuple[str, str]: + src.seek(0) + _read_until_line(src, "### Generated plans") + plan_text = _read_until_line(src, "### End") + _read_until_line(src, "### Generated devices") + device_text = _read_until_line(src, "### End") + return plan_text, device_text + + +def _read_until_line(src: StringIO, match: str) -> str: + text = "" + for line in src: + if line.startswith(match): + break + text += line + + return text diff --git a/tests/unit_tests/client/test_client.py b/tests/unit_tests/client/test_client.py index 4f6d85f61..67502d00d 100644 --- a/tests/unit_tests/client/test_client.py +++ b/tests/unit_tests/client/test_client.py @@ -675,13 +675,12 @@ def test_device_ignores_underscores(): def test_plan_help_text(): - plan = Plan("foo", PlanModel(name="foo", description="help for foo"), Mock()) + plan = Plan(PlanModel(name="foo", description="help for foo"), Mock()) assert plan.help_text == "help for foo" def test_plan_fallback_help_text(): plan = Plan( - "foo", PlanModel( name="foo", schema={"properties": {"one": {}, "two": {}}, "required": ["one"]}, @@ -693,7 +692,6 @@ def test_plan_fallback_help_text(): def test_plan_properties(): plan = Plan( - "foo", PlanModel( name="foo", schema={"properties": {"one": {}, "two": {}}, "required": ["one"]}, @@ -707,7 +705,7 @@ def test_plan_properties(): def test_plan_empty_fallback_help_text(): plan = Plan( - "foo", PlanModel(name="foo", schema={"properties": {}, "required": []}), Mock() + PlanModel(name="foo", schema={"properties": {}, "required": []}), Mock() ) assert plan.help_text == "Plan foo()" @@ -727,7 +725,7 @@ def test_plan_empty_fallback_help_text(): ) def test_plan_param_mapping(args, kwargs, params): runner = Mock() - plan = Plan(FULL_PLAN.name, FULL_PLAN, runner) + plan = Plan(FULL_PLAN, runner) plan(*args, **kwargs) runner.assert_called_once_with("foobar", params) @@ -751,7 +749,6 @@ def test_plan_param_mapping(args, kwargs, params): def test_plan_invalid_param_mapping(args, kwargs, msg): runner = Mock(spec=Callable) plan = Plan( - FULL_PLAN.name, FULL_PLAN, runner, ) diff --git a/tests/unit_tests/test_cli.py b/tests/unit_tests/test_cli.py index 7210ec2bb..a789d21eb 100644 --- a/tests/unit_tests/test_cli.py +++ b/tests/unit_tests/test_cli.py @@ -1329,3 +1329,17 @@ def test_config_schema( stream.write.assert_called() else: assert json.loads(result.output) == expected + pass + + +@patch("blueapi.client.client.BlueapiClient.from_config") +@patch("blueapi.cli.cli.stubgen") +def test_genstubs( + stubgen, + client, + runner: CliRunner, +): + runner.invoke(main, ["generate-stubs", "/path/to/stub_dir"]) + stubgen.generate_stubs.assert_called_once_with( + Path("/path/to/stub_dir"), list(client().plans), list(client().devices) + ) From 0c9b8d4bf58c5e3761a4189e95104175fc2fc346 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Mon, 19 Jan 2026 16:43:05 +0000 Subject: [PATCH 10/10] Test BlueapiClient.run_plan --- tests/unit_tests/client/test_client.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit_tests/client/test_client.py b/tests/unit_tests/client/test_client.py index 67502d00d..8bbc54578 100644 --- a/tests/unit_tests/client/test_client.py +++ b/tests/unit_tests/client/test_client.py @@ -406,6 +406,15 @@ def test_run_task_fails_on_failing_event( on_event.assert_called_with(FAILED_EVENT) +@patch("blueapi.client.client.BlueapiClient.run_task") +def test_run_plan(run_task, client, mock_rest): + client.instrument_session = "cm12345-2" + client.run_plan("foo", {"foo": "bar"}) + run_task.assert_called_once_with( + TaskRequest(name="foo", params={"foo": "bar"}, instrument_session="cm12345-2") + ) + + @pytest.mark.parametrize( "test_event", [