From 9fc90fb8f7af417631e59159d88ec1fe8b3a00dd Mon Sep 17 00:00:00 2001 From: Robert Tuck Date: Fri, 17 Oct 2025 11:49:27 +0000 Subject: [PATCH 1/5] Add option to connect as mock devices --- src/blueapi/config.py | 9 +++++++-- src/blueapi/core/context.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/blueapi/config.py b/src/blueapi/config.py index a4e01bd1ad..ed5b1136dd 100644 --- a/src/blueapi/config.py +++ b/src/blueapi/config.py @@ -50,8 +50,13 @@ class SourceKind(str, Enum): class Source(BlueapiBaseModel): - kind: SourceKind - module: Path | str + kind: SourceKind = Field(description="The type of source module") + module: Path | str = Field( + description="Module name or path to the module to be imported" + ) + mock: bool = Field( + description="If true, ophyd_async device connections are mocked", default=False + ) class TcpUrl(AnyUrl): diff --git a/src/blueapi/core/context.py b/src/blueapi/core/context.py index 31764b286f..2e884da3ad 100644 --- a/src/blueapi/core/context.py +++ b/src/blueapi/core/context.py @@ -173,7 +173,7 @@ def with_config(self, config: EnvironmentConfig) -> None: elif source.kind is SourceKind.DEVICE_FUNCTIONS: self.with_device_module(mod) elif source.kind is SourceKind.DODAL: - self.with_dodal_module(mod) + self.with_dodal_module(mod, mock=source.mock) def with_plan_module(self, module: ModuleType) -> None: """ From a8bf3cd984aa07f877b94b4851fa1cf2201fcaaa Mon Sep 17 00:00:00 2001 From: Robert Tuck Date: Mon, 20 Oct 2025 10:33:25 +0000 Subject: [PATCH 2/5] Add unit test for mock config option --- tests/unit_tests/core/test_context.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/core/test_context.py b/tests/unit_tests/core/test_context.py index 3a4166d5b8..46ef75c13b 100644 --- a/tests/unit_tests/core/test_context.py +++ b/tests/unit_tests/core/test_context.py @@ -4,7 +4,7 @@ from pathlib import Path from types import NoneType from typing import Generic, TypeVar, Union -from unittest.mock import patch +from unittest.mock import ANY, patch import pytest from bluesky.protocols import ( @@ -393,6 +393,30 @@ def test_add_metadata_with_config( assert md in empty_context.run_engine.md.items() +@pytest.mark.parametrize("mock", [True, False]) +def test_with_config_passes_mock_to_with_dodal_module( + empty_context: BlueskyContext, + mock: bool, +): + with patch.object(empty_context, "with_dodal_module") as mock_with_dodal_module: + empty_context.with_config( + EnvironmentConfig( + sources=[ + Source( + kind=SourceKind.DODAL, + module="tests.unit_tests.core.fake_device_module", + mock=mock, + ), + Source( + kind=SourceKind.PLAN_FUNCTIONS, + module="tests.unit_tests.core.fake_plan_module", + ), + ] + ) + ) + mock_with_dodal_module.assert_called_once_with(ANY, mock=mock) + + def test_function_spec(empty_context: BlueskyContext): spec = empty_context._type_spec_for_function(has_some_params) assert spec["foo"][0] is int From d81fe030e8ce7a0b5fe60ce1ff4cb9d879430004 Mon Sep 17 00:00:00 2001 From: Robert Tuck Date: Tue, 21 Oct 2025 09:53:38 +0000 Subject: [PATCH 3/5] Update the config schema jsons --- helm/blueapi/config_schema.json | 16 +++++++++++++--- helm/blueapi/values.schema.json | 10 ++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/helm/blueapi/config_schema.json b/helm/blueapi/config_schema.json index 58f06258df..8ddaa0cfa9 100644 --- a/helm/blueapi/config_schema.json +++ b/helm/blueapi/config_schema.json @@ -72,11 +72,13 @@ "default": [ { "kind": "planFunctions", - "module": "dodal.plans" + "module": "dodal.plans", + "mock": false }, { "kind": "planFunctions", - "module": "dodal.plan_stubs.wrapped" + "module": "dodal.plan_stubs.wrapped", + "mock": false } ], "items": { @@ -295,7 +297,8 @@ "additionalProperties": false, "properties": { "kind": { - "$ref": "#/$defs/SourceKind" + "$ref": "#/$defs/SourceKind", + "description": "The type of source module" }, "module": { "anyOf": [ @@ -307,7 +310,14 @@ "type": "string" } ], + "description": "Module name or path to the module to be imported", "title": "Module" + }, + "mock": { + "default": false, + "description": "If true, ophyd_async device connections are mocked", + "title": "Mock", + "type": "boolean" } }, "required": [ diff --git a/helm/blueapi/values.schema.json b/helm/blueapi/values.schema.json index 8b80efee86..3ca5ba4f33 100644 --- a/helm/blueapi/values.schema.json +++ b/helm/blueapi/values.schema.json @@ -576,10 +576,12 @@ "default": [ { "kind": "planFunctions", + "mock": false, "module": "dodal.plans" }, { "kind": "planFunctions", + "mock": false, "module": "dodal.plan_stubs.wrapped" } ], @@ -782,10 +784,18 @@ ], "properties": { "kind": { + "description": "The type of source module", "$ref": "#/$defs/SourceKind" }, + "mock": { + "title": "Mock", + "description": "If true, ophyd_async device connections are mocked", + "default": false, + "type": "boolean" + }, "module": { "title": "Module", + "description": "Module name or path to the module to be imported", "anyOf": [ { "type": "string" From 35f50f3bcd516af8435fcc41b1ec4ac933b50cfb Mon Sep 17 00:00:00 2001 From: Robert Tuck Date: Tue, 21 Oct 2025 10:26:27 +0000 Subject: [PATCH 4/5] Fix unit tests --- tests/unit_tests/test_config.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index 617df27c20..b9de8a9479 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -189,6 +189,10 @@ def is_subset(subset: Mapping[str, Any], superset: Mapping[str, Any]) -> bool: if isinstance(value, dict) and isinstance(superset_value, dict): if not is_subset(value, superset_value): return False + elif isinstance(value, list) and isinstance(superset_value, list): + for sub_val, sup_val in zip(value, superset_value, strict=True): + if not is_subset(sub_val, sup_val): + return False # Check equality for non-dict values, ignoring None in superset elif superset_value is not None and value != superset_value: return False @@ -296,9 +300,13 @@ def test_config_yaml_parsed(temp_yaml_config_file): "instrument": "p01", }, "sources": [ - {"kind": "dodal", "module": "dodal.adsim"}, - {"kind": "planFunctions", "module": "dodal.plans"}, - {"kind": "planFunctions", "module": "dodal.plan_stubs.wrapped"}, + {"kind": "dodal", "module": "dodal.adsim", "mock": True}, + {"kind": "planFunctions", "module": "dodal.plans", "mock": False}, + { + "kind": "planFunctions", + "module": "dodal.plan_stubs.wrapped", + "mock": False, + }, ], }, "api": { @@ -344,9 +352,13 @@ def test_config_yaml_parsed(temp_yaml_config_file): "auth_token_path": None, "env": { "sources": [ - {"kind": "dodal", "module": "dodal.adsim"}, - {"kind": "planFunctions", "module": "dodal.plans"}, - {"kind": "planFunctions", "module": "dodal.plan_stubs.wrapped"}, + {"kind": "dodal", "module": "dodal.adsim", "mock": False}, + {"kind": "planFunctions", "module": "dodal.plans", "mock": False}, + { + "kind": "planFunctions", + "module": "dodal.plan_stubs.wrapped", + "mock": False, + }, ], "events": {"broadcast_status_events": True}, "metadata": { From 722df18e7424e2a69a627c103ff5987b413c51db Mon Sep 17 00:00:00 2001 From: Robert Tuck Date: Wed, 29 Oct 2025 10:11:26 +0000 Subject: [PATCH 5/5] Change EnvironmentConfig to accept a discriminated type union of Source objects for sources --- helm/blueapi/config_schema.json | 82 ++++++++++++++++++++------- helm/blueapi/values.schema.json | 66 ++++++++++++++------- src/blueapi/config.py | 12 +++- src/blueapi/core/context.py | 3 +- tests/unit_tests/core/test_context.py | 10 +++- tests/unit_tests/test_config.py | 6 +- 6 files changed, 128 insertions(+), 51 deletions(-) diff --git a/helm/blueapi/config_schema.json b/helm/blueapi/config_schema.json index 8ddaa0cfa9..24515bd718 100644 --- a/helm/blueapi/config_schema.json +++ b/helm/blueapi/config_schema.json @@ -64,6 +64,41 @@ "title": "CORSConfig", "type": "object" }, + "DodalSource": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "dodal", + "default": "dodal", + "title": "Kind", + "type": "string" + }, + "module": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "string" + } + ], + "description": "Module name or path to the module to be imported", + "title": "Module" + }, + "mock": { + "default": false, + "description": "If true, ophyd_async device connections are mocked", + "title": "Mock", + "type": "boolean" + } + }, + "required": [ + "module" + ], + "title": "DodalSource", + "type": "object" + }, "EnvironmentConfig": { "additionalProperties": false, "description": "Config for the RunEngine environment", @@ -72,17 +107,30 @@ "default": [ { "kind": "planFunctions", - "module": "dodal.plans", - "mock": false + "module": "dodal.plans" }, { "kind": "planFunctions", - "module": "dodal.plan_stubs.wrapped", - "mock": false + "module": "dodal.plan_stubs.wrapped" } ], "items": { - "$ref": "#/$defs/Source" + "discriminator": { + "mapping": { + "deviceFunctions": "#/$defs/Source", + "dodal": "#/$defs/DodalSource", + "planFunctions": "#/$defs/Source" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/Source" + }, + { + "$ref": "#/$defs/DodalSource" + } + ] }, "title": "Sources", "type": "array" @@ -297,8 +345,13 @@ "additionalProperties": false, "properties": { "kind": { - "$ref": "#/$defs/SourceKind", - "description": "The type of source module" + "description": "The type of source module", + "enum": [ + "planFunctions", + "deviceFunctions" + ], + "title": "Kind", + "type": "string" }, "module": { "anyOf": [ @@ -312,12 +365,6 @@ ], "description": "Module name or path to the module to be imported", "title": "Module" - }, - "mock": { - "default": false, - "description": "If true, ophyd_async device connections are mocked", - "title": "Mock", - "type": "boolean" } }, "required": [ @@ -327,15 +374,6 @@ "title": "Source", "type": "object" }, - "SourceKind": { - "enum": [ - "planFunctions", - "deviceFunctions", - "dodal" - ], - "title": "SourceKind", - "type": "string" - }, "StompConfig": { "additionalProperties": false, "description": "Config for connecting to stomp broker", diff --git a/helm/blueapi/values.schema.json b/helm/blueapi/values.schema.json index 3ca5ba4f33..dcdf1510b2 100644 --- a/helm/blueapi/values.schema.json +++ b/helm/blueapi/values.schema.json @@ -553,6 +553,39 @@ }, "additionalProperties": false }, + "DodalSource": { + "title": "DodalSource", + "type": "object", + "required": [ + "module" + ], + "properties": { + "kind": { + "title": "Kind", + "default": "dodal", + "const": "dodal" + }, + "mock": { + "title": "Mock", + "description": "If true, ophyd_async device connections are mocked", + "default": false, + "type": "boolean" + }, + "module": { + "title": "Module", + "description": "Module name or path to the module to be imported", + "anyOf": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + }, + "additionalProperties": false + }, "EnvironmentConfig": { "title": "EnvironmentConfig", "description": "Config for the RunEngine environment", @@ -576,18 +609,23 @@ "default": [ { "kind": "planFunctions", - "mock": false, "module": "dodal.plans" }, { "kind": "planFunctions", - "mock": false, "module": "dodal.plan_stubs.wrapped" } ], "type": "array", "items": { - "$ref": "#/$defs/Source" + "oneOf": [ + { + "$ref": "#/$defs/Source" + }, + { + "$ref": "#/$defs/DodalSource" + } + ] } } }, @@ -784,14 +822,13 @@ ], "properties": { "kind": { + "title": "Kind", "description": "The type of source module", - "$ref": "#/$defs/SourceKind" - }, - "mock": { - "title": "Mock", - "description": "If true, ophyd_async device connections are mocked", - "default": false, - "type": "boolean" + "type": "string", + "enum": [ + "planFunctions", + "deviceFunctions" + ] }, "module": { "title": "Module", @@ -808,15 +845,6 @@ }, "additionalProperties": false }, - "SourceKind": { - "title": "SourceKind", - "type": "string", - "enum": [ - "planFunctions", - "deviceFunctions", - "dodal" - ] - }, "StompConfig": { "title": "StompConfig", "description": "Config for connecting to stomp broker", diff --git a/src/blueapi/config.py b/src/blueapi/config.py index ed5b1136dd..dfa82dd837 100644 --- a/src/blueapi/config.py +++ b/src/blueapi/config.py @@ -6,7 +6,7 @@ from functools import cached_property from pathlib import Path from string import Template -from typing import Any, Generic, Literal, TypeVar, cast +from typing import Annotated, Any, Generic, Literal, TypeVar, cast import requests import yaml @@ -50,10 +50,16 @@ class SourceKind(str, Enum): class Source(BlueapiBaseModel): - kind: SourceKind = Field(description="The type of source module") + kind: Literal[SourceKind.PLAN_FUNCTIONS, SourceKind.DEVICE_FUNCTIONS] = Field( + description="The type of source module" + ) module: Path | str = Field( description="Module name or path to the module to be imported" ) + + +class DodalSource(Source): + kind: Literal[SourceKind.DODAL] = SourceKind.DODAL # pyright: ignore [reportIncompatibleVariableOverride] mock: bool = Field( description="If true, ophyd_async device connections are mocked", default=False ) @@ -106,7 +112,7 @@ class EnvironmentConfig(BlueapiBaseModel): Config for the RunEngine environment """ - sources: list[Source] = [ + sources: list[Annotated[Source | DodalSource, Field(discriminator="kind")]] = [ Source(kind=SourceKind.PLAN_FUNCTIONS, module="dodal.plans"), Source(kind=SourceKind.PLAN_FUNCTIONS, module="dodal.plan_stubs.wrapped"), ] diff --git a/src/blueapi/core/context.py b/src/blueapi/core/context.py index 2e884da3ad..caa87ab934 100644 --- a/src/blueapi/core/context.py +++ b/src/blueapi/core/context.py @@ -18,7 +18,7 @@ from blueapi import utils from blueapi.client.numtracker import NumtrackerClient -from blueapi.config import ApplicationConfig, EnvironmentConfig, SourceKind +from blueapi.config import ApplicationConfig, DodalSource, EnvironmentConfig, SourceKind from blueapi.utils import ( BlueapiPlanModelConfig, is_function_sourced_from_module, @@ -173,6 +173,7 @@ def with_config(self, config: EnvironmentConfig) -> None: elif source.kind is SourceKind.DEVICE_FUNCTIONS: self.with_device_module(mod) elif source.kind is SourceKind.DODAL: + assert isinstance(source, DodalSource) self.with_dodal_module(mod, mock=source.mock) 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 46ef75c13b..bbd40d0e62 100644 --- a/tests/unit_tests/core/test_context.py +++ b/tests/unit_tests/core/test_context.py @@ -30,7 +30,13 @@ from pydantic.json_schema import SkipJsonSchema from pytest import LogCaptureFixture -from blueapi.config import EnvironmentConfig, MetadataConfig, Source, SourceKind +from blueapi.config import ( + DodalSource, + EnvironmentConfig, + MetadataConfig, + Source, + SourceKind, +) from blueapi.core import BlueskyContext, is_bluesky_compatible_device from blueapi.core.context import DefaultFactory, generic_bounds, qualified_name from blueapi.utils.connect_devices import _establish_device_connections @@ -402,7 +408,7 @@ def test_with_config_passes_mock_to_with_dodal_module( empty_context.with_config( EnvironmentConfig( sources=[ - Source( + DodalSource( kind=SourceKind.DODAL, module="tests.unit_tests.core.fake_device_module", mock=mock, diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index b9de8a9479..b2a0abd5ce 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -301,11 +301,10 @@ def test_config_yaml_parsed(temp_yaml_config_file): }, "sources": [ {"kind": "dodal", "module": "dodal.adsim", "mock": True}, - {"kind": "planFunctions", "module": "dodal.plans", "mock": False}, + {"kind": "planFunctions", "module": "dodal.plans"}, { "kind": "planFunctions", "module": "dodal.plan_stubs.wrapped", - "mock": False, }, ], }, @@ -353,11 +352,10 @@ def test_config_yaml_parsed(temp_yaml_config_file): "env": { "sources": [ {"kind": "dodal", "module": "dodal.adsim", "mock": False}, - {"kind": "planFunctions", "module": "dodal.plans", "mock": False}, + {"kind": "planFunctions", "module": "dodal.plans"}, { "kind": "planFunctions", "module": "dodal.plan_stubs.wrapped", - "mock": False, }, ], "events": {"broadcast_status_events": True},