From 5d50efeea0940883f2fca53ff7c4502ed5f2d8e8 Mon Sep 17 00:00:00 2001 From: Robert Tuck Date: Tue, 6 Jan 2026 11:36:33 +0000 Subject: [PATCH 1/2] Implement ensure_connected configuration option DeviceManagerSource now accepts ensure_connected boolean option. When enabled any failure to build or connect a device will result in an exception. --- helm/blueapi/config_schema.json | 6 +++++ helm/blueapi/values.schema.json | 6 +++++ src/blueapi/config.py | 4 +++ src/blueapi/core/context.py | 12 +++++++-- tests/unit_tests/core/test_context.py | 37 +++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/helm/blueapi/config_schema.json b/helm/blueapi/config_schema.json index d54bc5357..857393157 100644 --- a/helm/blueapi/config_schema.json +++ b/helm/blueapi/config_schema.json @@ -91,6 +91,12 @@ "description": "Name of the device manager in the module", "title": "Name", "type": "string" + }, + "ensure_connected": { + "default": false, + "description": "If true, all devices must be successfully connected at startup.", + "title": "Ensure Connected", + "type": "boolean" } }, "required": [ diff --git a/helm/blueapi/values.schema.json b/helm/blueapi/values.schema.json index 1578c0dad..95b5193ad 100644 --- a/helm/blueapi/values.schema.json +++ b/helm/blueapi/values.schema.json @@ -510,6 +510,12 @@ "module" ], "properties": { + "ensure_connected": { + "title": "Ensure Connected", + "description": "If true, all devices must be successfully connected at startup.", + "default": false, + "type": "boolean" + }, "kind": { "title": "Kind", "default": "deviceManager", diff --git a/src/blueapi/config.py b/src/blueapi/config.py index eb1b3122e..168a3db74 100644 --- a/src/blueapi/config.py +++ b/src/blueapi/config.py @@ -83,6 +83,10 @@ class DeviceManagerSource(Source): name: str = Field( default="devices", description="Name of the device manager in the module" ) + ensure_connected: bool = Field( + default=False, + description="If true, all devices must be successfully connected at startup.", + ) class TcpUrl(AnyUrl): diff --git a/src/blueapi/core/context.py b/src/blueapi/core/context.py index 681660076..ca5f7dbc5 100644 --- a/src/blueapi/core/context.py +++ b/src/blueapi/core/context.py @@ -214,13 +214,21 @@ def with_config(self, config: EnvironmentConfig) -> None: self.with_device_module(mod) case DodalSource(mock=mock): self.with_dodal_module(mod, mock=mock) - case DeviceManagerSource(mock=mock, name=name): + case DeviceManagerSource( + mock=mock, name=name, ensure_connected=ensure_connected + ): manager = getattr(mod, name) if not isinstance(manager, DeviceManager): raise ValueError( f"{name} in module {mod} is not a device manager" ) - self.with_device_manager(manager, mock) + device_map, error_map = self.with_device_manager(manager, mock) + if ensure_connected and error_map: + raise ExceptionGroup( + "Errors occurred while connecting the following devices: " + f"{', '.join(error_map.keys())}", + list(error_map.values()), + ) def with_plan_module(self, module: ModuleType) -> None: """ diff --git a/tests/unit_tests/core/test_context.py b/tests/unit_tests/core/test_context.py index 2f31784aa..aa7545ae6 100644 --- a/tests/unit_tests/core/test_context.py +++ b/tests/unit_tests/core/test_context.py @@ -20,6 +20,7 @@ from dodal.common import PlanGenerator, inject from ophyd import Device from ophyd_async.core import ( + NotConnectedError, PathProvider, StandardDetector, StaticPathProvider, @@ -153,6 +154,20 @@ def devicey_context(sim_motor: Motor, sim_detector: StandardDetector) -> Bluesky return ctx +@pytest.fixture +def beamline_with_connection_and_build_errors(): + stm = StaticDeviceManager( + devices={}, + build_errors={"foo": RuntimeError("Simulated Build Error")}, + connection_errors={"bar": NotConnectedError("Simulated Connection Error")}, + ) + dev_mod = Mock(spec=ModuleType) + dev_mod.devices = stm + with patch("blueapi.core.context.import_module") as imp_mod: + imp_mod.side_effect = lambda mod: dev_mod if mod == "foo.bar" else None + yield + + class SomeConfigurable: def read_configuration(self) -> SyncOrAsync[dict[str, Reading]]: return {} @@ -425,6 +440,28 @@ def test_with_config_passes_mock_to_with_dodal_module( mock_with_dodal_module.assert_called_once_with(ANY, mock=mock) +def test_with_config_raises_exception_group_on_connection_errors_when_ensure_connected( + empty_context: BlueskyContext, beamline_with_connection_and_build_errors: None +): + with pytest.raises(ExceptionGroup, match="Errors occurred while connecting.*") as e: + empty_context.with_config( + EnvironmentConfig( + sources=[DeviceManagerSource(module="foo.bar", ensure_connected=True)] + ) + ) + + assert e.value.exceptions[0].args[0] == "Simulated Build Error" + assert e.value.exceptions[1].args[0] == "Simulated Connection Error" + + +def test_with_config_ignores_build_connect_exceptions_by_default( + empty_context: BlueskyContext, beamline_with_connection_and_build_errors: None +): + empty_context.with_config( + EnvironmentConfig(sources=[DeviceManagerSource(module="foo.bar")]) + ) + + def test_function_spec(empty_context: BlueskyContext): spec = empty_context._type_spec_for_function(has_some_params) assert spec["foo"][0] is int From 65fefad0dc72e2536abc25ffe1d214787eb404aa Mon Sep 17 00:00:00 2001 From: Robert Tuck Date: Fri, 9 Jan 2026 14:00:28 +0000 Subject: [PATCH 2/2] Minor changes from PR comments Rename ensure_connected to check_connected --- helm/blueapi/config_schema.json | 4 ++-- helm/blueapi/values.schema.json | 4 ++-- src/blueapi/config.py | 2 +- src/blueapi/core/context.py | 13 ++++++------- tests/unit_tests/core/test_context.py | 9 ++++----- 5 files changed, 15 insertions(+), 17 deletions(-) diff --git a/helm/blueapi/config_schema.json b/helm/blueapi/config_schema.json index 857393157..363c1eb6f 100644 --- a/helm/blueapi/config_schema.json +++ b/helm/blueapi/config_schema.json @@ -92,10 +92,10 @@ "title": "Name", "type": "string" }, - "ensure_connected": { + "check_connected": { "default": false, "description": "If true, all devices must be successfully connected at startup.", - "title": "Ensure Connected", + "title": "Check Connected", "type": "boolean" } }, diff --git a/helm/blueapi/values.schema.json b/helm/blueapi/values.schema.json index 95b5193ad..c41276037 100644 --- a/helm/blueapi/values.schema.json +++ b/helm/blueapi/values.schema.json @@ -510,8 +510,8 @@ "module" ], "properties": { - "ensure_connected": { - "title": "Ensure Connected", + "check_connected": { + "title": "Check Connected", "description": "If true, all devices must be successfully connected at startup.", "default": false, "type": "boolean" diff --git a/src/blueapi/config.py b/src/blueapi/config.py index 168a3db74..9822d996d 100644 --- a/src/blueapi/config.py +++ b/src/blueapi/config.py @@ -83,7 +83,7 @@ class DeviceManagerSource(Source): name: str = Field( default="devices", description="Name of the device manager in the module" ) - ensure_connected: bool = Field( + check_connected: bool = Field( default=False, description="If true, all devices must be successfully connected at startup.", ) diff --git a/src/blueapi/core/context.py b/src/blueapi/core/context.py index ca5f7dbc5..5ac85881e 100644 --- a/src/blueapi/core/context.py +++ b/src/blueapi/core/context.py @@ -215,19 +215,18 @@ def with_config(self, config: EnvironmentConfig) -> None: case DodalSource(mock=mock): self.with_dodal_module(mod, mock=mock) case DeviceManagerSource( - mock=mock, name=name, ensure_connected=ensure_connected + mock=mock, name=name, check_connected=check_connected ): manager = getattr(mod, name) if not isinstance(manager, DeviceManager): raise ValueError( f"{name} in module {mod} is not a device manager" ) - device_map, error_map = self.with_device_manager(manager, mock) - if ensure_connected and error_map: - raise ExceptionGroup( - "Errors occurred while connecting the following devices: " - f"{', '.join(error_map.keys())}", - list(error_map.values()), + _, error_map = self.with_device_manager(manager, mock) + if check_connected and error_map: + raise RuntimeError( + "Errors occurred while building/connecting the following " + f"devices: {', '.join(error_map)}", ) def with_plan_module(self, module: ModuleType) -> None: diff --git a/tests/unit_tests/core/test_context.py b/tests/unit_tests/core/test_context.py index aa7545ae6..96525fe76 100644 --- a/tests/unit_tests/core/test_context.py +++ b/tests/unit_tests/core/test_context.py @@ -443,16 +443,15 @@ def test_with_config_passes_mock_to_with_dodal_module( def test_with_config_raises_exception_group_on_connection_errors_when_ensure_connected( empty_context: BlueskyContext, beamline_with_connection_and_build_errors: None ): - with pytest.raises(ExceptionGroup, match="Errors occurred while connecting.*") as e: + with pytest.raises( + RuntimeError, match="Errors occurred while building/connecting.*" + ): empty_context.with_config( EnvironmentConfig( - sources=[DeviceManagerSource(module="foo.bar", ensure_connected=True)] + sources=[DeviceManagerSource(module="foo.bar", check_connected=True)] ) ) - assert e.value.exceptions[0].args[0] == "Simulated Build Error" - assert e.value.exceptions[1].args[0] == "Simulated Connection Error" - def test_with_config_ignores_build_connect_exceptions_by_default( empty_context: BlueskyContext, beamline_with_connection_and_build_errors: None