From 0bf358c5b73956b3656c3ea8fd68142840977455 Mon Sep 17 00:00:00 2001 From: RJCD-Diamond Date: Fri, 28 Nov 2025 14:36:18 +0000 Subject: [PATCH 1/4] add UserClient --- src/blueapi/client/user_client.py | 210 ++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 src/blueapi/client/user_client.py diff --git a/src/blueapi/client/user_client.py b/src/blueapi/client/user_client.py new file mode 100644 index 000000000..e77824fc3 --- /dev/null +++ b/src/blueapi/client/user_client.py @@ -0,0 +1,210 @@ +import time +import warnings +from collections.abc import Callable +from pathlib import Path + +from bluesky.callbacks.best_effort import BestEffortCallback +from dodal.common import inject +from ophyd_async.core import StandardReadable + +from blueapi.cli.updates import CliEventRenderer +from blueapi.client.client import BlueapiClient +from blueapi.client.event_bus import AnyEvent +from blueapi.client.rest import BlueskyRemoteControlError +from blueapi.config import ( + ApplicationConfig, + ConfigLoader, +) +from blueapi.core import DataEvent +from blueapi.service.model import TaskRequest +from blueapi.worker import ProgressEvent + +warnings.filterwarnings("ignore") # callback complains about not running in main thread + +# Currently matplotlib uses tkinter as default, tkinter must be in the main thread +# WebAgg does need ot be, so can allow LivePlots +# import matplotlib +# matplotlib.use("WebAgg") + + +class UserClient(BlueapiClient): + """A client that can be easily used by the user, beamline scientist + in a scripts, for running bluesky plans. + + Example usage: + + blueapi_config_path = "/path/to/ixx_blueapi_config.yaml" + + client = UserClient(blueapi_config_path, "cm12345-1") + client.run("count", detectors=["det1", "det2"]) + client.change_session("cm12345-2") + + from dodal.plan_stubs.wrapped + + client.run(move, moves={"base.x": 0}) # move base.x to 0 + + or if passing the bluesky function you can just use args: + + client.run(move, {"base.x": 0}) + + """ + + def __init__( + self, + blueapi_config_path: str | Path, + instrument_session: str, + callback: bool = True, + timeout: int | float | None = None, + non_callback_delay: int | float = 1, + ): + self.instrument_session = instrument_session + self.callback = callback + self.retries = 5 + self.timeout = timeout + self.non_callback_delay = non_callback_delay + + blueapi_config_path = Path(blueapi_config_path) + + config_loader = ConfigLoader(ApplicationConfig) + config_loader.use_values_from_yaml(blueapi_config_path) + loaded_config = config_loader.load() + blueapi_class = BlueapiClient.from_config(loaded_config) + super().__init__(blueapi_class._rest, blueapi_class._events) # noqa + + def _convert_args_to_kwargs(self, plan: Callable, args: tuple) -> dict: + """Converts args to kwargs + If the user does not give kwargs, but gives args the bluesky plan is passed + this function can infer the kwargs, build the kwargs and create the params + for TaskRequest""" + arg_names = plan.__code__.co_varnames + inferred_kwargs = {} + + for key, val in zip(arg_names, args): # noqa intentionally not strict + inferred_kwargs[key] = val + params = inferred_kwargs + return params + + def _args_and_kwargs_to_params( + self, plan: Callable | str, args: tuple, kwargs: dict + ) -> dict: + """ + Creates the params needed for TaskRequest + """ + if not args and not kwargs: + params = {} + return params + elif kwargs and (not args): + params = kwargs + return params + elif ( + args + and (not kwargs) + and hasattr(plan, "__code__") + and not isinstance(plan, str) + ): + params = self._convert_args_to_kwargs(plan, args) + return params + elif ( + args and kwargs and hasattr(plan, "__code__") and not isinstance(plan, str) + ): + params = self._convert_args_to_kwargs(plan, args) + params.update(kwargs) + return params + elif isinstance(plan, str) and args: + raise ValueError("If you pass the bluesky plan str, you can't pass args ") + else: + raise ValueError("Could not infer parameters from args and kwargs") + + def run(self, plan: str | Callable, *args, **kwargs): + """Run a bluesky plan via BlueAPI. + plan can be a string, or the bluesky plan name""" + + if isinstance(plan, str): + plan_name = plan + elif hasattr(plan, "__name__") and hasattr(plan, "__code__"): + plan_name = plan.__name__ + else: + raise ValueError("Must be a str or a bluesky plan function") + + params = self._args_and_kwargs_to_params(plan, args=args, kwargs=kwargs) + + task = TaskRequest( + name=plan_name, + params=params, + instrument_session=self.instrument_session, + ) + if self.callback: + self.send_with_callback(plan_name, task) + else: + self.send_without_callback(plan_name, task) + + def return_detectors(self) -> list[StandardReadable]: + """Return a list of StandardReadable for the current beamline.""" + devices = self.get_devices().devices + return [inject(d.name) for d in devices] + + def change_session(self, new_session: str) -> None: + """Change the instrument session for the client.""" + print(f"New instrument session: {new_session}") + self.instrument_session = new_session + + def show_plans(self): + """Shows the bluesky plan names in a nice, human readable way""" + plans = self.get_plans().plans + for plan in plans: + print(plan.name) + print(f"Total plans: {len(plans)} \n") + + def show_devices(self): + """Shows the devices in a nice, human readable way""" + devices = self.get_devices().devices + for dev in devices: + print(dev.name) + print(f"Total devices: {len(devices)} \n") + + def send_with_callback(self, plan_name: str, task: TaskRequest): + """Sends a bluesky Task to blueapi with callback. + Callback allows LiveTable and LivePlot to be generated + """ + try: + progress_bar = CliEventRenderer() + callback = BestEffortCallback() + + def on_event(event: AnyEvent) -> None: + if isinstance(event, ProgressEvent): + progress_bar.on_progress_event(event) + elif isinstance(event, DataEvent): + callback(event.name, event.doc) + + resp = self.run_task(task, on_event=on_event, timeout=self.timeout) + + if ( + (resp.task_status is not None) + and (resp.task_status.task_complete) + and (not resp.task_status.task_failed) + ): + print(f"{plan_name} succeeded") + + return + + except Exception as e: + raise Exception(f"Task could not run: {e}") from e + + def send_without_callback(self, plan_name: str, task: TaskRequest): + """Send the TaskRequest as a put request. + Because it does not have callback + It does not know if blueapi is busy. + So it will try multiple times with a delay""" + success = False + + for _ in range(self.retries): + try: + server_task = self.create_and_start_task(task) + print(f"{plan_name} task sent as {server_task.task_id}") + success = True + return + except BlueskyRemoteControlError: + time.sleep(self.non_callback_delay) + + if not success: + raise Exception("Task could not be executed") From 8bddf460e2717e3660e60334cf05e0b7036b5af6 Mon Sep 17 00:00:00 2001 From: Richard Dixey Date: Wed, 3 Dec 2025 13:33:36 +0000 Subject: [PATCH 2/4] fix comment --- src/blueapi/client/user_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blueapi/client/user_client.py b/src/blueapi/client/user_client.py index e77824fc3..5df1a086d 100644 --- a/src/blueapi/client/user_client.py +++ b/src/blueapi/client/user_client.py @@ -39,7 +39,7 @@ class UserClient(BlueapiClient): client.run("count", detectors=["det1", "det2"]) client.change_session("cm12345-2") - from dodal.plan_stubs.wrapped + from dodal.plan_stubs.wrapped import move client.run(move, moves={"base.x": 0}) # move base.x to 0 From 766fc6d463343fbeeee6c209a446e10b685797d2 Mon Sep 17 00:00:00 2001 From: Richard Dixey Date: Wed, 3 Dec 2025 17:26:24 +0000 Subject: [PATCH 3/4] add tests for UserClient --- .vscode/settings.json | 3 +- pyproject.toml | 3 +- src/blueapi/client/client.py | 15 +- src/blueapi/client/user_client.py | 4 +- tests/unit_tests/client/test_user_client.py | 215 ++++++++++++++++++++ 5 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 tests/unit_tests/client/test_user_client.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 07d260d60..a9031bfe4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,5 +14,6 @@ }, "[toml]": { "editor.defaultFormatter": "tamasfe.even-better-toml", - } + }, + "python-envs.pythonProjects": [] } diff --git a/pyproject.toml b/pyproject.toml index 50432e36b..fed950a1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dev = [ "pre-commit>=3.8.0", "pydata-sphinx-theme>=0.15.4", "pytest", - "pyright<1.1.407", # https://github.com/bluesky/scanspec/issues/190 + "pyright<1.1.407", # https://github.com/bluesky/scanspec/issues/190 "pytest-cov", "pytest-asyncio", "responses", @@ -69,6 +69,7 @@ dev = [ "mock", "jwcrypto", "deepdiff", + "dls-dodal", ] [project.scripts] diff --git a/src/blueapi/client/client.py b/src/blueapi/client/client.py index 0930e240a..80cf16f1d 100644 --- a/src/blueapi/client/client.py +++ b/src/blueapi/client/client.py @@ -48,8 +48,10 @@ def __init__( self._rest = rest self._events = events - @classmethod - def from_config(cls, config: ApplicationConfig) -> "BlueapiClient": + @staticmethod + def config_to_rest_and_events( + config: ApplicationConfig, + ) -> tuple[BlueapiRestClient, EventBusClient | None]: session_manager: SessionManager | None = None try: session_manager = SessionManager.from_cache(config.auth_token_path) @@ -67,9 +69,14 @@ def from_config(cls, config: ApplicationConfig) -> "BlueapiClient": ) ) events = EventBusClient(client) - return cls(rest, events) + return rest, events else: - return cls(rest) + return rest, None + + @classmethod + def from_config(cls, config: ApplicationConfig) -> "BlueapiClient": + rest, events = BlueapiClient.config_to_rest_and_events(config) + return cls(rest, events) @start_as_current_span(TRACER) def get_plans(self) -> PlanResponse: diff --git a/src/blueapi/client/user_client.py b/src/blueapi/client/user_client.py index 5df1a086d..ba425a60d 100644 --- a/src/blueapi/client/user_client.py +++ b/src/blueapi/client/user_client.py @@ -68,8 +68,8 @@ def __init__( config_loader = ConfigLoader(ApplicationConfig) config_loader.use_values_from_yaml(blueapi_config_path) loaded_config = config_loader.load() - blueapi_class = BlueapiClient.from_config(loaded_config) - super().__init__(blueapi_class._rest, blueapi_class._events) # noqa + rest, events = BlueapiClient.config_to_rest_and_events(loaded_config) + super().__init__(rest, events) def _convert_args_to_kwargs(self, plan: Callable, args: tuple) -> dict: """Converts args to kwargs diff --git a/tests/unit_tests/client/test_user_client.py b/tests/unit_tests/client/test_user_client.py new file mode 100644 index 000000000..a53511110 --- /dev/null +++ b/tests/unit_tests/client/test_user_client.py @@ -0,0 +1,215 @@ +from unittest.mock import Mock, patch + +import pytest +from dodal.plans.wrapped import count + +from blueapi.client.client import BlueapiClient +from blueapi.client.event_bus import EventBusClient +from blueapi.client.rest import BlueapiRestClient +from blueapi.client.user_client import UserClient +from blueapi.service.model import DeviceResponse, PlanResponse + +BLUEAPI_CONFIG_PATH = ( + "/workspaces/blueapi/tests/unit_tests/valid_example_config/client.yaml" +) + + +class MockDevice: + def __init__(self, device: str): + self.device = device + self.name = device + + +class MockResponse: + def __init__(self, devices: list): + self.devices = devices + + +@pytest.fixture(autouse=True) +def client(): + client = UserClient(BLUEAPI_CONFIG_PATH, "cm12345-1", callback=True) + + client._rest = Mock(BlueapiRestClient) + client._events = Mock(EventBusClient) + + client._events.__enter__ = Mock(return_value=client._events) + client._events.__exit__ = Mock(return_value=None) + + return client + + +@pytest.fixture(autouse=True) +def client_without_callback(): + client_without_callback = UserClient( + BLUEAPI_CONFIG_PATH, "cm12345-1", callback=False + ) + + return client_without_callback + + +def test_blueapi_python_client(client: UserClient): + assert isinstance(client, BlueapiClient) + assert isinstance(client, UserClient) + + +def test_blueapi_python_client_change_session(client: UserClient): + new_session = "cm54321-1" + client.change_session(new_session) + assert client.instrument_session == new_session + + +def test_blueapi_python_client_run(client: UserClient): + # Patch instance methods so run executes but no re calls happen. + with ( + patch.object(client, "run_task", return_value=Mock()), + patch.object( + client, "create_and_start_task", return_value=Mock(task_id="t-fake") + ), + patch.object(client, "create_task", return_value=Mock(task_id="t-fake")), + patch.object(client, "start_task", return_value=Mock(task_id="t-fake")), + ): + assert client._events is not None + # Ensure the mocked event client can be used as a context manager if run uses it + client._events.__enter__ = Mock(return_value=client._events) + client._events.__exit__ = Mock(return_value=None) + + # Call run while the instance methods are patched + client.run(count) + client.run("count") + + +def test_blueapi_python_client_without_callback_run( + client_without_callback: UserClient, +): + # Patch instance methods so run executes but no calls happen + with ( + patch.object(client_without_callback, "run_task", return_value=Mock()), + patch.object( + client_without_callback, + "create_and_start_task", + return_value=Mock(task_id="t-fake"), + ), + patch.object( + client_without_callback, "create_task", return_value=Mock(task_id="t-fake") + ), + patch.object( + client_without_callback, "start_task", return_value=Mock(task_id="t-fake") + ), + ): + # Ensure the mocked event client can be used as a context manager if run uses it + client_without_callback._events = Mock(EventBusClient) + client_without_callback._events.__enter__ = Mock( + return_value=client_without_callback._events + ) + client_without_callback._events.__exit__ = Mock(return_value=None) + + client_without_callback.run(count) + + +@pytest.mark.parametrize( + "plan, args, kwargs", + ( + ["plan", (), {}], + [count, ["det1", "det2"], {}], + [count, ["det1", "det2"], {"num": 2}], + [count, (), {"detectors": ["det1", "det2"]}], + ), +) +def test_run_with_valid_paraneters(client: UserClient, plan, args: tuple, kwargs: dict): + # Patch instance methods so run executes but no re calls happen. + with ( + patch.object(client, "run_task", return_value=Mock()), + patch.object( + client, "create_and_start_task", return_value=Mock(task_id="t-fake") + ), + patch.object(client, "create_task", return_value=Mock(task_id="t-fake")), + patch.object(client, "start_task", return_value=Mock(task_id="t-fake")), + ): + assert client._events is not None + # Ensure the mocked event client can be used as a context manager if run uses it + client._events.__enter__ = Mock(return_value=client._events) + client._events.__exit__ = Mock(return_value=None) + + client.run(plan, *args, **kwargs) + + +@pytest.mark.parametrize( + "plan, args, kwargs", + ( + [None, (), {}], + ["plan", ["det1", "det2"], {}], + ["plan", ["det1", "det2"], {"num": 2}], + ), +) +def test_run_fails_with_invalid_parameters( + client: UserClient, plan, args: tuple, kwargs: dict +): + # Patch instance methods so run executes but no re calls happen. + with ( + patch.object(client, "run_task", return_value=Mock()), + patch.object( + client, "create_and_start_task", return_value=Mock(task_id="t-fake") + ), + patch.object(client, "create_task", return_value=Mock(task_id="t-fake")), + patch.object(client, "start_task", return_value=Mock(task_id="t-fake")), + ): + assert client._events is not None + # Ensure the mocked event client can be used as a context manager if run uses it + client._events.__enter__ = Mock(return_value=client._events) + client._events.__exit__ = Mock(return_value=None) + + # Call run while the instance methods are patched + with pytest.raises(ValueError): # noqa + client.run(plan, *args, **kwargs) + + +def test_return_detectors(client: UserClient): + # Mock the expected detector list response + + # Create a method mock for get_detectors + client.get_devices = Mock( + DeviceResponse, + return_value=MockResponse([MockDevice("det1"), MockDevice("det2")]), + ) + + # Call the method under test + result = client.return_detectors() + + # Verify the result matches our expected data + + # Verify the REST client was called correctly + client.get_devices.assert_called_once() + + assert isinstance(result, list) + + +def test_show_devices(client: UserClient): + # Create a method mock for get_detectors + client.get_devices = Mock( + DeviceResponse, + return_value=MockResponse([MockDevice("det1"), MockDevice("det2")]), + ) + + client.show_devices() + client.get_devices.assert_called_once() + + +class MockPlan: + def __init__(self, device: str): + self.name = device + + +class MockPlanResponse: + def __init__(self, plans: list): + self.plans = plans + + +def test_show_plans(client: UserClient): + # Create a method mock for get_detectors + client.get_plans = Mock( + PlanResponse, + return_value=MockPlanResponse([MockPlan("count"), MockPlan("test")]), + ) + + client.show_plans() + client.get_plans.assert_called_once() From 266aaa0a98ac3b7640c2d63540ed3d22adc4c947 Mon Sep 17 00:00:00 2001 From: Richard Dixey Date: Wed, 3 Dec 2025 17:34:18 +0000 Subject: [PATCH 4/4] fix test directory --- tests/unit_tests/client/test_user_client.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/client/test_user_client.py b/tests/unit_tests/client/test_user_client.py index a53511110..f1ce10ba3 100644 --- a/tests/unit_tests/client/test_user_client.py +++ b/tests/unit_tests/client/test_user_client.py @@ -1,3 +1,5 @@ +import os +from pathlib import Path from unittest.mock import Mock, patch import pytest @@ -9,8 +11,11 @@ from blueapi.client.user_client import UserClient from blueapi.service.model import DeviceResponse, PlanResponse -BLUEAPI_CONFIG_PATH = ( - "/workspaces/blueapi/tests/unit_tests/valid_example_config/client.yaml" +UNIT_TEST_DIRECTORY = Path(__file__).parent.parent + + +BLUEAPI_CONFIG_PATH = os.path.join( + UNIT_TEST_DIRECTORY, "valid_example_config/client.yaml" )