diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index 1be859c..4d17d17 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 0b61fa0..6519aa0 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 02f51ca..6c8953e 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 7698199..47c3364 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 4df8f55..9028ac5 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 514b509..f136aa7 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