diff --git a/helm/blueapi/config_schema.json b/helm/blueapi/config_schema.json index 58f06258df..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", @@ -80,7 +115,22 @@ } ], "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" @@ -295,7 +345,13 @@ "additionalProperties": false, "properties": { "kind": { - "$ref": "#/$defs/SourceKind" + "description": "The type of source module", + "enum": [ + "planFunctions", + "deviceFunctions" + ], + "title": "Kind", + "type": "string" }, "module": { "anyOf": [ @@ -307,6 +363,7 @@ "type": "string" } ], + "description": "Module name or path to the module to be imported", "title": "Module" } }, @@ -317,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 8b80efee86..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", @@ -585,7 +618,14 @@ ], "type": "array", "items": { - "$ref": "#/$defs/Source" + "oneOf": [ + { + "$ref": "#/$defs/Source" + }, + { + "$ref": "#/$defs/DodalSource" + } + ] } } }, @@ -782,10 +822,17 @@ ], "properties": { "kind": { - "$ref": "#/$defs/SourceKind" + "title": "Kind", + "description": "The type of source module", + "type": "string", + "enum": [ + "planFunctions", + "deviceFunctions" + ] }, "module": { "title": "Module", + "description": "Module name or path to the module to be imported", "anyOf": [ { "type": "string" @@ -798,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 a4e01bd1ad..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,8 +50,19 @@ class SourceKind(str, Enum): class Source(BlueapiBaseModel): - kind: SourceKind - module: Path | str + 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 + ) class TcpUrl(AnyUrl): @@ -101,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 31764b286f..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,7 +173,8 @@ 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) + 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 3a4166d5b8..bbd40d0e62 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 ( @@ -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 @@ -393,6 +399,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=[ + DodalSource( + 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 diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index 617df27c20..b2a0abd5ce 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,12 @@ def test_config_yaml_parsed(temp_yaml_config_file): "instrument": "p01", }, "sources": [ - {"kind": "dodal", "module": "dodal.adsim"}, + {"kind": "dodal", "module": "dodal.adsim", "mock": True}, {"kind": "planFunctions", "module": "dodal.plans"}, - {"kind": "planFunctions", "module": "dodal.plan_stubs.wrapped"}, + { + "kind": "planFunctions", + "module": "dodal.plan_stubs.wrapped", + }, ], }, "api": { @@ -344,9 +351,12 @@ def test_config_yaml_parsed(temp_yaml_config_file): "auth_token_path": None, "env": { "sources": [ - {"kind": "dodal", "module": "dodal.adsim"}, + {"kind": "dodal", "module": "dodal.adsim", "mock": False}, {"kind": "planFunctions", "module": "dodal.plans"}, - {"kind": "planFunctions", "module": "dodal.plan_stubs.wrapped"}, + { + "kind": "planFunctions", + "module": "dodal.plan_stubs.wrapped", + }, ], "events": {"broadcast_status_events": True}, "metadata": {