diff --git a/helm/blueapi/config_schema.json b/helm/blueapi/config_schema.json index d54bc5357..363c1eb6f 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" + }, + "check_connected": { + "default": false, + "description": "If true, all devices must be successfully connected at startup.", + "title": "Check Connected", + "type": "boolean" } }, "required": [ diff --git a/helm/blueapi/values.schema.json b/helm/blueapi/values.schema.json index 1578c0dad..c41276037 100644 --- a/helm/blueapi/values.schema.json +++ b/helm/blueapi/values.schema.json @@ -510,6 +510,12 @@ "module" ], "properties": { + "check_connected": { + "title": "Check 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..9822d996d 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" ) + check_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..5ac85881e 100644 --- a/src/blueapi/core/context.py +++ b/src/blueapi/core/context.py @@ -214,13 +214,20 @@ 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, 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" ) - self.with_device_manager(manager, mock) + _, 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 2f31784aa..96525fe76 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,27 @@ 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( + RuntimeError, match="Errors occurred while building/connecting.*" + ): + empty_context.with_config( + EnvironmentConfig( + sources=[DeviceManagerSource(module="foo.bar", check_connected=True)] + ) + ) + + +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