Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Config setup docs - through tests #634

Merged
merged 12 commits into from
Oct 30, 2024
3 changes: 2 additions & 1 deletion src/blueapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ def load(self) -> C:
try:
return self._adapter.validate_python(self._values)
except ValidationError as exc:
error_details = "\n".join(str(e) for e in exc.errors())
raise InvalidConfigError(
"Something is wrong with the configuration file: \n"
f"Something is wrong with the configuration file: \n {error_details}"
) from exc
188 changes: 186 additions & 2 deletions tests/unit_tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import json
import os
import tempfile
from collections.abc import Generator, Mapping
from pathlib import Path
from typing import Any
from unittest import mock

import pytest
import yaml
from bluesky_stomp.models import BasicAuthentication
from pydantic import BaseModel, Field

from blueapi.config import ConfigLoader
from blueapi.config import ApplicationConfig, ConfigLoader
from blueapi.utils import InvalidConfigError


Expand Down Expand Up @@ -146,4 +150,184 @@ def test_auth_from_env_throws_when_not_available():
with pytest.raises(KeyError):
BasicAuthentication(username="${BAZ}", password="baz")
with pytest.raises(KeyError):
BasicAuthentication(username="${baz}", password="baz")
BasicAuthentication(username="${baz}", passcode="baz")


def is_subset(subset: Mapping[str, Any], superset: Mapping[str, Any]) -> bool:
"""
Recursively check if 'subset' is contained within 'superset',
skipping nullable (None) fields in the superset.
"""
for key, value in subset.items():
if key not in superset:
return False
superset_value = superset[key]

# If both values are dictionaries, recurse
if isinstance(value, dict) and isinstance(superset_value, dict):
if not is_subset(value, superset_value):
return False
# Check equality for non-dict values, ignoring None in superset
elif superset_value is not None and value != superset_value:
return False
return True


# Parameterize the fixture to accept different config examples
@pytest.fixture
def temp_yaml_config_file(
request: pytest.FixtureRequest,
) -> Generator[tuple[Path, dict[str, Any]]]:
# Use the provided config data from test parameters
config_data = request.param

# Create a temporary YAML file with the configuration
with tempfile.NamedTemporaryFile(
suffix=".yaml", mode="w", delete=False
) as temp_yaml_file:
yaml.dump(config_data, temp_yaml_file)
temp_yaml_file_path = temp_yaml_file.name

# Provide the path and the config data
yield Path(temp_yaml_file_path), config_data

# Cleanup after test execution
os.remove(temp_yaml_file_path)


# Parameterized test to run with different configurations
@pytest.mark.parametrize(
"temp_yaml_config_file",
[
# Different configuration examples passed to the fixture
{
"env": {
"sources": [
{"kind": "dodal", "module": "dodal.adsim"},
{"kind": "planFunctions", "module": "dls_bluesky_core.plans"},
{"kind": "planFunctions", "module": "dls_bluesky_core.stubs"},
],
},
"api": {"host": "0.0.0.0", "port": 8000},
},
{
"stomp": None,
"env": {
"sources": [
{"kind": "dodal", "module": "dodal.adsim"},
{"kind": "planFunctions", "module": "dls_bluesky_core.plans"},
{"kind": "planFunctions", "module": "dls_bluesky_core.stubs"},
],
"events": {"broadcast_status_events": True},
},
"logging": {"level": "INFO"},
"api": {"host": "0.0.0.0", "port": 8000, "protocol": "http"},
"scratch": None,
},
],
indirect=True,
)
def test_config_yaml_parsed(temp_yaml_config_file):
stan-dot marked this conversation as resolved.
Show resolved Hide resolved
temp_yaml_file_path, config_data = temp_yaml_config_file
callumforrester marked this conversation as resolved.
Show resolved Hide resolved

# Initialize loader and load config from the YAML file
loader = ConfigLoader(ApplicationConfig)
loader.use_values_from_yaml(temp_yaml_file_path)
loaded_config = loader.load()

# Parse the loaded config JSON into a dictionary
target_dict_json = json.loads(loaded_config.model_dump_json())

# Assert that config_data is a subset of target_dict_json
assert is_subset(config_data, target_dict_json)


@pytest.mark.parametrize(
"temp_yaml_config_file",
[
# Different configuration examples passed to the fixture
{
"stomp": {
"host": "localhost",
"port": 61613,
"auth": {"username": "guest", "password": "guest"},
},
"env": {
"events": {
"broadcast_status_events": True,
},
"sources": [
{"kind": "dodal", "module": "dodal.adsim"},
{"kind": "planFunctions", "module": "dls_bluesky_core.plans"},
{"kind": "planFunctions", "module": "dls_bluesky_core.stubs"},
],
},
"api": {
"host": "0.0.0.0",
"port": 8000,
"protocol": "http",
},
"logging": {"level": "INFO"},
"scratch": {
"root": "/tmp/scratch/blueapi",
"repositories": [
{
"name": "dodal",
"remote_url": "https://github.com/DiamondLightSource/dodal.git",
}
],
},
},
{
"stomp": {
"host": "https://rabbitmq.diamond.ac.uk",
"port": 61613,
"auth": {"username": "guest", "password": "guest"},
},
"env": {
"sources": [
{"kind": "dodal", "module": "dodal.adsim"},
{"kind": "planFunctions", "module": "dls_bluesky_core.plans"},
{"kind": "planFunctions", "module": "dls_bluesky_core.stubs"},
],
"events": {"broadcast_status_events": True},
},
"logging": {"level": "INFO"},
"api": {"host": "0.0.0.0", "port": 8001, "protocol": "http"},
"scratch": {
"root": "/tmp/scratch/blueapi",
"repositories": [
{
"name": "dodal",
"remote_url": "https://github.com/DiamondLightSource/dodal.git",
}
],
},
},
],
indirect=True,
)
def test_config_yaml_parsed_complete(temp_yaml_config_file: dict):
temp_yaml_file_path, config_data = temp_yaml_config_file

# Initialize loader and load config from the YAML file
loader = ConfigLoader(ApplicationConfig)
loader.use_values_from_yaml(temp_yaml_file_path)
loaded_config = loader.load()

# Parse the loaded config JSON into a dictionary
target_dict_json = json.loads(loaded_config.model_dump_json())

assert loaded_config.stomp is not None
assert loaded_config.stomp.auth is not None
assert (
loaded_config.stomp.auth.password.get_secret_value()
== config_data["stomp"]["auth"]["password"] # noqa: E501
)
# Remove the password field to not compare it again in the full dict comparison
del target_dict_json["stomp"]["auth"]["password"]
del config_data["stomp"]["auth"]["password"] # noqa: E501
# Assert that the remaining config data is identical
assert (
target_dict_json == config_data
), f"Expected config {config_data}, but got {target_dict_json}"
Loading