Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/labthings_fastapi/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -65,6 +65,7 @@
self,
things: ThingsConfig,
settings_folder: Optional[str] = None,
application_config: Optional[Mapping[str, Any]] = None,
) -> None:
r"""Initialise a LabThings server.

Expand All @@ -81,10 +82,17 @@
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()
Expand Down Expand Up @@ -148,6 +156,15 @@
"""
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]:
Expand Down Expand Up @@ -176,7 +193,7 @@
instances = self.things_by_class(cls)
if len(instances) == 1:
return instances[0]
raise RuntimeError(

Check warning on line 196 in src/labthings_fastapi/server/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

196 line is not covered with tests
f"There are {len(instances)} Things of class {cls}, expected 1."
)

Expand Down Expand Up @@ -337,7 +354,7 @@
:return: a list of paths pointing to `.Thing` instances. These
URLs will return the :ref:`wot_td` of one `.Thing` each.
""" # noqa: D403 (URLs is correct capitalisation)
return {

Check warning on line 357 in src/labthings_fastapi/server/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

357 line is not covered with tests
t: f"{str(request.base_url).rstrip('/')}{t}"
for t in thing_server.things.keys()
}
12 changes: 12 additions & 0 deletions src/labthings_fastapi/server/config_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/labthings_fastapi/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@
"""
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")

Expand Down Expand Up @@ -211,7 +219,7 @@
if isinstance(interface, MockThingServerInterface):
interface._mocks.append(mock)
else:
raise TypeError(

Check warning on line 222 in src/labthings_fastapi/testing.py

View workflow job for this annotation

GitHub Actions / coverage

222 line is not covered with tests
"Slots may not be mocked when a Thing is attached to a real "
"server."
)
Expand Down
6 changes: 6 additions & 0 deletions src/labthings_fastapi/thing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -82,6 +83,8 @@
_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.

Expand All @@ -98,6 +101,9 @@
`.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
Expand Down Expand Up @@ -249,13 +255,13 @@
the settings file every time.
"""
if self._disable_saving_settings:
return

Check warning on line 258 in src/labthings_fastapi/thing.py

View workflow job for this annotation

GitHub Actions / coverage

258 line is not covered with tests
if self._settings is not None:
setting_dict = {}
for name in self._settings.keys():
value = getattr(self, name)
if isinstance(value, BaseModel):
value = value.model_dump()

Check warning on line 264 in src/labthings_fastapi/thing.py

View workflow job for this annotation

GitHub Actions / coverage

264 line is not covered with tests
setting_dict[name] = value
# Dumpy to string before writing so if this fails the file isn't overwritten
setting_json = json.dumps(setting_dict, indent=4)
Expand All @@ -280,9 +286,9 @@
Some measure of caching here is a nice aim for the future, but not yet
implemented.
"""
if self._labthings_thing_state is None:
self._labthings_thing_state = {}
return self._labthings_thing_state

Check warning on line 291 in src/labthings_fastapi/thing.py

View workflow job for this annotation

GitHub Actions / coverage

289-291 lines are not covered with tests

def validate_thing_description(self) -> None:
"""Raise an exception if the thing description is not valid."""
Expand Down Expand Up @@ -314,7 +320,7 @@
and self._cached_thing_description[0] == path
and self._cached_thing_description[1] == base
):
return self._cached_thing_description[2]

Check warning on line 323 in src/labthings_fastapi/thing.py

View workflow job for this annotation

GitHub Actions / coverage

323 line is not covered with tests

properties = {}
actions = {}
Expand Down
5 changes: 5 additions & 0 deletions src/labthings_fastapi/thing_server_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
19 changes: 19 additions & 0 deletions tests/test_thing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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