Merge branch 'patch-config' into 'v3'

Specify config patches rather than full config files in settings

See merge request openflexure/openflexure-microscope-server!420
This commit is contained in:
Julian Stirling 2025-10-27 17:29:38 +00:00
commit 21f0c81a88
3 changed files with 280 additions and 1 deletions

View file

@ -6,10 +6,13 @@ from typing import Optional, Callable, Any
from functools import wraps from functools import wraps
from copy import copy from copy import copy
import logging import logging
from argparse import Namespace
import labthings_fastapi as lt import labthings_fastapi as lt
import uvicorn import uvicorn
from uvicorn.main import Server from uvicorn.main import Server
from openflexure_microscope_server.utilities import load_patched_config
from .serve_static_files import add_static_files from .serve_static_files import add_static_files
from .legacy_api import add_v2_endpoints from .legacy_api import add_v2_endpoints
from ..logging import configure_logging, retrieve_log, retrieve_log_from_file from ..logging import configure_logging, retrieve_log, retrieve_log_from_file
@ -81,7 +84,7 @@ def serve_from_cli(argv: Optional[list[str]] = None) -> None:
config = None config = None
server = None server = None
try: try:
config = lt.cli.config_from_args(args) config = _full_config_from_args(args)
log_folder = config.get("log_folder", "./openflexure/logs") log_folder = config.get("log_folder", "./openflexure/logs")
scans_folder = _get_scans_dir(config) scans_folder = _get_scans_dir(config)
server = lt.cli.server_from_config(config) server = lt.cli.server_from_config(config)
@ -128,3 +131,16 @@ def serve_from_cli(argv: Optional[list[str]] = None) -> None:
) )
else: else:
raise e raise e
def _full_config_from_args(args: Namespace) -> dict:
"""Load configuration from LabThings args allowing patching.
This provides similar functionarlity to lt.cli.config_from_args except allows the
configuration file to specify a base config, and optionally patches.
"""
# If no config file specified let LabThings handle it.
if not args.config:
return lt.cli.config_from_args(args)
return load_patched_config(args.config)

View file

