From 899f18ff0d9c242c6ea0a72defc8c7d7b7a36ddf Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Fri, 12 Jun 2026 15:10:15 +0100 Subject: [PATCH 1/3] refactor: Stop DeviceRef extending str This was originally to make serialization behave correctly when the task was passed to requests. As we are using pydantic to dump the model to JSON before passing it, we can add the custom serialization there to get the device-to-string conversion that we need. --- src/blueapi/client/client.py | 16 ++++++++-------- src/blueapi/client/rest.py | 10 +++++++++- tests/unit_tests/client/test_client.py | 4 ++-- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/blueapi/client/client.py b/src/blueapi/client/client.py index 68637594fa..034b9f6a98 100644 --- a/src/blueapi/client/client.py +++ b/src/blueapi/client/client.py @@ -120,23 +120,23 @@ def __repr__(self) -> str: return f"DeviceCache({len(self._cache)} devices)" -class DeviceRef(str): +class DeviceRef: + name: 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 __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}"] + return self._cache[f"{self.name}.{name}"] def __repr__(self): - return f"Device({self})" + return f"Device({self.name})" class Plan: diff --git a/src/blueapi/client/rest.py b/src/blueapi/client/rest.py index cacbc1656c..739c623095 100644 --- a/src/blueapi/client/rest.py +++ b/src/blueapi/client/rest.py @@ -13,6 +13,7 @@ from pydantic import BaseModel, TypeAdapter, ValidationError from blueapi import __version__ +from blueapi.client import client from blueapi.config import RestConfig from blueapi.service.authentication import JWTAuth, SessionManager from blueapi.service.model import ( @@ -241,7 +242,7 @@ def create_task(self, task: TaskRequest) -> TaskResponse: TaskResponse, method="POST", get_exception=_create_task_exceptions, - data=task.model_dump(), + data=task.model_dump(fallback=_task_model_fallback), ) def clear_task(self, task_id: str) -> TaskResponse: @@ -363,3 +364,10 @@ def __getattr__(name: str): class ServiceUnavailableError(Exception): pass + + +def _task_model_fallback(obj: Any) -> Any: + """Fallback method for serializing TaskRequests""" + if isinstance(obj, client.DeviceRef): + return obj.name + raise ValueError() diff --git a/tests/unit_tests/client/test_client.py b/tests/unit_tests/client/test_client.py index 169d727f62..125fc165c2 100644 --- a/tests/unit_tests/client/test_client.py +++ b/tests/unit_tests/client/test_client.py @@ -202,9 +202,9 @@ def test_get_child_device(mock_rest: Mock, client: BlueapiClient): else None ) foo = client.devices.foo - assert foo == "foo" + assert foo.name == "foo" x = client.devices.foo.x - assert x == "foo.x" + assert x.name == "foo.x" def test_state_property(client: BlueapiClient): From 239d1cba6d9ffb15cd1a4836af954c55fce31b0a Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Wed, 24 Jun 2026 11:18:21 +0100 Subject: [PATCH 2/3] Add test for device serialization --- tests/unit_tests/client/test_rest.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/unit_tests/client/test_rest.py b/tests/unit_tests/client/test_rest.py index 0de1c94277..fc1fff87e9 100644 --- a/tests/unit_tests/client/test_rest.py +++ b/tests/unit_tests/client/test_rest.py @@ -11,6 +11,7 @@ from responses import DELETE, GET, PUT, matchers from blueapi import __version__ +from blueapi.client.client import DeviceRef from blueapi.client.rest import ( BlueapiRestClient, BlueskyRemoteControlError, @@ -29,6 +30,7 @@ DeviceModel, EnvironmentResponse, PlanModel, + TaskRequest, TaskResponse, TasksListResponse, WorkerTask, @@ -77,6 +79,29 @@ def test_rest_error_code( rest.get_plans() +def test_create_task_serialization(): + rest = Mock(spec=BlueapiRestClient) + request = TaskRequest( + name="demo", + instrument_session="cm12345-1", + params={"devices": [DeviceRef(name="foo", cache=Mock(), model=Mock())]}, + ) + + BlueapiRestClient.create_task(rest, request) + + rest._request_and_deserialize.assert_called_once_with( + "/tasks", + TaskResponse, + method="POST", + get_exception=_create_task_exceptions, + data={ + "name": "demo", + "instrument_session": "cm12345-1", + "params": {"devices": ["foo"]}, + }, + ) + + @pytest.mark.parametrize( "code,content,expected_exception", [ From d093774204c3cbf0102a84b4a286571745042e86 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Wed, 24 Jun 2026 11:38:59 +0100 Subject: [PATCH 3/3] Add test for failing device serialization --- src/blueapi/client/rest.py | 5 +++-- tests/unit_tests/client/test_rest.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/blueapi/client/rest.py b/src/blueapi/client/rest.py index 739c623095..0bddb5c87c 100644 --- a/src/blueapi/client/rest.py +++ b/src/blueapi/client/rest.py @@ -11,6 +11,7 @@ start_as_current_span, ) from pydantic import BaseModel, TypeAdapter, ValidationError +from pydantic_core import PydanticSerializationError from blueapi import __version__ from blueapi.client import client @@ -242,7 +243,7 @@ def create_task(self, task: TaskRequest) -> TaskResponse: TaskResponse, method="POST", get_exception=_create_task_exceptions, - data=task.model_dump(fallback=_task_model_fallback), + data=task.model_dump(mode="json", fallback=_task_model_fallback), ) def clear_task(self, task_id: str) -> TaskResponse: @@ -370,4 +371,4 @@ def _task_model_fallback(obj: Any) -> Any: """Fallback method for serializing TaskRequests""" if isinstance(obj, client.DeviceRef): return obj.name - raise ValueError() + raise PydanticSerializationError(f"Object of type {type(obj)} not serializable") diff --git a/tests/unit_tests/client/test_rest.py b/tests/unit_tests/client/test_rest.py index fc1fff87e9..c3386ad552 100644 --- a/tests/unit_tests/client/test_rest.py +++ b/tests/unit_tests/client/test_rest.py @@ -8,6 +8,7 @@ import requests import responses from packaging.version import Version +from pydantic_core import PydanticSerializationError from responses import DELETE, GET, PUT, matchers from blueapi import __version__ @@ -102,6 +103,22 @@ def test_create_task_serialization(): ) +def test_create_task_serialization_error(): + class CustomType: + pass + + rest = Mock(spec=BlueapiRestClient) + request = TaskRequest( + name="demo", + instrument_session="cm12345-1", + params={"devices": [CustomType()]}, + ) + + with pytest.raises(PydanticSerializationError, match="not serializable"): + BlueapiRestClient.create_task(rest, request) + rest._request_and_deserialize.assert_not_called() + + @pytest.mark.parametrize( "code,content,expected_exception", [