From ce87e1966f2f21128278fcc81748b5daea3b8976 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sun, 22 Feb 2026 13:13:15 +0000 Subject: [PATCH 1/3] Add application configuration that propagates to Things --- src/labthings_fastapi/server/__init__.py | 21 +++++++++++++++++-- src/labthings_fastapi/server/config_model.py | 12 +++++++++++ src/labthings_fastapi/testing.py | 8 +++++++ src/labthings_fastapi/thing.py | 6 ++++++ .../thing_server_interface.py | 5 +++++ tests/test_thing.py | 19 +++++++++++++++++ 6 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index 1be859c1..4d17d17c 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -7,7 +7,7 @@ """ from __future__ import annotations -from typing import AsyncGenerator, Optional, TypeVar +from typing import Any, AsyncGenerator, Optional, TypeVar from typing_extensions import Self import os @@ -65,6 +65,7 @@ def __init__( self, things: ThingsConfig, settings_folder: Optional[str] = None, + application_config: Optional[Mapping[str, Any]] = None, ) -> None: r"""Initialise a LabThings server. @@ -81,10 +82,17 @@ def __init__( arguments, and any connections to other `.Thing`\ s. :param settings_folder: the location on disk where `.Thing` settings will be saved. + :param application_config: A mapping containing custom configuration for the + application. This is not processed by LabThings. Each `.Thing` can access + this via their ``application_config`` attribute """ self.startup_failure: dict | None = None configure_thing_logger() # Note: this is safe to call multiple times. - self._config = ThingServerConfig(things=things, settings_folder=settings_folder) + self._config = ThingServerConfig( + things=things, + settings_folder=settings_folder, + application_config=application_config, + ) self.app = FastAPI(lifespan=self.lifespan) self._set_cors_middleware() self._set_url_for_middleware() @@ -148,6 +156,15 @@ def things(self) -> Mapping[str, Thing]: """ return MappingProxyType(self._things) + @property + def application_config(self) -> Mapping[str, Any] | None: + """Return the application configuration from the config file. + + :return: The custom configuration as specified in the configuration + file. + """ + return self._config.application_config + ThingInstance = TypeVar("ThingInstance", bound=Thing) def things_by_class(self, cls: type[ThingInstance]) -> Sequence[ThingInstance]: diff --git a/src/labthings_fastapi/server/config_model.py b/src/labthings_fastapi/server/config_model.py index 0b61fa0e..6519aa01 100644 --- a/src/labthings_fastapi/server/config_model.py +++ b/src/labthings_fastapi/server/config_model.py @@ -180,6 +180,18 @@ def thing_configs(self) -> Mapping[ThingName, ThingConfig]: description="The location of the settings folder.", ) + application_config: dict[str, Any] | None = Field( + default=None, + description=( + """Any custom settings required by the application. + + These settings will be available to any Things within the application via + their ``application_config`` attribute. Any validation of the dictionary is + the responsibility of application code. + """ + ), + ) + def normalise_things_config(things: ThingsConfig) -> Mapping[ThingName, ThingConfig]: r"""Ensure every Thing is defined by a `.ThingConfig` object. diff --git a/src/labthings_fastapi/testing.py b/src/labthings_fastapi/testing.py index 02f51ca4..6c8953e7 100644 --- a/src/labthings_fastapi/testing.py +++ b/src/labthings_fastapi/testing.py @@ -121,6 +121,14 @@ def _action_manager(self) -> ActionManager: """ raise NotImplementedError("MockThingServerInterface has no ActionManager.") + @property + def application_config(self) -> None: + """Return an empty application configuration when mocking. + + :return: None + """ + return None + ThingSubclass = TypeVar("ThingSubclass", bound="Thing") diff --git a/src/labthings_fastapi/thing.py b/src/labthings_fastapi/thing.py index 76981998..47c33641 100644 --- a/src/labthings_fastapi/thing.py +++ b/src/labthings_fastapi/thing.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Optional from typing_extensions import Self from collections.abc import Mapping +from copy import deepcopy import logging import os import json @@ -82,6 +83,8 @@ class Thing: _thing_server_interface: ThingServerInterface """Provide access to features of the server that this `.Thing` is attached to.""" + application_config: Mapping[str, Any] | None + def __init__(self, thing_server_interface: ThingServerInterface) -> None: """Initialise a Thing. @@ -98,6 +101,9 @@ def __init__(self, thing_server_interface: ThingServerInterface) -> None: `.create_thing_without_server` which generates a mock interface. """ self._thing_server_interface = thing_server_interface + # Create a deepcopy of the configuration so if one Thing mutates the config + # it cannot propagate. + self.application_config = deepcopy(thing_server_interface.application_config) self._disable_saving_settings: bool = False @property diff --git a/src/labthings_fastapi/thing_server_interface.py b/src/labthings_fastapi/thing_server_interface.py index 4df8f556..9028ac56 100644 --- a/src/labthings_fastapi/thing_server_interface.py +++ b/src/labthings_fastapi/thing_server_interface.py @@ -156,6 +156,11 @@ def path(self) -> str: """ return self._get_server().path_for_thing(self.name) + @property + def application_config(self) -> Mapping[str, Any] | None: + """The custom application configuration options from configuration.""" + return self._get_server().application_config + def get_thing_states(self) -> Mapping[str, Any]: """Retrieve metadata from all Things on the server. diff --git a/tests/test_thing.py b/tests/test_thing.py index 514b5090..f136aa76 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -13,3 +13,22 @@ def test_add_thing(): """Check that thing can be added to the server""" server = ThingServer({"thing": MyThing}) assert isinstance(server.things["thing"], MyThing) + + +def test_thing_can_access_application_config(): + """Check that a thing can access its application config.""" + conf = { + "things": {"thing1": MyThing, "thing2": MyThing}, + "application_config": {"foo": "bar", "mock": True}, + } + + server = ThingServer.from_config(conf) + thing1 = server.things["thing1"] + thing2 = server.things["thing2"] + + # Check both Things can access the application config + assert thing1.application_config == {"foo": "bar", "mock": True} + assert thing1.application_config == thing2.application_config + # But that they are not the same dictionary, preventing mutations affecting + # behaviour of another thing. + assert thing1.application_config is not thing2.application_config From f4041bb31491260c2a5bee4e8718ea47f210514c Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 25 Feb 2026 22:11:58 +0000 Subject: [PATCH 2/3] Remove `Thing.application_config` and move `deepcopy` to `ThingServerInterface`. This keeps the application config as a server property, which I think is clearer. Also, we now deep copy it every time it's needed, rather than in `Thing.__init__`. It may be nice in the future to swap deepcopy for some kind of freezing method, but that's for a future PR, and probably not worth an issue. --- src/labthings_fastapi/thing.py | 6 ------ src/labthings_fastapi/thing_server_interface.py | 3 ++- tests/test_thing.py | 8 +++++--- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/labthings_fastapi/thing.py b/src/labthings_fastapi/thing.py index 47c33641..76981998 100644 --- a/src/labthings_fastapi/thing.py +++ b/src/labthings_fastapi/thing.py @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Any, Optional from typing_extensions import Self from collections.abc import Mapping -from copy import deepcopy import logging import os import json @@ -83,8 +82,6 @@ class Thing: _thing_server_interface: ThingServerInterface """Provide access to features of the server that this `.Thing` is attached to.""" - application_config: Mapping[str, Any] | None - def __init__(self, thing_server_interface: ThingServerInterface) -> None: """Initialise a Thing. @@ -101,9 +98,6 @@ def __init__(self, thing_server_interface: ThingServerInterface) -> None: `.create_thing_without_server` which generates a mock interface. """ self._thing_server_interface = thing_server_interface - # Create a deepcopy of the configuration so if one Thing mutates the config - # it cannot propagate. - self.application_config = deepcopy(thing_server_interface.application_config) self._disable_saving_settings: bool = False @property diff --git a/src/labthings_fastapi/thing_server_interface.py b/src/labthings_fastapi/thing_server_interface.py index 9028ac56..b0e46f92 100644 --- a/src/labthings_fastapi/thing_server_interface.py +++ b/src/labthings_fastapi/thing_server_interface.py @@ -2,6 +2,7 @@ from __future__ import annotations from concurrent.futures import Future +from copy import deepcopy import os from typing import ( TYPE_CHECKING, @@ -159,7 +160,7 @@ def path(self) -> str: @property def application_config(self) -> Mapping[str, Any] | None: """The custom application configuration options from configuration.""" - return self._get_server().application_config + return deepcopy(self._get_server().application_config) def get_thing_states(self) -> Mapping[str, Any]: """Retrieve metadata from all Things on the server. diff --git a/tests/test_thing.py b/tests/test_thing.py index f136aa76..523bda34 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -27,8 +27,10 @@ def test_thing_can_access_application_config(): thing2 = server.things["thing2"] # Check both Things can access the application config - assert thing1.application_config == {"foo": "bar", "mock": True} - assert thing1.application_config == thing2.application_config + thing1_config = thing1._thing_server_interface.application_config + thing2_config = thing2._thing_server_interface.application_config + assert thing1_config == {"foo": "bar", "mock": True} + assert thing1_config == thing2_config # But that they are not the same dictionary, preventing mutations affecting # behaviour of another thing. - assert thing1.application_config is not thing2.application_config + assert thing1_config is not thing2_config From 5f58fda7ff48885f0d28d3b114318c1304c8cf72 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 25 Feb 2026 22:28:19 +0000 Subject: [PATCH 3/3] Update src/labthings_fastapi/server/__init__.py Co-authored-by: Julian Stirling --- src/labthings_fastapi/server/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index 4d17d17c..01c7f73d 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -84,7 +84,8 @@ def __init__( settings will be saved. :param application_config: A mapping containing custom configuration for the application. This is not processed by LabThings. Each `.Thing` can access - this via their ``application_config`` attribute + application. This is not processed by LabThings. Each `.Thing` can access + this via the Thing-Server interface. """ self.startup_failure: dict | None = None configure_thing_logger() # Note: this is safe to call multiple times.