@ -9,6 +9,8 @@ from typing import (
Concatenate, Concatenate,
Self, Self,
overload, overload,
TypeAlias,
Literal,
) )
import os import os
import re import re
@ -18,6 +20,7 @@ import logging
from importlib.metadata import version from importlib.metadata import version
import tomllib import tomllib
from functools import wraps from functools import wraps
import json
from pydantic import BaseModel from pydantic import BaseModel
import numpy as np import numpy as np
@ -25,6 +28,9 @@ import numpy as np
T = TypeVar("T") T = TypeVar("T")
P = ParamSpec("P") P = ParamSpec("P")
JSONScalar: TypeAlias = str | int | float | bool | None
JSONType: TypeAlias = dict[str, "JSONType"] | list["JSONType"] | JSONScalar
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
REPO_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) REPO_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
@ -359,3 +365,136 @@ def quadratic(
:return: The quadratic, evaluated at each point in ``x`` :return: The quadratic, evaluated at each point in ``x``
""" """
return a * x**2 + b * x + c return a * x**2 + b * x + c
# Use overload to clarify to MyPy that if enforce_dict is true, inputs and outputs are
# dictionaries
@overload
def merge_patch(
target: dict[str, JSONType], patch: dict[str, JSONType], enforce_dict: Literal[True]
) -> dict[str, JSONType]: ...
# If not they are any JSONType
@overload
def merge_patch(
target: JSONType, patch: JSONType, enforce_dict: Literal[False]
) -> JSONType: ...
@overload
def merge_patch(target: JSONType, patch: JSONType) -> JSONType: ...
def merge_patch(
target: JSONType, patch: JSONType, enforce_dict: bool = False
) -> JSONType:
"""Merge json data using the methods from IETF RFC 7396.
Primarily this is designed to merge dictionaries, but IETF RFC 7396 provides
defined methods for handling non-dictionary data loaded from JSON, so this
has been provided in full.
:param target: The target object
:param patch: The patch to be applied
:param enforce_dict: Boolean, set True enfoces that the target and patch are both
dictionaries.
"""
if enforce_dict and not (isinstance(target, dict) and isinstance(patch, dict)):
raise ValueError("Both target and patch should be dictionaries.")
# If patch is not a dict (object), it replaces target entirely:
if not isinstance(patch, dict):
return patch
# If target is not an object, replace it with an empty object
if not isinstance(target, dict):
target = {}
result = {}
# First, keep all keys from target that are not in patch
for key, target_val in target.items():
if key not in patch:
result[key] = target_val
# Then apply patch keys
for key, patch_val in patch.items():
# If patch_val is None, do not add the key (deleting from target).
if patch_val is None:
continue
# Check if values are dictionaries.
if isinstance(patch_val, dict):
# If patch value is a dictionary then recurse.
result[key] = merge_patch(target.get(key), patch_val)
else:
# Else, use the patch value
result[key] = patch_val
return result
def load_patched_config(config_path: str) -> dict:
"""Load a json configuration file, if it a patch, return the full config.
Load a json file. If it does not contains the "base_config_file" key then return
the data as read.
If the configuration specifies a "base_config_file" but no "patch" then return the
data from reading base_config_file as json.
If the configuration specifies a "base_config_file" and "patch" then apply the
patch to the data in base_config_file and return the result. The patching method
is merge_patch()
:param config_path: The path of the configuration file.
:return: The contents of the configuration file after loading and patching the
specified base configuration file if applicable.
"""
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
except IOError as e:
raise type(e)(f"Couldn't load configuration file {config_path}") from e
except json.JSONDecodeError as e:
raise json.JSONDecodeError(
f"Invalid JSON in configuration file {config_path}: {e}", e.doc, e.pos
) from e
# If base_config_file not specified then this is a normal LabThings config file
# Just return it.
if "base_config_file" not in config:
return config
config_dir = os.path.dirname(os.path.abspath(config_path))
base_conf_path = config["base_config_file"]
base_conf_path = resolve_path_from_dir(base_conf_path, config_dir)
try:
with open(base_conf_path, "r", encoding="utf-8") as f:
base_config = json.load(f)
except IOError as e:
raise type(e)(f"Couldn't load base configuration file {base_conf_path}") from e
except json.JSONDecodeError as e:
raise json.JSONDecodeError(
f"Invalid JSON in base configuration file {config_path}: {e}", e.doc, e.pos
) from e
if "patch" in config:
return merge_patch(base_config, config["patch"], enforce_dict=True)
return base_config
def resolve_path_from_dir(path: str, directory: str) -> str:
"""Convert a relative or abs path specified in one dir to the working dir.
This also expands any environment variables.
:param path: The specified path (could be absolute or relative)
:param directory: The directory from which the path was specified
:return: The normalised path.
"""
path = os.path.expandvars(os.path.expanduser(path))
if not os.path.isabs(path):
path = os.path.join(directory, path)
return os.path.normpath(path)

View file

@ -0,0 +1,124 @@
"""Test the configuration handling functions in utilities."""
import pytest
import os
import json
from json import JSONDecodeError
from openflexure_microscope_server import utilities
@pytest.mark.parametrize(
("target", "patch", "outcome", "err_if_enforce"),
[
({"a": "b"}, {"a": "c"}, {"a": "c"}, False),
({"a": "b"}, {"b": "c"}, {"a": "b", "b": "c"}, False),
({"a": "b"}, {"a": None}, {}, False),
({"a": "b", "b": "c"}, {"a": None}, {"b": "c"}, False),
({"a": ["b"]}, {"a": "c"}, {"a": "c"}, False),
({"a": "c"}, {"a": ["b"]}, {"a": ["b"]}, False),
(
{"a": {"b": "c"}},
{"a": {"b": "d", "c": None}},
{"a": {"b": "d"}},
False,
),
({"a": [{"b": "c"}]}, {"a": [1]}, {"a": [1]}, False),
(["a", "b"], ["c", "d"], ["c", "d"], True),
({"a": "b"}, ["c"], ["c"], True),
({"a": "foo"}, None, None, True),
({"a": "foo"}, "bar", "bar", True),
({"e": None}, {"a": 1}, {"e": None, "a": 1}, False),
([1, 2], {"a": "b", "c": None}, {"a": "b"}, True),
({}, {"a": {"bb": {"ccc": None}}}, {"a": {"bb": {}}}, False),
],
)
def test_merge_patch(target, patch, outcome, err_if_enforce):
"""Check that merge_patch returns the expected outcome for each target and patch.
These test cases are specified in Appedix A of IETF RFC 7396.
"""
assert utilities.merge_patch(target, patch) == outcome
# If expected to error when enforce_dict is True then check:
if err_if_enforce:
with pytest.raises(ValueError, match="Both target and patch"):
utilities.merge_patch(target, patch, enforce_dict=True)
else:
# Else should run without error with same expected value
assert utilities.merge_patch(target, patch, enforce_dict=True) == outcome
@pytest.mark.parametrize(
("base_conf", "resolves_to"),
[
("/var/base.json", "/var/base.json"),
("base.json", "/var/openflexure/settings/base.json"),
("./base.json", "/var/openflexure/settings/base.json"),
("../base.json", "/var/openflexure/base.json"),
(
"$OFM_LIB/base.json",
"/var/openflexure/application/openflexure-microscope-server/base.json",
),
],
)
def test_resolve_path_from_dir(base_conf, resolves_to, mocker):
"""Test that resolve path handles, absolute and relative paths and expands env vars."""
mocker.patch.dict(
os.environ,
{"OFM_LIB": "/var/openflexure/application/openflexure-microscope-server"},
)
conf_dir = "/var/openflexure/settings"
resolved_path = utilities.resolve_path_from_dir(base_conf, directory=conf_dir)
assert resolved_path == resolves_to
def test_patching_config(tmpdir):
"""Test each situation in load_patched_config."""
conf_file = os.path.join(tmpdir, "ofm_config.json")
base_conf_file = os.path.join(tmpdir, "ofm_base_config.json")
simple_config = {"things": {"thing1": "python.class"}}
config_patch = {"things": {"thing2": "python.class2"}}
patched_config = {"things": {"thing1": "python.class", "thing2": "python.class2"}}
broken_json = "{{invalid{Json"
# Error if config file doesn't exist
with pytest.raises(IOError, match="Couldn't load configuration"):
utilities.load_patched_config(conf_file)
# Error if config file contains bad json
with open(conf_file, "w", encoding="utf-8") as file_obj:
file_obj.write(broken_json)
with pytest.raises(JSONDecodeError, match="Invalid JSON in configuration"):
utilities.load_patched_config(conf_file)
# If base_config_file is not set, just return the configuration
with open(conf_file, "w", encoding="utf-8") as file_obj:
json.dump(simple_config, file_obj)
assert utilities.load_patched_config(conf_file) == simple_config
# Set the "base_config_file" instead, error as there is no base config file
config = {"base_config_file": "./ofm_base_config.json"}
with open(conf_file, "w", encoding="utf-8") as file_obj:
json.dump(config, file_obj)
with pytest.raises(IOError, match="Couldn't load base configuration"):
utilities.load_patched_config(conf_file)
# Error if base config file contains bad json
with open(base_conf_file, "w", encoding="utf-8") as file_obj:
file_obj.write(broken_json)
with pytest.raises(JSONDecodeError, match="Invalid JSON in base configuration"):
utilities.load_patched_config(conf_file)
# Loads the simple configuration from the base config file.
with open(base_conf_file, "w", encoding="utf-8") as file_obj:
json.dump(simple_config, file_obj)
assert utilities.load_patched_config(conf_file) == simple_config
# Set the outer config to have a patch.
# It now reads the simple_config from the base condif file and patches it
config["patch"] = config_patch
with open(conf_file, "w", encoding="utf-8") as file_obj:
json.dump(config, file_obj)
assert utilities.load_patched_config(conf_file) == patched_config