From e282676610338370da5976ae2e10be3e16c055b3 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 27 Oct 2025 16:14:36 +0000 Subject: [PATCH 01/15] Initialise all Things during ThingServer.__init__ This marks a few more methods of ThingServer as private, and accepts a dict of Things to be created during initialisation. In order to do this cleanly, I have now formalised a schema for config files, using a Pydantic model. Tests (and no doubt fixes) will come in the next commit. --- src/labthings_fastapi/server/__init__.py | 143 ++++++++++--------- src/labthings_fastapi/server/cli.py | 25 ++-- src/labthings_fastapi/server/config_model.py | 59 ++++++++ 3 files changed, 142 insertions(+), 85 deletions(-) create mode 100644 src/labthings_fastapi/server/config_model.py diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index d91f62a..07a6d9b 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -8,6 +8,7 @@ from __future__ import annotations from typing import Any, AsyncGenerator, Optional, TypeVar +from typing_extensions import Self import os.path import re @@ -23,7 +24,7 @@ from ..utilities import class_attributes from ..utilities.object_reference_to_object import ( - object_reference_to_object, + object_reference_to_object as object_reference_to_object, ) from ..actions import ActionManager from ..logs import configure_thing_logger @@ -31,6 +32,7 @@ from ..thing_server_interface import ThingServerInterface from ..thing_description._model import ThingDescription from ..dependencies.thing_server import _thing_servers # noqa: F401 +from .config_model import ThingConfig, ThingsConfig, ThingServerConfig # `_thing_servers` is used as a global from `ThingServer.__init__` from ..outputs.blob import BlobDataManager @@ -62,7 +64,9 @@ class ThingServer: an `anyio.from_thread.BlockingPortal`. """ - def __init__(self, settings_folder: Optional[str] = None) -> None: + def __init__( + self, things: ThingsConfig, settings_folder: Optional[str] = None + ) -> None: """Initialise a LabThings server. Setting up the `.ThingServer` involves creating the underlying @@ -76,27 +80,33 @@ def __init__(self, settings_folder: Optional[str] = None) -> None: :param settings_folder: the location on disk where `.Thing` settings will be saved. """ + configure_thing_logger() # Note: this is safe to call multiple times. self.app = FastAPI(lifespan=self.lifespan) - self.set_cors_middleware() + self._set_cors_middleware() self.settings_folder = settings_folder or "./settings" self.action_manager = ActionManager() self.action_manager.attach_to_app(self.app) self.blob_data_manager = BlobDataManager() self.blob_data_manager.attach_to_app(self.app) - self.add_things_view_to_app() - self._things: dict[str, Thing] = {} - self.thing_connections: dict[str, Mapping[str, str | Iterable[str] | None]] = {} + self._add_things_view_to_app() self.blocking_portal: Optional[BlockingPortal] = None self.startup_status: dict[str, str | dict] = {"things": {}} global _thing_servers # noqa: F824 _thing_servers.add(self) - configure_thing_logger() # Note: this is safe to call multiple times. + self._things = self._create_and_connect_things(things) + + @classmethod + def from_config(cls, config: ThingServerConfig) -> Self: + r"""Create a ThingServer from a configuration model. - app: FastAPI - action_manager: ActionManager - blob_data_manager: BlobDataManager + This is equivalent to ``ThingServer(**dict(config))``\ . - def set_cors_middleware(self) -> None: + :param config: The configuration parameters for the server. + :return: A `.ThingServer` configured as per the model. + """ + return cls(**dict(config)) + + def _set_cors_middleware(self) -> None: """Configure the server to allow requests from other origins. This is required to allow web applications access to the HTTP API, @@ -154,14 +164,44 @@ def thing_by_class(self, cls: type[ThingInstance]) -> ThingInstance: f"There are {len(instances)} Things of class {cls}, expected 1." ) - def add_thing( + def _create_and_connect_things(self, configs: ThingsConfig) -> Mapping[str, Thing]: + r"""Create the Things, add them to the server, and connect them up if needed. + + This method is responsible for creating instances of `.Thing` subclasses + and adding them to the server. It also ensures the `.Thing`\ s are connected + together if required. + + :param things: A mapping of names to Things. The keys must always be strings, + consisting only of alphanumeric characters and underscores. The values + may be classes, or dictionaries with keys as specified in `.ThingConfig`\ . + + :return: A mapping of names to `.Thing` instances. + """ + connections: dict[str, Mapping[str, str | Iterable[str] | None]] = {} + things: dict[str, Thing] = {} + for name, config in configs.items(): + if isinstance(config, type): + # If a class has been passed, wrap it in a dictionary + config = ThingConfig(thing_subclass=config) + connections[name] = config.thing_connections or {} + things = self._add_thing( + name=name, + thing_subclass=config.thing_subclass, + things=things, + args=config.args, + kwargs=config.kwargs, + ) + self._connect_things(connections) + return things + + def _add_thing( self, name: str, thing_subclass: type[ThingSubclass], + things: dict[str, Thing], args: Sequence[Any] | None = None, kwargs: Mapping[str, Any] | None = None, - thing_connections: Mapping[str, str | Iterable[str] | None] | None = None, - ) -> ThingSubclass: + ) -> dict[str, Thing]: r"""Add a thing to the server. This function will create an instance of ``thing_subclass`` and supply @@ -176,15 +216,10 @@ def add_thing( ``thing_subclass``\ . :param kwargs: keyword arguments to pass to the constructor of ``thing_subclass``\ . - :param thing_connections: a mapping that sets up the `.thing_connection`\ s. - Keys are the names of attributes of the `.Thing` and the values are - the name(s) of the `.Thing`\ (s) you'd like to connect. If this is left - at its default, the connections will use their default behaviour, usually - automatically connecting to a `.Thing` of the right type. - :returns: the instance of ``thing_subclass`` that was created and added - to the server. There is no need to retain a reference to this, as it - is stored in the server's dictionary of `.Thing` instances. + :return: a `dict` mapping names to `.Thing` instances. This is the same + dictionary supplied as ``things`` but with the additional `.Thing` + instance added. :raise ValueError: if ``path`` contains invalid characters. :raise KeyError: if a `.Thing` has already been added at ``path``\ . @@ -200,7 +235,7 @@ def add_thing( "characters, hyphens and underscores" ) raise ValueError(msg) - if name in self._things: + if name in things: raise KeyError(f"{name} has already been added to this thing server.") if not issubclass(thing_subclass, Thing): raise TypeError(f"{thing_subclass} is not a Thing subclass.") @@ -219,13 +254,11 @@ def add_thing( **kwargs, thing_server_interface=interface, ) # type: ignore[misc] - self._things[name] = thing - if thing_connections is not None: - self.thing_connections[name] = thing_connections + things[name] = thing thing.attach_to_server( server=self, ) - return thing + return things def path_for_thing(self, name: str) -> str: """Return the path for a thing with the given name. @@ -240,8 +273,10 @@ def path_for_thing(self, name: str) -> str: raise KeyError(f"No thing named {name} has been added to this server.") return f"/{name}/" - def _connect_things(self) -> None: - """Connect the `thing_connection` attributes of Things. + def _connect_things( + self, connections: dict[str, Mapping[str, str | Iterable[str] | None]] + ) -> None: + r"""Connect the `thing_connection` attributes of Things. A `.Thing` may have attributes defined as ``lt.thing_connection()``, which will be populated after all `.Thing` instances are loaded on the server. @@ -253,9 +288,15 @@ def _connect_things(self) -> None: `.ThingConnectionError` will be raised by code called by this method if the connection cannot be provided. See `.ThingConnection.connect` for more details. + + :param connections: holds the supplied configuration, which is used + in preference to looking up Things by type. It is a mapping of + `.Thing` names to mappings, which map attribute names to the requested + `.Thing`\ . For example, to connect ``mything.myslot`` to the `.Thing` + named `"foo"`\ , you could pass ``{"mything": {"myslot": "foo"}}``\ . """ for thing_name, thing in self.things.items(): - config = self.thing_connections.get(thing_name, {}) + config = connections.get(thing_name, {}) for attr_name, attr in class_attributes(thing): if not isinstance(attr, ThingConnection): continue @@ -287,10 +328,6 @@ async def lifespan(self, app: FastAPI) -> AsyncGenerator[None]: # in the event loop. self.blocking_portal = portal - # Now we need to connect any ThingConnections. This is done here so that - # all of the Things are already created and added to the server. - self._connect_things() - # we __aenter__ and __aexit__ each Thing, which will in turn call the # synchronous __enter__ and __exit__ methods if they exist, to initialise # and shut down the hardware. NB we must make sure the blocking portal @@ -302,7 +339,7 @@ async def lifespan(self, app: FastAPI) -> AsyncGenerator[None]: self.blocking_portal = None - def add_things_view_to_app(self) -> None: + def _add_things_view_to_app(self) -> None: """Add an endpoint that shows the list of attached things.""" thing_server = self @@ -344,39 +381,3 @@ def thing_paths(request: Request) -> Mapping[str, str]: t: f"{str(request.base_url).rstrip('/')}{t}" for t in thing_server.things.keys() } - - -def server_from_config(config: dict) -> ThingServer: - r"""Create a ThingServer from a configuration dictionary. - - This function creates a `.ThingServer` and adds a number of `.Thing` - instances from a configuration dictionary. - - :param config: A dictionary, in the format used by :ref:`config_files` - - :return: A `.ThingServer` with instances of the specified `.Thing` - subclasses attached. The server will not be started by this - function. - - :raise ImportError: if a Thing could not be loaded from the specified - object reference. - """ - server = ThingServer(config.get("settings_folder", None)) - for name, thing in config.get("things", {}).items(): - if isinstance(thing, str): - thing = {"class": thing} - try: - cls = object_reference_to_object(thing["class"]) - except ImportError as e: - raise ImportError( - f"Could not import {thing['class']}, which was " - f"specified as the class for {name}." - ) from e - server.add_thing( - name=name, - thing_subclass=cls, - args=thing.get("args", ()), - kwargs=thing.get("kwargs", {}), - thing_connections=thing.get("thing_connections", {}), - ) - return server diff --git a/src/labthings_fastapi/server/cli.py b/src/labthings_fastapi/server/cli.py index 2e636d2..a3d956f 100644 --- a/src/labthings_fastapi/server/cli.py +++ b/src/labthings_fastapi/server/cli.py @@ -20,14 +20,14 @@ from argparse import ArgumentParser, Namespace from typing import Optional -import json from ..utilities.object_reference_to_object import ( object_reference_to_object, ) import uvicorn -from . import ThingServer, server_from_config +from . import ThingServer +from .config_model import ThingServerConfig def get_default_parser() -> ArgumentParser: @@ -74,7 +74,7 @@ def parse_args(argv: Optional[list[str]] = None) -> Namespace: return parser.parse_args(argv) -def config_from_args(args: Namespace) -> dict: +def config_from_args(args: Namespace) -> ThingServerConfig: """Load the configuration from a supplied file or JSON string. This function will first attempt to load a JSON file specified in the @@ -87,29 +87,26 @@ def config_from_args(args: Namespace) -> dict: :param args: Parsed arguments from `.parse_args`. - :return: a server configuration, as a dictionary. + :return: the server configuration. :raise FileNotFoundError: if the configuration file specified is missing. :raise RuntimeError: if neither a config file nor a string is provided. """ if args.config: + if args.json: + raise RuntimeError("Can't use both --config and --json simultaneously.") try: with open(args.config) as f: - config = json.load(f) + return ThingServerConfig.model_validate_json(f.read()) except FileNotFoundError as e: raise FileNotFoundError( f"Could not find configuration file {args.config}" ) from e + elif args.json: + return ThingServerConfig.model_validate_json(args.json) else: - config = {} - if args.json: - config.update(json.loads(args.json)) - - if len(config) == 0: raise RuntimeError("No configuration (or empty configuration) provided") - return config - def serve_from_cli( argv: Optional[list[str]] = None, dry_run: bool = False @@ -118,7 +115,7 @@ def serve_from_cli( This function will parse command line arguments, load configuration, set up a server, and start it. It calls `.parse_args`, - `.config_from_args` and `.server_from_config` to get a server, then + `.config_from_args` and `.ThingServer.from_config` to get a server, then starts `uvicorn` to serve on the specified host and port. If the ``fallback`` argument is specified, errors that stop the @@ -143,7 +140,7 @@ def serve_from_cli( try: config, server = None, None config = config_from_args(args) - server = server_from_config(config) + server = ThingServer.from_config(config) if dry_run: return server uvicorn.run(server.app, host=args.host, port=args.port) diff --git a/src/labthings_fastapi/server/config_model.py b/src/labthings_fastapi/server/config_model.py new file mode 100644 index 0000000..cc26a37 --- /dev/null +++ b/src/labthings_fastapi/server/config_model.py @@ -0,0 +1,59 @@ +r"""Pydantic models to enable server configuration to be loaded from file. + +The models in this module allow `.ThingConfig` dataclasses to be constructed +from dictionaries or JSON files. They also describe the full server configurtion +with `.ServerConfigModel`\ . These models are used by the `.cli` module to +start servers based on configuration files or strings. +""" + +from pydantic import BaseModel, Field, ImportString +from typing import TYPE_CHECKING, Any, Annotated +from collections.abc import Mapping, Sequence, Iterable + +if TYPE_CHECKING: + from ..thing import Thing + + +class ThingConfig(BaseModel): + """A Pydantic model corresponding to the `.ThingConfig` dataclass.""" + + thing_subclass: ImportString + """The `.Thing` subclass to add to the server.""" + + args: Sequence[Any] | None = None + r"""Positional arguments to pass to the constructor of ``thing_subclass``\ .""" + + kwargs: Mapping[str, Any] | None = None + r"""Keyword arguments to pass to the constructor of ``thing_subclass``\ .""" + + thing_connections: Mapping[str, str | Iterable[str] | None] | None = None + r"""A mapping that sets up the `.thing_slot`\ s. + Keys are the names of attributes of the `.Thing` and the values are + the name(s) of the `.Thing`\ (s) you'd like to connect. If this is left + at its default, the connections will use their default behaviour, usually + automatically connecting to a `.Thing` of the right type. + """ + + +ThingName = Annotated[ + str, + Field(min_length=1, pattern=r"^([a-zA-Z0-9\-_]+)$"), +] + + +ThingsConfig = Mapping[ThingName, ThingConfig | type[Thing]] + + +class ThingServerConfig(BaseModel): + r"""The configuration parameters for a `.ThingServer`\ .""" + + things: ThingsConfig + """A mapping of names to Thing configurations. + + Each Thing on the server must be given a name, which is the dictionary + key. The value is either the class to be used, or a `.ThingConfig` + object specifying the class, initial arguments, and other settings. + """ + + settings_folder: str | None = None + """The location of the settings folder, or `None` to use the default location.""" From 15dce7b432342f6d843b04fca487415898f770ed Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 27 Oct 2025 23:18:14 +0000 Subject: [PATCH 02/15] Tidy up config models and make them available at module level. The config modules now do all the required validation, including Thing names, ensuring we expand classes into full `ThingConfig` objects, and supplying default values for args, kwargs, etc. I've exposed ThingConfig and ThingServerConfig at module level as they are likely useful in a number of scenarios, particularly writing tests. It is possible to supply a dict whenever a ThingConfig is required - I'll include this in the tests, but I don't want to recommend it - it's better to use the model as it makes it harder to miss fields. --- src/labthings_fastapi/__init__.py | 3 + src/labthings_fastapi/server/config_model.py | 132 +++++++++++++++---- 2 files changed, 106 insertions(+), 29 deletions(-) diff --git a/src/labthings_fastapi/__init__.py b/src/labthings_fastapi/__init__.py index e989268..0b3bab4 100644 --- a/src/labthings_fastapi/__init__.py +++ b/src/labthings_fastapi/__init__.py @@ -31,6 +31,7 @@ from . import outputs from .outputs import blob from .server import ThingServer, cli +from .server.config_model import ThingConfig, ThingServerConfig from .client import ThingClient from .invocation_contexts import ( cancellable_sleep, @@ -59,6 +60,8 @@ "blob", "ThingServer", "cli", + "ThingConfig", + "ThingServerConfig", "ThingClient", "cancellable_sleep", "raise_if_cancelled", diff --git a/src/labthings_fastapi/server/config_model.py b/src/labthings_fastapi/server/config_model.py index cc26a37..0a171df 100644 --- a/src/labthings_fastapi/server/config_model.py +++ b/src/labthings_fastapi/server/config_model.py @@ -6,33 +6,41 @@ start servers based on configuration files or strings. """ -from pydantic import BaseModel, Field, ImportString -from typing import TYPE_CHECKING, Any, Annotated +from pydantic import BaseModel, Field, ImportString, AliasChoices, field_validator +from typing import Any, Annotated, TypeAlias from collections.abc import Mapping, Sequence, Iterable -if TYPE_CHECKING: - from ..thing import Thing - class ThingConfig(BaseModel): - """A Pydantic model corresponding to the `.ThingConfig` dataclass.""" + r"""The information needed to add a `.Thing` to a `.ThingServer`\ .""" - thing_subclass: ImportString - """The `.Thing` subclass to add to the server.""" + cls: ImportString = Field( + validation_alias=AliasChoices("cls", "class"), + description="The Thing subclass to add to the server.", + ) - args: Sequence[Any] | None = None - r"""Positional arguments to pass to the constructor of ``thing_subclass``\ .""" + args: Sequence[Any] = Field( + default_factory=list, + description="Positional arguments to pass to the constructor of `cls`.", + ) - kwargs: Mapping[str, Any] | None = None - r"""Keyword arguments to pass to the constructor of ``thing_subclass``\ .""" + kwargs: Mapping[str, Any] = Field( + default_factory=dict, + description="Keyword arguments to pass to the constructor of `cls`.", + ) - thing_connections: Mapping[str, str | Iterable[str] | None] | None = None - r"""A mapping that sets up the `.thing_slot`\ s. - Keys are the names of attributes of the `.Thing` and the values are - the name(s) of the `.Thing`\ (s) you'd like to connect. If this is left - at its default, the connections will use their default behaviour, usually - automatically connecting to a `.Thing` of the right type. - """ + thing_connections: Mapping[str, str | Iterable[str] | None] = Field( + default_factory=dict, + description=( + """Connections to other Things. + + Keys are the names of attributes of the Thing and the values are + the name(s) of the Thing(s) you'd like to connect. If this is left + at its default, the connections will use their default behaviour, usually + automatically connecting to a Thing of the right type. + """ + ), + ) ThingName = Annotated[ @@ -41,19 +49,85 @@ class ThingConfig(BaseModel): ] -ThingsConfig = Mapping[ThingName, ThingConfig | type[Thing]] +ThingsConfig: TypeAlias = Mapping[ThingName, ThingConfig | ImportString] class ThingServerConfig(BaseModel): r"""The configuration parameters for a `.ThingServer`\ .""" - things: ThingsConfig - """A mapping of names to Thing configurations. - - Each Thing on the server must be given a name, which is the dictionary - key. The value is either the class to be used, or a `.ThingConfig` - object specifying the class, initial arguments, and other settings. - """ + things: ThingsConfig = Field( + description=( + """A mapping of names to Thing configurations. + + Each Thing on the server must be given a name, which is the dictionary + key. The value is either the class to be used, or a `.ThingConfig` + object specifying the class, initial arguments, and other settings. + """ + ), + ) + + @field_validator("things", mode="after") + @classmethod + def check_things(cls, things: ThingsConfig) -> ThingsConfig: + """Check that the thing configurations can be normalised. + + It's possible to specify the things as a mapping from names to classes. + We use `pydantic.ImportString` as the type of the classes: this takes a + string, and imports the corresponding Python object. When loading config + from JSON, this does the right thing - but when loading from Python objects + it will accept any Python object. + + This validator runs `.normalise_thing_config` to check each value is either + a valid `.ThingConfig` or a type or a mapping. If it's a mapping, we + will attempt to make a `.ThingConfig` from it. If it's a `type` we will + create a `.ThingConfig` using that type as the class. We don't check for + `.Thing` subclasses in this module to avoid a dependency loop. + """ + return normalise_things_config(things) + + @property + def thing_configs(self) -> Mapping[ThingName, ThingConfig]: + r"""A copy of the ``things`` field where every value is a ``.ThingConfig``\ . - settings_folder: str | None = None - """The location of the settings folder, or `None` to use the default location.""" + The field validator on ``things`` already ensures it returns a mapping, but + it's not typed strictly, to allow Things to be specified with just a class. + + This property returns the list of `.ThingConfig` objects, and is typed strictly. + """ + return normalise_things_config(self.things) + + settings_folder: str | None = Field( + default=None, + description="The location of the settings folder.", + ) + + +def normalise_things_config(things: ThingsConfig) -> Mapping[ThingName, ThingConfig]: + r"""Ensure every Thing is defined by a `.ThingConfig` object. + + Things may be specified either using a `.ThingConfig` object, or just a bare + `.Thing` subclass, if the other parameters are not needed. To simplify code that + uses the configuration, this function wraps bare classes in a `.ThingConfig` so + the values are uniformly typed. + + :param things: A mapping of names to Things, either classes or `.ThingConfig` + objects. + + :return: A mapping of names to `.ThingConfig` objects. + + :raises ValueError: if a Python object is passed that's neither a `type` nor + a `dict`\ . + """ + normalised: dict[str, ThingConfig] = {} + for k, v in things.items(): + if isinstance(v, ThingConfig): + normalised[k] = v + elif isinstance(v, Mapping): + normalised[k] = ThingConfig.model_validate(v) + elif isinstance(v, type): + normalised[k] = ThingConfig(cls=v) + else: + raise ValueError( + "Things must be specified either as a class or a ThingConfig." + ) + return normalised From 53995692f47e0e3682f1a6b52740881f5322aa8a Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 27 Oct 2025 23:20:47 +0000 Subject: [PATCH 03/15] Tidy up Thing lifecycle and leave validation to the Pydantic models There was duplicated validation code in ThingServer, which I've removed, in favour of using the ThingServerConfig model. I've also split up the code adding Things, so now we: 1. Create Things (and supply a ThingServerInterface) 2. Make connections between Things. 3. Add Things to the API. Step (3) may move out of __init__at some point in the future. --- src/labthings_fastapi/server/__init__.py | 172 +++++++++-------------- 1 file changed, 65 insertions(+), 107 deletions(-) diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index 07a6d9b..0903884 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 Any, AsyncGenerator, Optional, TypeVar +from typing import AsyncGenerator, Optional, TypeVar from typing_extensions import Self import os.path import re @@ -16,7 +16,7 @@ from fastapi.middleware.cors import CORSMiddleware from anyio.from_thread import BlockingPortal from contextlib import asynccontextmanager, AsyncExitStack -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Mapping, Sequence from types import MappingProxyType from ..exceptions import ThingConnectionError as ThingConnectionError @@ -32,7 +32,12 @@ from ..thing_server_interface import ThingServerInterface from ..thing_description._model import ThingDescription from ..dependencies.thing_server import _thing_servers # noqa: F401 -from .config_model import ThingConfig, ThingsConfig, ThingServerConfig +from .config_model import ( + ThingConfig as ThingConfig, + ThingsConfig, + ThingServerConfig, + normalise_things_config as normalise_things_config, +) # `_thing_servers` is used as a global from `ThingServer.__init__` from ..outputs.blob import BlobDataManager @@ -65,9 +70,11 @@ class ThingServer: """ def __init__( - self, things: ThingsConfig, settings_folder: Optional[str] = None + self, + things: ThingsConfig, + settings_folder: Optional[str] = None, ) -> None: - """Initialise a LabThings server. + r"""Initialise a LabThings server. Setting up the `.ThingServer` involves creating the underlying `fastapi.FastAPI` app, setting its lifespan function (used to @@ -77,10 +84,14 @@ def __init__( We also create the `.ActionManager` to manage :ref:`actions` and the `.BlobManager` to manage the downloading of :ref:`blobs`. + :param things: A mapping of Thing names to `.Thing` subclasses, or + `.ThingConfig` objects specifying the subclass, its initialisation + arguments, and any connections to other `.Thing`\ s. :param settings_folder: the location on disk where `.Thing` settings will be saved. """ configure_thing_logger() # Note: this is safe to call multiple times. + self._config = ThingServerConfig(things=things, settings_folder=settings_folder) self.app = FastAPI(lifespan=self.lifespan) self._set_cors_middleware() self.settings_folder = settings_folder or "./settings" @@ -93,7 +104,10 @@ def __init__( self.startup_status: dict[str, str | dict] = {"things": {}} global _thing_servers # noqa: F824 _thing_servers.add(self) - self._things = self._create_and_connect_things(things) + # The function calls below create and set up the Things. + self._things = self._create_things() + self._connect_things() + self._attach_things_to_server() @classmethod def from_config(cls, config: ThingServerConfig) -> Self: @@ -164,106 +178,10 @@ def thing_by_class(self, cls: type[ThingInstance]) -> ThingInstance: f"There are {len(instances)} Things of class {cls}, expected 1." ) - def _create_and_connect_things(self, configs: ThingsConfig) -> Mapping[str, Thing]: - r"""Create the Things, add them to the server, and connect them up if needed. - - This method is responsible for creating instances of `.Thing` subclasses - and adding them to the server. It also ensures the `.Thing`\ s are connected - together if required. - - :param things: A mapping of names to Things. The keys must always be strings, - consisting only of alphanumeric characters and underscores. The values - may be classes, or dictionaries with keys as specified in `.ThingConfig`\ . - - :return: A mapping of names to `.Thing` instances. - """ - connections: dict[str, Mapping[str, str | Iterable[str] | None]] = {} - things: dict[str, Thing] = {} - for name, config in configs.items(): - if isinstance(config, type): - # If a class has been passed, wrap it in a dictionary - config = ThingConfig(thing_subclass=config) - connections[name] = config.thing_connections or {} - things = self._add_thing( - name=name, - thing_subclass=config.thing_subclass, - things=things, - args=config.args, - kwargs=config.kwargs, - ) - self._connect_things(connections) - return things - - def _add_thing( - self, - name: str, - thing_subclass: type[ThingSubclass], - things: dict[str, Thing], - args: Sequence[Any] | None = None, - kwargs: Mapping[str, Any] | None = None, - ) -> dict[str, Thing]: - r"""Add a thing to the server. - - This function will create an instance of ``thing_subclass`` and supply - the ``args`` and ``kwargs`` arguments to its ``__init__`` method. That - instance will then be added to the server with the given name. - - :param name: The name to use for the thing. This will be part of the URL - used to access the thing, and must only contain alphanumeric characters, - hyphens and underscores. - :param thing_subclass: The `.Thing` subclass to add to the server. - :param args: positional arguments to pass to the constructor of - ``thing_subclass``\ . - :param kwargs: keyword arguments to pass to the constructor of - ``thing_subclass``\ . - - :return: a `dict` mapping names to `.Thing` instances. This is the same - dictionary supplied as ``things`` but with the additional `.Thing` - instance added. - - :raise ValueError: if ``path`` contains invalid characters. - :raise KeyError: if a `.Thing` has already been added at ``path``\ . - :raise TypeError: if ``thing_subclass`` is not a subclass of `.Thing` - or if ``name`` is not string-like. This usually means arguments - are being passed the wrong way round. - """ - if not isinstance(name, str): - raise TypeError("Thing names must be strings.") - if PATH_REGEX.match(name) is None: - msg = ( - f"'{name}' contains unsafe characters. Use only alphanumeric " - "characters, hyphens and underscores" - ) - raise ValueError(msg) - if name in things: - raise KeyError(f"{name} has already been added to this thing server.") - if not issubclass(thing_subclass, Thing): - raise TypeError(f"{thing_subclass} is not a Thing subclass.") - if args is None: - args = [] - if kwargs is None: - kwargs = {} - interface = ThingServerInterface(name=name, server=self) - os.makedirs(interface.settings_folder, exist_ok=True) - # This is where we instantiate the Thing - # I've had to ignore this line because the *args causes an error. - # Given that *args and **kwargs are very loosely typed anyway, this - # doesn't lose us much. - thing = thing_subclass( - *args, - **kwargs, - thing_server_interface=interface, - ) # type: ignore[misc] - things[name] = thing - thing.attach_to_server( - server=self, - ) - return things - def path_for_thing(self, name: str) -> str: """Return the path for a thing with the given name. - :param name: The name of the thing, as passed to `.add_thing`. + :param name: The name of the thing. :return: The path at which the thing is served. @@ -273,9 +191,40 @@ def path_for_thing(self, name: str) -> str: raise KeyError(f"No thing named {name} has been added to this server.") return f"/{name}/" - def _connect_things( - self, connections: dict[str, Mapping[str, str | Iterable[str] | None]] - ) -> None: + def _create_things(self) -> Mapping[str, Thing]: + r"""Create the Things, add them to the server, and connect them up if needed. + + This method is responsible for creating instances of `.Thing` subclasses + and adding them to the server. It also ensures the `.Thing`\ s are connected + together if required. + + The Things are defined in ``self._things_config`` which in turn is generated + from the ``things`` argument to ``__init__``\ . + + :return: A mapping of names to `.Thing` instances. + + :raise ValueError: if a thing name contains invalid characters. + :raise TypeError: if ``cls`` is not a subclass of `.Thing` + or if ``name`` is not string-like. + """ + things: dict[str, Thing] = {} + for name, config in self._config.thing_configs.items(): + if not issubclass(config.cls, Thing): + raise TypeError(f"{config.cls} is not a Thing subclass.") + interface = ThingServerInterface(name=name, server=self) + os.makedirs(interface.settings_folder, exist_ok=True) + # This is where we instantiate the Thing + # I've had to type ignore this line because the *args causes an error. + # Given that *args and **kwargs are very loosely typed anyway, this + # doesn't lose us much. + things[name] = config.cls( + *config.args, + **config.kwargs, + thing_server_interface=interface, + ) # type: ignore[misc] + return things + + def _connect_things(self) -> None: r"""Connect the `thing_connection` attributes of Things. A `.Thing` may have attributes defined as ``lt.thing_connection()``, which @@ -296,13 +245,22 @@ def _connect_things( named `"foo"`\ , you could pass ``{"mything": {"myslot": "foo"}}``\ . """ for thing_name, thing in self.things.items(): - config = connections.get(thing_name, {}) + config = self._config.thing_configs[thing_name].thing_connections for attr_name, attr in class_attributes(thing): if not isinstance(attr, ThingConnection): continue target = config.get(attr_name, ...) attr.connect(thing, self.things, target) + def _attach_things_to_server(self) -> None: + """Add the Things to the FastAPI App. + + This calls `.Thing.attach_to_server` on each `.Thing` that is a part of + this `.ThingServer` in order to add the HTTP endpoints and load settings. + """ + for thing in self.things.values(): + thing.attach_to_server(self) + @asynccontextmanager async def lifespan(self, app: FastAPI) -> AsyncGenerator[None]: """Manage set up and tear down of the server and Things. From d9689a0fb6a4c8376157994270c1c9f81beac14f Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 27 Oct 2025 23:22:49 +0000 Subject: [PATCH 04/15] Don't print stack traces for validation errors. If the server is started from the command line, we print out any validation errors, then exit: a stack trace is not helpful. This also fixes an issue where ValidationError would not serialise properly, which stopped the test code working (it used multiprocessing). --- src/labthings_fastapi/server/cli.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/labthings_fastapi/server/cli.py b/src/labthings_fastapi/server/cli.py index a3d956f..8a15761 100644 --- a/src/labthings_fastapi/server/cli.py +++ b/src/labthings_fastapi/server/cli.py @@ -19,8 +19,11 @@ """ from argparse import ArgumentParser, Namespace +import sys from typing import Optional +from pydantic import ValidationError + from ..utilities.object_reference_to_object import ( object_reference_to_object, ) @@ -124,6 +127,8 @@ def serve_from_cli( if ``labthings-server`` is being run on a headless server, where an HTTP error page is more useful than no response. + If ``fallback`` is not specified, we will print the error and exit. + :param argv: command line arguments (defaults to arguments supplied to the current command). :param dry_run: may be set to ``True`` to terminate after the server @@ -155,5 +160,9 @@ def serve_from_cli( app.labthings_error = e uvicorn.run(app, host=args.host, port=args.port) else: - raise e + if isinstance(e, ValidationError): + print(f"Error reading LabThings configuration:\n{e}") + sys.exit(3) + else: + raise e return None # This is required as we sometimes return the server From f3aedd86b9d3040069dc9001a70cb18b29f48e90 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 27 Oct 2025 23:25:31 +0000 Subject: [PATCH 05/15] Update test code to pass Thing classes to ThingServer.__init__ I've removed ThingServer.add_thing in favour of passing a dictionary to ThingServer.__init__ and the test code is updated to reflect that. As a rule, when thing instances were required, I would: 1. Create the server 2. Assign `my_thing = server.things["my_thing"]` 3. Assert `assert isinstance(my_thing, MyThing)` That was enough for `pyright` (and I guess mypy) to correctly infer the type in my test code. It's slightly more verbose, but means we can use the same code in test and production, rather than needing a different way to create/add things in test code. --- .../test_action_cancel.py | 3 +- .../test_action_logging.py | 3 +- .../test_dependency_metadata.py | 9 +- .../test_directthingclient.py | 9 +- .../test_thing_dependencies.py | 15 +-- tests/test_action_cancel.py | 3 +- tests/test_action_logging.py | 3 +- tests/test_action_manager.py | 3 +- tests/test_actions.py | 3 +- tests/test_blob_output.py | 9 +- tests/test_endpoint_decorator.py | 3 +- tests/test_fallback.py | 14 ++- tests/test_locking_decorator.py | 3 +- tests/test_mjpeg_stream.py | 8 +- tests/test_properties.py | 3 +- tests/test_server.py | 8 +- tests/test_server_cli.py | 6 +- tests/test_server_config_model.py | 94 ++++++++++++++++ tests/test_settings.py | 93 ++++++++++------ tests/test_thing.py | 3 +- tests/test_thing_connection.py | 101 +++++++++--------- tests/test_thing_lifecycle.py | 19 +++- tests/test_thing_server_interface.py | 8 +- tests/test_websocket.py | 3 +- 24 files changed, 278 insertions(+), 148 deletions(-) create mode 100644 tests/test_server_config_model.py diff --git a/tests/old_dependency_tests/test_action_cancel.py b/tests/old_dependency_tests/test_action_cancel.py index cf79c41..43c6af4 100644 --- a/tests/old_dependency_tests/test_action_cancel.py +++ b/tests/old_dependency_tests/test_action_cancel.py @@ -72,8 +72,7 @@ def count_and_only_cancel_if_asked_twice( @pytest.fixture def server(): """Create a server with a CancellableCountingThing added.""" - server = lt.ThingServer() - server.add_thing("counting_thing", CancellableCountingThing) + server = lt.ThingServer({"counting_thing": CancellableCountingThing}) return server diff --git a/tests/old_dependency_tests/test_action_logging.py b/tests/old_dependency_tests/test_action_logging.py index 2c521d3..3bcd158 100644 --- a/tests/old_dependency_tests/test_action_logging.py +++ b/tests/old_dependency_tests/test_action_logging.py @@ -34,8 +34,7 @@ def action_with_invocation_error(self, logger: lt.deps.InvocationLogger): @pytest.fixture def client(): """Set up a Thing Server and yield a client to it.""" - server = lt.ThingServer() - server.add_thing("log_and_error_thing", ThingThatLogsAndErrors) + server = lt.ThingServer({"log_and_error_thing": ThingThatLogsAndErrors}) with TestClient(server.app) as client: yield client diff --git a/tests/old_dependency_tests/test_dependency_metadata.py b/tests/old_dependency_tests/test_dependency_metadata.py index 47d264e..a0d49e5 100644 --- a/tests/old_dependency_tests/test_dependency_metadata.py +++ b/tests/old_dependency_tests/test_dependency_metadata.py @@ -61,9 +61,12 @@ def count_and_watch( @pytest.fixture def client(): """Yield a test client connected to a ThingServer.""" - server = lt.ThingServer() - server.add_thing("thing_one", ThingOne) - server.add_thing("thing_two", ThingTwo) + server = lt.ThingServer( + { + "thing_one": ThingOne, + "thing_two": ThingTwo, + } + ) with TestClient(server.app) as client: yield client diff --git a/tests/old_dependency_tests/test_directthingclient.py b/tests/old_dependency_tests/test_directthingclient.py index 381ee39..6cc2aa9 100644 --- a/tests/old_dependency_tests/test_directthingclient.py +++ b/tests/old_dependency_tests/test_directthingclient.py @@ -144,9 +144,12 @@ def test_directthingclient_in_server(action): This uses the internal thing client mechanism. """ - server = lt.ThingServer() - server.add_thing("counter", Counter) - server.add_thing("controller", Controller) + server = lt.ThingServer( + { + "counter": Counter, + "controller": Controller, + } + ) with TestClient(server.app) as client: r = client.post(f"/controller/{action}") invocation = poll_task(client, r.json()) diff --git a/tests/old_dependency_tests/test_thing_dependencies.py b/tests/old_dependency_tests/test_thing_dependencies.py index e66c7cb..ab1b0c8 100644 --- a/tests/old_dependency_tests/test_thing_dependencies.py +++ b/tests/old_dependency_tests/test_thing_dependencies.py @@ -80,9 +80,7 @@ def test_interthing_dependency(): This uses the internal thing client mechanism. """ - server = lt.ThingServer() - server.add_thing("thing_one", ThingOne) - server.add_thing("thing_two", ThingTwo) + server = lt.ThingServer({"thing_one": ThingOne, "thing_two": ThingTwo}) with TestClient(server.app) as client: r = client.post("/thing_two/action_two") invocation = poll_task(client, r.json()) @@ -96,10 +94,9 @@ def test_interthing_dependency_with_dependencies(): This uses the internal thing client mechanism, and requires dependency injection for the called action """ - server = lt.ThingServer() - server.add_thing("thing_one", ThingOne) - server.add_thing("thing_two", ThingTwo) - server.add_thing("thing_three", ThingThree) + server = lt.ThingServer( + {"thing_one": ThingOne, "thing_two": ThingTwo, "thing_three": ThingThree} + ) with TestClient(server.app) as client: r = client.post("/thing_three/action_three") r.raise_for_status() @@ -121,9 +118,7 @@ def action_two(self, thing_one: ThingOneDep) -> str: """An action that needs a ThingOne""" return thing_one.action_one() - server = lt.ThingServer() - server.add_thing("thing_one", ThingOne) - server.add_thing("thing_two", ThingTwo) + server = lt.ThingServer({"thing_one": ThingOne, "thing_two": ThingTwo}) with TestClient(server.app) as client: r = client.post("/thing_two/action_two") invocation = poll_task(client, r.json()) diff --git a/tests/test_action_cancel.py b/tests/test_action_cancel.py index d9be8a7..2713fd8 100644 --- a/tests/test_action_cancel.py +++ b/tests/test_action_cancel.py @@ -68,8 +68,7 @@ def count_and_only_cancel_if_asked_twice(self, n: int = 10): @pytest.fixture def server(): """Create a server with a CancellableCountingThing added.""" - server = lt.ThingServer() - server.add_thing("counting_thing", CancellableCountingThing) + server = lt.ThingServer({"counting_thing": CancellableCountingThing}) return server diff --git a/tests/test_action_logging.py b/tests/test_action_logging.py index 9804ae7..39749ea 100644 --- a/tests/test_action_logging.py +++ b/tests/test_action_logging.py @@ -34,8 +34,7 @@ def action_with_invocation_error(self): @pytest.fixture def client(): """Set up a Thing Server and yield a client to it.""" - server = lt.ThingServer() - server.add_thing("log_and_error_thing", ThingThatLogsAndErrors) + server = lt.ThingServer({"log_and_error_thing": ThingThatLogsAndErrors}) with TestClient(server.app) as client: yield client diff --git a/tests/test_action_manager.py b/tests/test_action_manager.py index 5da65ba..507806b 100644 --- a/tests/test_action_manager.py +++ b/tests/test_action_manager.py @@ -28,8 +28,7 @@ def increment_counter_longlife(self): @pytest.fixture def client(): """Yield a TestClient connected to a ThingServer.""" - server = lt.ThingServer() - server.add_thing("thing", CounterThing) + server = lt.ThingServer({"thing": CounterThing}) with TestClient(server.app) as client: yield client diff --git a/tests/test_actions.py b/tests/test_actions.py index 3227087..c2c7b90 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -12,8 +12,7 @@ @pytest.fixture def client(): """Yield a client connected to a ThingServer""" - server = lt.ThingServer() - server.add_thing("thing", MyThing) + server = lt.ThingServer({"thing": MyThing}) with TestClient(server.app) as client: yield client diff --git a/tests/test_blob_output.py b/tests/test_blob_output.py index 32fdb94..ae95532 100644 --- a/tests/test_blob_output.py +++ b/tests/test_blob_output.py @@ -71,9 +71,12 @@ def check_passthrough(self, thing_one: ThingOneDep) -> bool: @pytest.fixture def client(): """Yield a test client connected to a ThingServer.""" - server = lt.ThingServer() - server.add_thing("thing_one", ThingOne) - server.add_thing("thing_two", ThingTwo) + server = lt.ThingServer( + { + "thing_one": ThingOne, + "thing_two": ThingTwo, + } + ) with TestClient(server.app) as client: yield client diff --git a/tests/test_endpoint_decorator.py b/tests/test_endpoint_decorator.py index 6360704..9348d43 100644 --- a/tests/test_endpoint_decorator.py +++ b/tests/test_endpoint_decorator.py @@ -24,8 +24,7 @@ def post_method(self, body: PostBodyModel) -> str: def test_endpoints(): """Check endpoints may be added to the app and work as expected.""" - server = lt.ThingServer() - server.add_thing("thing", MyThing) + server = lt.ThingServer({"thing": MyThing}) thing = server.things["thing"] with TestClient(server.app) as client: # Check the function works when used directly diff --git a/tests/test_fallback.py b/tests/test_fallback.py index 257ccda..bc0619a 100644 --- a/tests/test_fallback.py +++ b/tests/test_fallback.py @@ -1,5 +1,12 @@ +"""Test the fallback server. + +If the server is started from the command line, with ``--fallback`` specified, +we start a lightweight fallback server to show an error message. This test +verifies that it works as expected. +""" + from fastapi.testclient import TestClient -from labthings_fastapi.server import server_from_config +import labthings_fastapi as lt from labthings_fastapi.server.fallback import app @@ -34,7 +41,7 @@ def test_fallback_with_error(): def test_fallback_with_server(): - config = { + config_dict = { "things": { "thing1": "labthings_fastapi.example_things:MyThing", "thing2": { @@ -43,7 +50,8 @@ def test_fallback_with_server(): }, } } - app.labthings_server = server_from_config(config) + config = lt.ThingServerConfig.model_validate(config_dict) + app.labthings_server = lt.ThingServer.from_config(config) with TestClient(app) as client: response = client.get("/") html = response.text diff --git a/tests/test_locking_decorator.py b/tests/test_locking_decorator.py index 798bfd1..c256677 100644 --- a/tests/test_locking_decorator.py +++ b/tests/test_locking_decorator.py @@ -115,8 +115,7 @@ def echo_via_client(client): def test_locking_in_server(): """Check the lock works within LabThings.""" - server = lt.ThingServer() - server.add_thing("thing", LockedExample) + server = lt.ThingServer({"thing": LockedExample}) thing = server.things["thing"] with TestClient(server.app) as client: # Start a long task diff --git a/tests/test_mjpeg_stream.py b/tests/test_mjpeg_stream.py index a780d10..0effde3 100644 --- a/tests/test_mjpeg_stream.py +++ b/tests/test_mjpeg_stream.py @@ -46,8 +46,7 @@ def _make_images(self): @pytest.fixture def client(): """Yield a test client connected to a ThingServer""" - server = lt.ThingServer() - server.add_thing("telly", Telly) + server = lt.ThingServer({"telly": Telly}) with TestClient(server.app) as client: yield client @@ -74,8 +73,9 @@ def test_mjpeg_stream(client): if __name__ == "__main__": import uvicorn - server = lt.ThingServer() - telly = server.add_thing("telly", Telly) + server = lt.ThingServer({"telly": Telly}) + telly = server.things["telly"] + assert isinstance(telly, Telly) telly.framerate = 6 telly.frame_limit = -1 uvicorn.run(server.app, port=5000) diff --git a/tests/test_properties.py b/tests/test_properties.py index a50a8b4..fd6da3a 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -51,8 +51,7 @@ def toggle_boolprop_from_thread(self): @pytest.fixture def server(): - server = lt.ThingServer() - server.add_thing("thing", PropertyTestThing) + server = lt.ThingServer({"thing": PropertyTestThing}) return server diff --git a/tests/test_server.py b/tests/test_server.py index bf12abb..fcf85a3 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -7,10 +7,14 @@ """ import pytest -from labthings_fastapi import server as ts +import labthings_fastapi as lt def test_server_from_config_non_thing_error(): """Test a typeerror is raised if something that's not a Thing is added.""" with pytest.raises(TypeError, match="not a Thing"): - ts.server_from_config({"things": {"thingone": {"class": "builtins:object"}}}) + lt.ThingServer.from_config( + lt.ThingServerConfig( + things={"thingone": lt.ThingConfig(cls="builtins:object")} + ) + ) diff --git a/tests/test_server_cli.py b/tests/test_server_cli.py index 0ec3a84..2a39109 100644 --- a/tests/test_server_cli.py +++ b/tests/test_server_cli.py @@ -7,7 +7,6 @@ import pytest from labthings_fastapi import ThingServer -from labthings_fastapi.server import server_from_config from labthings_fastapi.server.cli import serve_from_cli @@ -82,7 +81,7 @@ def run_monitored(self, terminate_outputs=None, timeout=10): def test_server_from_config(): """Check we can create a server from a config object""" - server = server_from_config(CONFIG) + server = ThingServer.from_config(CONFIG) assert isinstance(server, ThingServer) @@ -138,8 +137,9 @@ def test_invalid_thing(): } } ) - with raises(ImportError): + with raises(SystemExit) as excinfo: check_serve_from_cli(["-j", config_json]) + assert excinfo.value.code == 3 @pytest.mark.slow diff --git a/tests/test_server_config_model.py b/tests/test_server_config_model.py new file mode 100644 index 0000000..50c8e08 --- /dev/null +++ b/tests/test_server_config_model.py @@ -0,0 +1,94 @@ +r"""Test code for `.server.config_model`\ .""" + +from pydantic import ValidationError +import pytest +from labthings_fastapi.server import config_model as cm +import labthings_fastapi.example_things +from labthings_fastapi.example_things import MyThing + + +def test_ThingConfig(): + """Test the ThingConfig model loads classes as expected.""" + # We should be able to create a valid config with just a class + direct = cm.ThingConfig(cls=labthings_fastapi.example_things.MyThing) + # Equivalently, we should be able to pass a string + fromstr = cm.ThingConfig(cls="labthings_fastapi.example_things:MyThing") + assert direct.cls is MyThing + assert fromstr.cls is MyThing + # In the absence of supplied arguments, default factories should be used + assert len(direct.args) == 0 + assert direct.kwargs == {} + assert direct.thing_connections == {} + + with pytest.raises(ValidationError, match="No module named"): + cm.ThingConfig(cls="missing.module") + + +VALID_THING_CONFIGS = { + "direct": MyThing, + "string": "labthings_fastapi.example_things:MyThing", + "model_d": cm.ThingConfig(cls=MyThing), + "model_s": cm.ThingConfig(cls="labthings_fastapi.example_things:MyThing"), + "dict_d": {"cls": MyThing}, + "dict_da": {"class": MyThing}, + "dict_s": {"cls": "labthings_fastapi.example_things:MyThing"}, + "dict_sa": {"class": "labthings_fastapi.example_things:MyThing"}, +} + + +INVALID_THING_CONFIGS = [ + {}, + {"foo": "bar"}, + {"class": MyThing, "kwargs": 1}, + "missing.module:object", + 4, + None, + False, +] + + +VALID_THING_NAMES = [ + "my_thing", + "MyThing", + "Something", + "f90785342", + "1", +] + +INVALID_THING_NAMES = [ + "", + "spaces in name", + "special * chars", + False, + 1, +] + + +def test_ThingServerConfig(): + """Check validation of the whole server config.""" + # Things should be able to be specified as a string, a class, or a ThingConfig + config = cm.ThingServerConfig(things=VALID_THING_CONFIGS) + assert len(config.thing_configs) == 8 + for v in config.thing_configs.values(): + assert v.cls is MyThing + + # When we validate from a dict, the same options work + config = cm.ThingServerConfig.model_validate({"things": VALID_THING_CONFIGS}) + assert len(config.thing_configs) == 8 + for v in config.thing_configs.values(): + assert v.cls is MyThing + + # Check invalid configs are picked up + for spec in INVALID_THING_CONFIGS: + with pytest.raises(ValidationError): + cm.ThingServerConfig(things={"thing": spec}) + + # Check valid names are allowed + for name in VALID_THING_NAMES: + sc = cm.ThingServerConfig(things={name: MyThing}) + assert sc.thing_configs[name].cls is MyThing + + # Check bad names raise errors + for name in INVALID_THING_NAMES: + with pytest.raises(ValidationError): + cm.ThingServerConfig(things={name: MyThing}) diff --git a/tests/test_settings.py b/tests/test_settings.py index b138dcd..aea1520 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -146,8 +146,9 @@ def directly_set_localonly_boolsetting( test_thing.localonly_boolsetting = val -def _get_setting_file(server, thingpath): - path = os.path.join(server.settings_folder, thingpath.lstrip("/"), "settings.json") +def _get_setting_file(server: lt.ThingServer, name: str): + """Find the location of the settings file for a given Thing on a server.""" + path = server.things[name]._thing_server_interface.settings_file_path return os.path.normpath(path) @@ -176,11 +177,12 @@ def _settings_dict( @pytest.fixture -def server(): +def tempdir(): + """A temporary directory""" with tempfile.TemporaryDirectory() as tempdir: - # Yield server rather than return so that the temp directory isn't cleaned up + # Yield rather than return so that the temp directory isn't cleaned up # until after the test is run - yield lt.ThingServer(settings_folder=tempdir) + yield tempdir def test_setting_available(): @@ -193,13 +195,13 @@ def test_setting_available(): assert thing.dictsetting == {"a": 1, "b": 2} -def test_functional_settings_save(server): +def test_functional_settings_save(tempdir): """Check updated settings are saved to disk ``floatsetting`` is a functional setting, we should also test a `.DataSetting` for completeness.""" - setting_file = _get_setting_file(server, "/thing") - server.add_thing("thing", ThingWithSettings) + server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + setting_file = _get_setting_file(server, "thing") # No setting file created when first added assert not os.path.isfile(setting_file) with TestClient(server.app) as client: @@ -219,13 +221,13 @@ def test_functional_settings_save(server): assert json.load(file_obj) == _settings_dict(floatsetting=2.0) -def test_data_settings_save(server): +def test_data_settings_save(tempdir): """Check updated settings are saved to disk This uses ``intsetting`` which is a `.DataSetting` so it tests a different code path to the functional setting above.""" - setting_file = _get_setting_file(server, "/thing") - server.add_thing("thing", ThingWithSettings) + server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + setting_file = _get_setting_file(server, "thing") # The settings file should not be created yet - it's created the # first time we write to a setting. assert not os.path.isfile(setting_file) @@ -256,7 +258,7 @@ def test_data_settings_save(server): "method", ["http", "direct_thing_client", "direct"], ) -def test_readonly_setting(server, endpoint, value, method): +def test_readonly_setting(tempdir, endpoint, value, method): """Check read-only functional settings cannot be set remotely. Functional settings must always have a setter, and will be @@ -271,9 +273,11 @@ def test_readonly_setting(server, endpoint, value, method): The test is parametrized so it will run 6 times, trying one block of code inside the ``with`` block each time. """ - setting_file = _get_setting_file(server, "/thing") - server.add_thing("thing", ThingWithSettings) - server.add_thing("client_thing", ClientThing) + server = lt.ThingServer( + things={"thing": ThingWithSettings, "client_thing": ClientThing}, + settings_folder=tempdir, + ) + setting_file = _get_setting_file(server, "thing") # No setting file created when first added assert not os.path.isfile(setting_file) @@ -319,10 +323,12 @@ def test_readonly_setting(server, endpoint, value, method): assert not os.path.isfile(setting_file) # No file created -def test_settings_dict_save(server): +def test_settings_dict_save(tempdir): """Check settings are saved if the dict is updated in full""" - setting_file = _get_setting_file(server, "/thing") - thing = server.add_thing("thing", ThingWithSettings) + server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + setting_file = _get_setting_file(server, "thing") + thing = server.things["thing"] + assert isinstance(thing, ThingWithSettings) # No setting file created when first added assert not os.path.isfile(setting_file) with TestClient(server.app): @@ -333,14 +339,16 @@ def test_settings_dict_save(server): assert json.load(file_obj) == _settings_dict(dictsetting={"c": 3}) -def test_settings_dict_internal_update(server): +def test_settings_dict_internal_update(tempdir): """Confirm settings are not saved if the internal value of a dictionary is updated This behaviour is not ideal, but it is documented. If the behaviour is updated then the documentation should be updated and this test removed """ - setting_file = _get_setting_file(server, "/thing") - thing = server.add_thing("thing", ThingWithSettings) + server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + setting_file = _get_setting_file(server, "thing") + thing = server.things["thing"] + assert isinstance(thing, ThingWithSettings) # No setting file created when first added assert not os.path.isfile(setting_file) with TestClient(server.app): @@ -350,64 +358,81 @@ def test_settings_dict_internal_update(server): assert not os.path.isfile(setting_file) -def test_settings_load(server): +def test_settings_load(tempdir): """Check settings can be loaded from disk when added to server""" - setting_file = _get_setting_file(server, "/thing") + server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + setting_file = _get_setting_file(server, "thing") + del server setting_json = json.dumps(_settings_dict(floatsetting=3.0, stringsetting="bar")) # Create setting file - os.makedirs(os.path.dirname(setting_file)) with open(setting_file, "w", encoding="utf-8") as file_obj: file_obj.write(setting_json) # Add thing to server and check new settings are loaded - thing = server.add_thing("thing", ThingWithSettings) + server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + thing = server.things["thing"] + assert isinstance(thing, ThingWithSettings) assert not thing.boolsetting assert thing.stringsetting == "bar" assert thing.floatsetting == 3.0 -def test_load_extra_settings(server, caplog): +def test_load_extra_settings(caplog, tempdir): """Load from setting file. Extra setting in file should create a warning.""" - setting_file = _get_setting_file(server, "/thing") + server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + setting_file = _get_setting_file(server, "thing") + del server setting_dict = _settings_dict(floatsetting=3.0, stringsetting="bar") setting_dict["extra_setting"] = 33.33 setting_json = json.dumps(setting_dict) # Create setting file - os.makedirs(os.path.dirname(setting_file)) with open(setting_file, "w", encoding="utf-8") as file_obj: file_obj.write(setting_json) with caplog.at_level(logging.WARNING): - # Add thing to server - thing = server.add_thing("thing", ThingWithSettings) + # Create the server with the Thing added. + server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) assert len(caplog.records) == 1 assert caplog.records[0].levelname == "WARNING" assert caplog.records[0].name == "labthings_fastapi.thing" + # Get the instance of the ThingWithSettings + thing = server.things["thing"] + assert isinstance(thing, ThingWithSettings) + # Check other settings are loaded as expected assert not thing.boolsetting assert thing.stringsetting == "bar" assert thing.floatsetting == 3.0 -def test_try_loading_corrupt_settings(server, caplog): +def test_try_loading_corrupt_settings(tempdir, caplog): """Load from setting file. Extra setting in file should create a warning.""" - setting_file = _get_setting_file(server, "/thing") + # Create the server once, so we can get the settings path + server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + setting_file = _get_setting_file(server, "thing") + del server + + # Construct a broken settings file setting_dict = _settings_dict(floatsetting=3.0, stringsetting="bar") setting_json = json.dumps(setting_dict) # Cut the start off the json to so it can't be decoded. setting_json = setting_json[3:] # Create setting file - os.makedirs(os.path.dirname(setting_file)) with open(setting_file, "w", encoding="utf-8") as file_obj: file_obj.write(setting_json) + # Recreate the server and check for warnings with caplog.at_level(logging.WARNING): # Add thing to server - thing = server.add_thing("thing", ThingWithSettings) + server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) assert len(caplog.records) == 1 assert caplog.records[0].levelname == "WARNING" assert caplog.records[0].name == "labthings_fastapi.thing" + # Get the instance of the ThingWithSettings + thing = server.things["thing"] + assert isinstance(thing, ThingWithSettings) + # Check default settings are loaded assert not thing.boolsetting assert thing.stringsetting == "foo" diff --git a/tests/test_thing.py b/tests/test_thing.py index 375c33e..988a064 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -11,6 +11,5 @@ def test_td_validates(): def test_add_thing(): """Check that thing can be added to the server""" - server = ThingServer() - server.add_thing("thing", MyThing) + server = ThingServer({"thing": MyThing}) assert isinstance(server.things["thing"], MyThing) diff --git a/tests/test_thing_connection.py b/tests/test_thing_connection.py index 05b71ea..fa846a0 100644 --- a/tests/test_thing_connection.py +++ b/tests/test_thing_connection.py @@ -6,7 +6,7 @@ import labthings_fastapi as lt from fastapi.testclient import TestClient -from labthings_fastapi.exceptions import ThingConnectionError, ThingNotConnectedError +from labthings_fastapi.exceptions import ThingConnectionError class ThingOne(lt.Thing): @@ -355,19 +355,17 @@ def test_circular_connection(cls_1, cls_2, connections) -> None: Thing classes. Circular dependencies should not cause any problems for the LabThings server. """ - server = lt.ThingServer() - thing_one = server.add_thing( - "thing_one", cls_1, thing_connections=connections.get("thing_one", {}) + server = lt.ThingServer( + things={ + "thing_one": lt.ThingConfig( + cls=cls_1, thing_connections=connections.get("thing_one", {}) + ), + "thing_two": lt.ThingConfig( + cls=cls_2, thing_connections=connections.get("thing_two", {}) + ), + } ) - thing_two = server.add_thing( - "thing_two", cls_2, thing_connections=connections.get("thing_two", {}) - ) - things = [thing_one, thing_two] - - # Check the connections don't work initially, because they aren't connected - for thing in things: - with pytest.raises(ThingNotConnectedError): - _ = thing.other_thing + things = [server.things[n] for n in ["thing_one", "thing_two"]] with TestClient(server.app) as _: # The things should be connected as the server is now running @@ -375,18 +373,6 @@ def test_circular_connection(cls_1, cls_2, connections) -> None: assert thing.other_thing is other -def connectionerror_starting_server(server): - """Attempt to start a server, and return the error as a string.""" - with pytest.RaisesGroup(ThingConnectionError) as excinfo: - # Creating a TestClient starts the server - with TestClient(server.app): - pass - # excinfo contains an ExceptionGroup because TestClient runs in a - # task group, hence the use of RaisesGroup and the `.exceptions[0]` - # below. - return str(excinfo.value.exceptions[0]) - - @pytest.mark.parametrize( ("connections", "error"), [ @@ -409,28 +395,37 @@ def test_connections_none_default(connections, error): to specify connections for 'thing_two' in the last case - because that's the only one where 'thing_one' connects successfully. """ - server = lt.ThingServer() - thing_one = server.add_thing("thing_one", ThingN) - server.add_thing("thing_two", ThingN) - server.add_thing("thing_three", ThingThree) - - server.thing_connections = connections + things = { + "thing_one": lt.ThingConfig( + cls=ThingN, thing_connections=connections.get("thing_one", {}) + ), + "thing_two": lt.ThingConfig( + cls=ThingN, thing_connections=connections.get("thing_two", {}) + ), + "thing_three": lt.ThingConfig( + cls=ThingThree, thing_connections=connections.get("thing_three", {}) + ), + } if error is None: + server = lt.ThingServer(things) with TestClient(server.app): + thing_one = server.things["thing_one"] + assert isinstance(thing_one, ThingN) assert thing_one.other_thing is thing_one return - assert error in connectionerror_starting_server(server) + with pytest.raises(ThingConnectionError, match=error): + server = lt.ThingServer(things) def test_optional_and_empty(): """Check that an optional or mapping connection can be None/empty.""" - server = lt.ThingServer() - thing_one = server.add_thing("thing_one", ThingOne) - _thing_two = server.add_thing("thing_two", ThingTwo) + server = lt.ThingServer({"thing_one": ThingOne, "thing_two": ThingTwo}) with TestClient(server.app): + thing_one = server.things["thing_one"] + assert isinstance(thing_one, ThingOne) assert thing_one.optional_thing is None assert len(thing_one.n_things) == 0 @@ -441,27 +436,27 @@ def test_mapping_and_multiple(): This also tests the expected error if multiple things match a single connection. """ - server = lt.ThingServer() - thing_one = server.add_thing("thing_one", ThingOne) - _thing_two = server.add_thing("thing_two", ThingTwo) - for i in range(3): - server.add_thing(f"thing_{i + 3}", ThingThree) - - # Attempting to start the server should fail, because + things = { + "thing_one": ThingOne, + "thing_two": ThingTwo, + "thing_3": ThingThree, + "thing_4": ThingThree, + "thing_5": ThingThree, + } + # We can't set up a server like this, because # thing_one.optional_thing will match multiple ThingThree instances. - assert "multiple Things" in connectionerror_starting_server(server) + with pytest.raises(ThingConnectionError, match="multiple Things"): + server = lt.ThingServer(things) # Set optional thing to one specific name and it will start OK. - server.thing_connections = {"thing_one": {"optional_thing": "thing_3"}} - + things["thing_one"] = lt.ThingConfig( + cls=ThingOne, + thing_connections={"optional_thing": "thing_3"}, + ) + server = lt.ThingServer(things) with TestClient(server.app): + thing_one = server.things["thing_one"] + assert isinstance(thing_one, ThingOne) + assert thing_one.optional_thing is not None assert thing_one.optional_thing.name == "thing_3" assert names_set(thing_one.n_things) == {f"thing_{i + 3}" for i in range(3)} - - -def test_connections_in_server(): - r"Check that ``thing_connections`` is correctly remembered from ``add_thing``\ ." - server = lt.ThingServer() - thing_one_connections = {"other_thing": "thing_name"} - server.add_thing("thing_one", ThingOne, thing_connections=thing_one_connections) - assert server.thing_connections["thing_one"] is thing_one_connections diff --git a/tests/test_thing_lifecycle.py b/tests/test_thing_lifecycle.py index 6be7554..e737506 100644 --- a/tests/test_thing_lifecycle.py +++ b/tests/test_thing_lifecycle.py @@ -1,8 +1,9 @@ +import pytest import labthings_fastapi as lt from fastapi.testclient import TestClient -class TestThing(lt.Thing): +class LifecycleThing(lt.Thing): alive: bool = lt.property(default=False) "Whether the thing is alive." @@ -16,11 +17,19 @@ def __exit__(self, *args): self.alive = False -server = lt.ThingServer() -thing = server.add_thing("thing", TestThing) +@pytest.fixture +def server(): + """A ThingServer with a LifecycleThing.""" + return lt.ThingServer({"thing": LifecycleThing}) -def test_thing_alive(): +@pytest.fixture +def thing(server): + """The thing attached to our server.""" + return server.things["thing"] + + +def test_thing_alive(server, thing): assert thing.alive is False with TestClient(server.app) as client: assert thing.alive is True @@ -29,7 +38,7 @@ def test_thing_alive(): assert thing.alive is False -def test_thing_alive_twice(): +def test_thing_alive_twice(server, thing): """It's unlikely we need to stop and restart the server within one Python session, except for testing. This test should explicitly make sure our lifecycle stuff is closing down cleanly and can restart. diff --git a/tests/test_thing_server_interface.py b/tests/test_thing_server_interface.py index 82914d0..c63a0b1 100644 --- a/tests/test_thing_server_interface.py +++ b/tests/test_thing_server_interface.py @@ -26,8 +26,10 @@ def thing_state(self): def server(): """Return a LabThings server""" with tempfile.TemporaryDirectory() as dir: - server = lt.ThingServer(settings_folder=dir) - server.add_thing("example", ExampleThing) + server = lt.ThingServer( + things={"example": ExampleThing}, + settings_folder=dir, + ) yield server @@ -57,7 +59,7 @@ def test_get_server_error(): This is an error condition that I would find surprising if it ever occurred, but it's worth checking. """ - server = lt.ThingServer() + server = lt.ThingServer(things={}) interface = tsi.ThingServerInterface(server, NAME) assert interface._get_server() is server del server diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 00d7947..41e5ee3 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -55,8 +55,7 @@ def cancel_myself(self): @pytest.fixture def server(): """Create a server, and add a MyThing test Thing to it.""" - server = lt.ThingServer() - server.add_thing("thing", ThingWithProperties) + server = lt.ThingServer({"thing": ThingWithProperties}) return server From 869d7ac539a10a3c631b53bc4127a3bfd8899d9b Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 27 Oct 2025 23:26:01 +0000 Subject: [PATCH 06/15] Update example/documentation code examples. --- docs/source/dependencies/example.py | 9 ++++++--- docs/source/quickstart/counter.py | 5 +---- docs/source/thing_connections.rst | 15 +++++++++------ docs/source/tutorial/writing_a_thing.rst | 3 +-- src/labthings_fastapi/outputs/mjpeg_stream.py | 3 +-- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/docs/source/dependencies/example.py b/docs/source/dependencies/example.py index 315611e..e2c383a 100644 --- a/docs/source/dependencies/example.py +++ b/docs/source/dependencies/example.py @@ -18,9 +18,12 @@ def increment_counter(self, my_thing: MyThingDep) -> None: my_thing.increment_counter() -server = lt.ThingServer() -server.add_thing("mything", MyThing) -server.add_thing("testthing", TestThing) +server = lt.ThingServer( + { + "mything": MyThing, + "testthing": TestThing, + } +) if __name__ == "__main__": import uvicorn diff --git a/docs/source/quickstart/counter.py b/docs/source/quickstart/counter.py index 8e4b566..43fcffe 100644 --- a/docs/source/quickstart/counter.py +++ b/docs/source/quickstart/counter.py @@ -31,10 +31,7 @@ def slowly_increase_counter(self) -> None: if __name__ == "__main__": import uvicorn - server = lt.ThingServer() - - # The line below creates a TestThing instance and adds it to the server - server.add_thing("counter", TestThing) + server = lt.ThingServer({"counter": TestThing}) # We run the server using `uvicorn`: uvicorn.run(server.app, port=5000) diff --git a/docs/source/thing_connections.rst b/docs/source/thing_connections.rst index 06df126..fdf8f22 100644 --- a/docs/source/thing_connections.rst +++ b/docs/source/thing_connections.rst @@ -42,9 +42,12 @@ The following example shows the use of a Thing Connection: return self.thing_a.say_hello() - server = lt.ThingServer() - server.add_thing("thing_a", ThingA) - server.add_thing("thing_b", ThingB) + server = lt.ThingServer( + { + "thing_a": ThingA, + "thing_b": ThingB, + } + ) In this example, ``ThingB.thing_a`` is the simplest form of Thing Connection: it @@ -67,9 +70,9 @@ will look up the `.Thing` by name. If the default is `None` the connection will evaluate to `None` unless explicitly configured. Connections may also be configured when `.Thing`\ s are added to the server: -`.ThingServer.add_thing` takes an argument that allows connections to be made -by name (or set to `None`). Similarly, if you set up your server using a config -file, each entry in the ``things`` list may have a ``thing_connections`` property +`.ThingConfig` takes an argument that allows connections to be made +by name (or set to `None`). The same field is present in a config +file. Each entry in the ``things`` list may have a ``thing_connections`` property that sets up the connections. To repeat the example above with a configuration file: diff --git a/docs/source/tutorial/writing_a_thing.rst b/docs/source/tutorial/writing_a_thing.rst index defe651..f6aa16b 100644 --- a/docs/source/tutorial/writing_a_thing.rst +++ b/docs/source/tutorial/writing_a_thing.rst @@ -30,8 +30,7 @@ Our first Thing will pretend to be a light: we can set its brightness and turn i self.is_on = not self.is_on - server = lt.ThingServer() - server.add_thing("light", Light) + server = lt.ThingServer({"light": Light}) if __name__ == "__main__": import uvicorn diff --git a/src/labthings_fastapi/outputs/mjpeg_stream.py b/src/labthings_fastapi/outputs/mjpeg_stream.py index 2aaf05b..2c14226 100644 --- a/src/labthings_fastapi/outputs/mjpeg_stream.py +++ b/src/labthings_fastapi/outputs/mjpeg_stream.py @@ -456,8 +456,7 @@ class Camera(lt.Thing): stream = MJPEGStreamDescriptor() - server = lt.ThingServer() - server.add_thing("camera", Camera) + server = lt.ThingServer({"camera": Camera}) :param app: the `fastapi.FastAPI` application to which we are being added. :param thing: the host `.Thing` instance. From 3a5aef12e509f81cbcd5d2de80c559812c20594c Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 27 Oct 2025 23:31:46 +0000 Subject: [PATCH 07/15] Fix up some docstrings. --- src/labthings_fastapi/logs.py | 1 + src/labthings_fastapi/server/__init__.py | 7 ------- src/labthings_fastapi/server/config_model.py | 7 ++++++- tests/test_logs.py | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/labthings_fastapi/logs.py b/src/labthings_fastapi/logs.py index ebcfd7d..cf27e76 100644 --- a/src/labthings_fastapi/logs.py +++ b/src/labthings_fastapi/logs.py @@ -115,6 +115,7 @@ def add_thing_log_destination( :param destination: should specify a deque, to which we will append each log entry as it comes in. This is assumed to be thread safe. + :raises LogConfigurationError: if there is not exactly one suitable handler. """ handlers = [ h for h in THING_LOGGER.handlers if isinstance(h, DequeByInvocationIDHandler) diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index 0903884..28cf529 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -203,7 +203,6 @@ def _create_things(self) -> Mapping[str, Thing]: :return: A mapping of names to `.Thing` instances. - :raise ValueError: if a thing name contains invalid characters. :raise TypeError: if ``cls`` is not a subclass of `.Thing` or if ``name`` is not string-like. """ @@ -237,12 +236,6 @@ def _connect_things(self) -> None: `.ThingConnectionError` will be raised by code called by this method if the connection cannot be provided. See `.ThingConnection.connect` for more details. - - :param connections: holds the supplied configuration, which is used - in preference to looking up Things by type. It is a mapping of - `.Thing` names to mappings, which map attribute names to the requested - `.Thing`\ . For example, to connect ``mything.myslot`` to the `.Thing` - named `"foo"`\ , you could pass ``{"mything": {"myslot": "foo"}}``\ . """ for thing_name, thing in self.things.items(): config = self._config.thing_configs[thing_name].thing_connections diff --git a/src/labthings_fastapi/server/config_model.py b/src/labthings_fastapi/server/config_model.py index 0a171df..5d325f5 100644 --- a/src/labthings_fastapi/server/config_model.py +++ b/src/labthings_fastapi/server/config_model.py @@ -1,7 +1,7 @@ r"""Pydantic models to enable server configuration to be loaded from file. The models in this module allow `.ThingConfig` dataclasses to be constructed -from dictionaries or JSON files. They also describe the full server configurtion +from dictionaries or JSON files. They also describe the full server configuration with `.ServerConfigModel`\ . These models are used by the `.cli` module to start servers based on configuration files or strings. """ @@ -82,6 +82,11 @@ def check_things(cls, things: ThingsConfig) -> ThingsConfig: will attempt to make a `.ThingConfig` from it. If it's a `type` we will create a `.ThingConfig` using that type as the class. We don't check for `.Thing` subclasses in this module to avoid a dependency loop. + + :param things: The validated value of the field. + + :return: A copy of the input, with all values converted to `.ThingConfig` + instances. """ return normalise_things_config(things) diff --git a/tests/test_logs.py b/tests/test_logs.py index c09abd0..af24e6f 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -1,7 +1,7 @@ """Unit tests for the `.logs` module. These tests are intended to complement the more functional tests -in ``test_aciton_logging`` with bottom-up tests for code in the +in ``test_action_logging`` with bottom-up tests for code in the `.logs` module. """ From c7786df653ffdec81b2f1df9204e1fbb71961a92 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 27 Oct 2025 23:47:44 +0000 Subject: [PATCH 08/15] Rename thing_connection to thing_slot The name `thing_connection` confused people. `thing_slot` suggests we are marking the attribute and its value will be supplied later, which is exactly right. This commit makes that swap. I've searched/replaced every instance of ThingConnection and thing_connection: we should check the docs build OK, this is perhaps most easily done in CI. --- docs/source/dependencies/dependencies.rst | 4 +- docs/source/index.rst | 2 +- ...{thing_connections.rst => thing_slots.rst} | 36 ++++---- src/labthings_fastapi/__init__.py | 4 +- src/labthings_fastapi/exceptions.py | 14 +-- src/labthings_fastapi/server/__init__.py | 17 ++-- src/labthings_fastapi/server/config_model.py | 2 +- .../{thing_connections.py => thing_slots.py} | 84 +++++++++--------- tests/test_server_config_model.py | 2 +- tests/test_thing_connection.py | 88 +++++++++---------- 10 files changed, 124 insertions(+), 129 deletions(-) rename docs/source/{thing_connections.rst => thing_slots.rst} (72%) rename src/labthings_fastapi/{thing_connections.py => thing_slots.py} (85%) diff --git a/docs/source/dependencies/dependencies.rst b/docs/source/dependencies/dependencies.rst index 8f58d4e..4e4823b 100644 --- a/docs/source/dependencies/dependencies.rst +++ b/docs/source/dependencies/dependencies.rst @@ -5,7 +5,7 @@ Dependencies .. warning:: - The use of dependencies is now deprecated. See :ref:`thing_connections` and `.ThingServerInterface` for a more intuitive way to access that functionality. + The use of dependencies is now deprecated. See :ref:`thing_slots` and `.ThingServerInterface` for a more intuitive way to access that functionality. LabThings makes use of the powerful "dependency injection" mechanism in FastAPI. You can see the `FastAPI documentation`_ for more information. In brief, FastAPI dependencies are annotated types that instruct FastAPI to supply certain function arguments automatically. This removes the need to set up resources at the start of a function, and ensures everything the function needs is declared and typed clearly. The most common use for dependencies in LabThings is where an action needs to make use of another `.Thing` on the same `.ThingServer`. @@ -14,7 +14,7 @@ Inter-Thing dependencies .. warning:: - These dependencies are deprecated - see :ref:`thing_connections` instead. + These dependencies are deprecated - see :ref:`thing_slots` instead. Simple actions depend only on their input parameters and the `.Thing` on which they are defined. However, it's quite common to need something else, for example accessing another `.Thing` instance on the same LabThings server. There are two important principles to bear in mind here: diff --git a/docs/source/index.rst b/docs/source/index.rst index 8d313ab..7f2486b 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -11,7 +11,7 @@ Documentation for LabThings-FastAPI tutorial/index.rst examples.rst actions.rst - thing_connections.rst + thing_slots.rst dependencies/dependencies.rst blobs.rst concurrency.rst diff --git a/docs/source/thing_connections.rst b/docs/source/thing_slots.rst similarity index 72% rename from docs/source/thing_connections.rst rename to docs/source/thing_slots.rst index fdf8f22..a31fd84 100644 --- a/docs/source/thing_connections.rst +++ b/docs/source/thing_slots.rst @@ -1,11 +1,11 @@ -.. thing_connections: +.. thing_slots: -Thing Connections -================= +Thing Slots +=========== It is often desirable for two Things in the same server to be able to communicate. In order to do this in a nicely typed way that is easy to test and inspect, -LabThings-FastAPI provides `.thing_connection`\ . This allows a `.Thing` +LabThings-FastAPI provides `.thing_slot`\ . This allows a `.Thing` to declare that it depends on another `.Thing` being present, and provides a way for the server to automatically connect the two when the server is set up. @@ -15,7 +15,7 @@ access a connection before it is available, it will raise an exception. The advantage of making connections after initialisation is that we don't need to worry about the order in which `.Thing`\ s are created. -The following example shows the use of a Thing Connection: +The following example shows the use of a `.thing_slot`: .. code-block:: python @@ -34,7 +34,7 @@ The following example shows the use of a Thing Connection: class ThingB(lt.Thing): "A class that relies on ThingA." - thing_a: ThingA = lt.thing_connection() + thing_a: ThingA = lt.thing_slot() @lt.action def say_hello(self) -> str: @@ -50,29 +50,29 @@ The following example shows the use of a Thing Connection: ) -In this example, ``ThingB.thing_a`` is the simplest form of Thing Connection: it +In this example, ``ThingB.thing_a`` is the simplest form of `.thing_slot`: it is type hinted as a `.Thing` subclass, and by default the server will look for the instance of that class and supply it when the server starts. If there is no matching `.Thing` or if more than one instance is present, the server will fail -to start with a `.ThingConnectionError`\ . +to start with a `.ThingSlotError`\ . It is also possible to use an optional type hint (``ThingA | None``), which means there will be no error if a matching `.Thing` instance is not found, and -the connection will evaluate to `None`\ . Finally, a `.thing_connection` may be +the slot will evaluate to `None`\ . Finally, a `.thing_slot` may be type hinted as ``Mapping[str, ThingA]`` which permits zero or more instances to be connected. The mapping keys are the names of the things. -Configuring Thing Connections ------------------------------ +Configuring Thing Slots +----------------------- -A Thing Connection may be given a default value. If this is a string, the server -will look up the `.Thing` by name. If the default is `None` the connection will +A `.thing_slot` may be given a default value. If this is a string, the server +will look up the `.Thing` by name. If the default is `None` the slot will evaluate to `None` unless explicitly configured. -Connections may also be configured when `.Thing`\ s are added to the server: +Slots may also be specified in the server's configuration: `.ThingConfig` takes an argument that allows connections to be made by name (or set to `None`). The same field is present in a config -file. Each entry in the ``things`` list may have a ``thing_connections`` property +file. Each entry in the ``things`` list may have a ``thing_slots`` property that sets up the connections. To repeat the example above with a configuration file: @@ -82,11 +82,11 @@ file: "thing_a": "example:ThingA", "thing_b": { "class": "example:ThingB", - "thing_connections": { + "thing_slots": { "thing_a": "thing_a" } } } -More detail can be found in the description of `.thing_connection` or the -:mod:`.thing_connections` module documentation. +More detail can be found in the description of `.thing_slot` or the +:mod:`.thing_slots` module documentation. diff --git a/src/labthings_fastapi/__init__.py b/src/labthings_fastapi/__init__.py index 0b3bab4..c84f369 100644 --- a/src/labthings_fastapi/__init__.py +++ b/src/labthings_fastapi/__init__.py @@ -20,7 +20,7 @@ """ from .thing import Thing -from .thing_connections import thing_connection +from .thing_slots import thing_slot from .thing_server_interface import ThingServerInterface from .properties import property, setting, DataProperty, DataSetting from .decorators import ( @@ -53,7 +53,7 @@ "DataProperty", "DataSetting", "thing_action", - "thing_connection", + "thing_slot", "fastapi_endpoint", "deps", "outputs", diff --git a/src/labthings_fastapi/exceptions.py b/src/labthings_fastapi/exceptions.py index 4895f85..fc99dba 100644 --- a/src/labthings_fastapi/exceptions.py +++ b/src/labthings_fastapi/exceptions.py @@ -51,7 +51,7 @@ class PropertyNotObservableError(RuntimeError): class InconsistentTypeError(TypeError): """Different type hints have been given for a descriptor. - Some descriptors in LabThings, particularly `.DataProperty` and `.ThingConnection` + Some descriptors in LabThings, particularly `.DataProperty` and `.ThingSlot` may have their type specified in different ways. If multiple type hints are provided, they must match. See `.property` for more details. """ @@ -64,14 +64,14 @@ class MissingTypeError(TypeError): There are different ways of providing these type hints. This error indicates that no type hint was found. - See documentation for `.property` and `.thing_connection` for more details. + See documentation for `.property` and `.thing_slot` for more details. """ class ThingNotConnectedError(RuntimeError): - """ThingConnections have not yet been set up. + r"""`.ThingSlot`\ s have not yet been set up. - This error is raised if a ThingConnection is accessed before the `.Thing` has + This error is raised if a `.ThingSlot` is accessed before the `.Thing` has been supplied by the LabThings server. This usually happens because either the `.Thing` is being used without a server (in which case the attribute should be mocked), or because it has been accessed before ``__enter__`` @@ -79,11 +79,11 @@ class ThingNotConnectedError(RuntimeError): """ -class ThingConnectionError(RuntimeError): - """A ThingConnection could not be set up. +class ThingSlotError(RuntimeError): + """A `.ThingSlot` could not be set up. This error is raised if the LabThings server is unable to set up a - ThingConnection, for example because the named Thing does not exist, + `.ThingSlot`, for example because the named Thing does not exist, or is of the wrong type, or is not specified and there is no default. """ diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index 28cf529..e6cc1db 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -19,8 +19,7 @@ from collections.abc import Mapping, Sequence from types import MappingProxyType -from ..exceptions import ThingConnectionError as ThingConnectionError -from ..thing_connections import ThingConnection +from ..thing_slots import ThingSlot from ..utilities import class_attributes from ..utilities.object_reference_to_object import ( @@ -220,27 +219,27 @@ def _create_things(self) -> Mapping[str, Thing]: *config.args, **config.kwargs, thing_server_interface=interface, - ) # type: ignore[misc] + ) return things def _connect_things(self) -> None: - r"""Connect the `thing_connection` attributes of Things. + r"""Connect the `thing_slot` attributes of Things. - A `.Thing` may have attributes defined as ``lt.thing_connection()``, which + A `.Thing` may have attributes defined as ``lt.thing_slot()``, which will be populated after all `.Thing` instances are loaded on the server. This function is responsible for supplying the `.Thing` instances required for each connection. This will be done by using the name specified either in the connection's default, or in the configuration of the server. - `.ThingConnectionError` will be raised by code called by this method if - the connection cannot be provided. See `.ThingConnection.connect` for more + `.ThingSlotError` will be raised by code called by this method if + the connection cannot be provided. See `.ThingSlot.connect` for more details. """ for thing_name, thing in self.things.items(): - config = self._config.thing_configs[thing_name].thing_connections + config = self._config.thing_configs[thing_name].thing_slots for attr_name, attr in class_attributes(thing): - if not isinstance(attr, ThingConnection): + if not isinstance(attr, ThingSlot): continue target = config.get(attr_name, ...) attr.connect(thing, self.things, target) diff --git a/src/labthings_fastapi/server/config_model.py b/src/labthings_fastapi/server/config_model.py index 5d325f5..1d8e9dc 100644 --- a/src/labthings_fastapi/server/config_model.py +++ b/src/labthings_fastapi/server/config_model.py @@ -29,7 +29,7 @@ class ThingConfig(BaseModel): description="Keyword arguments to pass to the constructor of `cls`.", ) - thing_connections: Mapping[str, str | Iterable[str] | None] = Field( + thing_slots: Mapping[str, str | Iterable[str] | None] = Field( default_factory=dict, description=( """Connections to other Things. diff --git a/src/labthings_fastapi/thing_connections.py b/src/labthings_fastapi/thing_slots.py similarity index 85% rename from src/labthings_fastapi/thing_connections.py rename to src/labthings_fastapi/thing_slots.py index 9431980..79a5e9c 100644 --- a/src/labthings_fastapi/thing_connections.py +++ b/src/labthings_fastapi/thing_slots.py @@ -2,7 +2,7 @@ It is often desirable for two Things in the same server to be able to communicate. In order to do this in a nicely typed way that is easy to test and inspect, -LabThings-FastAPI provides the `.thing_connection`\ . This allows a `.Thing` +LabThings-FastAPI provides the `.thing_slot`\ . This allows a `.Thing` to declare that it depends on another `.Thing` being present, and provides a way for the server to automatically connect the two when the server is set up. @@ -13,7 +13,7 @@ are not a problem: Thing `a` may depend on Thing `b` and vice versa. As with properties, thing connections will usually be declared using the function -`.thing_connection` rather than the descriptor directly. This allows them to be +`.thing_slot` rather than the descriptor directly. This allows them to be typed and documented on the class, i.e. .. code-block:: python @@ -33,7 +33,7 @@ def say_hello(self) -> str: class ThingB(lt.Thing): "A class that relies on ThingA." - thing_a: ThingA = lt.thing_connection() + thing_a: ThingA = lt.thing_slot() @lt.action def say_hello(self) -> str: @@ -46,7 +46,7 @@ def say_hello(self) -> str: from collections.abc import Mapping, Iterable, Sequence from weakref import ReferenceType, WeakKeyDictionary, ref, WeakValueDictionary from .base_descriptor import FieldTypedBaseDescriptor -from .exceptions import ThingNotConnectedError, ThingConnectionError +from .exceptions import ThingNotConnectedError, ThingSlotError if TYPE_CHECKING: from .thing import Thing @@ -59,12 +59,10 @@ def say_hello(self) -> str: ) -class ThingConnection( - Generic[ConnectedThings], FieldTypedBaseDescriptor[ConnectedThings] -): - r"""Descriptor that returns other Things from the server. +class ThingSlot(Generic[ConnectedThings], FieldTypedBaseDescriptor[ConnectedThings]): + r"""Descriptor that instructs the server to supply other Things. - A `.ThingConnection` provides either one or several + A `.ThingSlot` provides either one or several `.Thing` instances as a property of a `.Thing`\ . This allows `.Thing`\ s to communicate with each other within the server, including accessing attributes that are not exposed over HTTP. @@ -75,10 +73,10 @@ class ThingConnection( of run-time crashes. The usual way of creating these connections is the function - `.thing_connection`\ . This class and its subclasses are not usually + `.thing_slot`\ . This class and its subclasses are not usually instantiated directly. - The type of the `.ThingConnection` attribute is key to its operation. + The type of the `.ThingSlot` attribute is key to its operation. It should be assigned to an attribute typed either as a `.Thing` subclass, a mapping of strings to `.Thing` or subclass instances, or an optional `.Thing` instance: @@ -91,19 +89,19 @@ class OtherExample(lt.Thing): class Example(lt.Thing): # This will always evaluate to an `OtherExample` - other_thing: OtherExample = lt.thing_connection("other_thing") + other_thing: OtherExample = lt.thing_slot("other_thing") # This may evaluate to an `OtherExample` or `None` - optional: OtherExample | None = lt.thing_connection("other_thing") + optional: OtherExample | None = lt.thing_slot("other_thing") # This evaluates to a mapping of `str` to `.Thing` instances - things: Mapping[str, OtherExample] = lt.thing_connection(["thing_a"]) + things: Mapping[str, OtherExample] = lt.thing_slot(["thing_a"]) """ def __init__( self, *, default: str | None | Iterable[str] | EllipsisType = ... ) -> None: - """Declare a ThingConnection. + """Declare a ThingSlot. :param default: The name of the Thing(s) that will be connected by default. @@ -181,8 +179,8 @@ def _pick_things( ) -> "Sequence[Thing]": r"""Pick the Things we should connect to from a list. - This function is used internally by `.ThingConnection.connect` to choose - the Things we return when the `.ThingConnection` is accessed. + This function is used internally by `.ThingSlot.connect` to choose + the Things we return when the `.ThingSlot` is accessed. :param things: the available `.Thing` instances on the server. :param target: the name(s) we should connect to, or `None` to set the @@ -190,7 +188,7 @@ def _pick_things( which will pick the `.Thing` instannce(s) matching this connection's type hint. - :raises ThingConnectionError: if the supplied `.Thing` is of the wrong + :raises ThingSlotError: if the supplied `.Thing` is of the wrong type, if a sequence is supplied when a single `.Thing` is required, or if `None` is supplied and the connection is not optional. :raises TypeError: if ``target`` is not one of the allowed types. @@ -210,15 +208,15 @@ def _pick_things( ] elif isinstance(target, str): if not isinstance(things[target], self.thing_type): - raise ThingConnectionError(f"{target} is the wrong type") + raise ThingSlotError(f"{target} is the wrong type") return [things[target]] elif isinstance(target, Iterable): for t in target: if not isinstance(things[t], self.thing_type): - raise ThingConnectionError(f"{t} is the wrong type") + raise ThingSlotError(f"{t} is the wrong type") return [things[t] for t in target] - msg = "The target specified for a ThingConnection ({target}) has the wrong " - msg += "type. See ThingConnection.connect() docstring for details." + msg = "The target specified for a ThingSlot ({target}) has the wrong " + msg += "type. See ThingSlot.connect() docstring for details." raise TypeError(msg) def connect( @@ -229,7 +227,7 @@ def connect( ) -> None: r"""Find the `.Thing`\ (s) we should supply when accessed. - This method sets up a ThingConnection on ``host_thing`` by finding the + This method sets up a ThingSlot on ``host_thing`` by finding the `.Thing` instance(s) it should supply when its ``__get__`` method is called. The logic for determining this is: @@ -257,10 +255,10 @@ def connect( :param things: the available `.Thing` instances on the server. :param target: the name(s) we should connect to, or `None` to set the connection to `None` (if it is optional). The default is `...` - which will use the default that was set when this `.ThingConnection` + which will use the default that was set when this `.ThingSlot` was defined. - :raises ThingConnectionError: if the supplied `.Thing` is of the wrong + :raises ThingSlotError: if the supplied `.Thing` is of the wrong type, if a sequence is supplied when a single `.Thing` is required, or if `None` is supplied and the connection is not optional. """ @@ -268,7 +266,7 @@ def connect( try: # First, explicitly check for None so we can raise a helpful error. if used_target is None and not self.is_optional and not self.is_mapping: - raise ThingConnectionError("it must be set in configuration") + raise ThingSlotError("it must be set in configuration") # Most of the logic is split out into `_pick_things` to separate # picking the Things from turning them into the correct mapping/reference. picked = self._pick_things(things, used_target) @@ -281,15 +279,15 @@ def connect( self._things[host] = None else: # Otherwise a single Thing is required, so raise an error. - raise ThingConnectionError("no matching Thing was found") + raise ThingSlotError("no matching Thing was found") elif len(picked) == 1: # A single Thing is found: we can safely use this. self._things[host] = ref(picked[0]) else: # If more than one Thing is found (and we're not a mapping) this is # an error. - raise ThingConnectionError("it can't connect to multiple Things") - except (ThingConnectionError, KeyError) as e: + raise ThingSlotError("it can't connect to multiple Things") + except (ThingSlotError, KeyError) as e: reason = e.args[0] if isinstance(e, KeyError): reason += " is not the name of a Thing" @@ -303,7 +301,7 @@ def connect( else: msg += f"The default searches for Things by type: '{self.thing_type}'." - raise ThingConnectionError(msg) from e + raise ThingSlotError(msg) from e def instance_get(self, obj: "Thing") -> ConnectedThings: r"""Supply the connected `.Thing`\ (s). @@ -312,7 +310,7 @@ def instance_get(self, obj: "Thing") -> ConnectedThings: :return: the `.Thing` instance(s) connected. - :raises ThingNotConnectedError: if the ThingConnection has not yet been set up. + :raises ThingNotConnectedError: if the ThingSlot has not yet been set up. :raises ReferenceError: if a connected Thing no longer exists (should not ever happen in normal usage). @@ -348,10 +346,10 @@ def instance_get(self, obj: "Thing") -> ConnectedThings: # See docstring for an explanation of the type ignore directives. -def thing_connection(default: str | Iterable[str] | None | EllipsisType = ...) -> Any: +def thing_slot(default: str | Iterable[str] | None | EllipsisType = ...) -> Any: r"""Declare a connection to another `.Thing` in the same server. - ``lt.thing_connection`` marks a class attribute as a connection to another + ``lt.thing_slot`` marks a class attribute as a connection to another `.Thing` on the same server. This will be automatically supplied when the server is started, based on the type hint and default value. @@ -369,9 +367,9 @@ class ThingA(lt.Thing): ... class ThingB(lt.Thing): "A class that relies on ThingA." - thing_a: ThingA = lt.thing_connection() + thing_a: ThingA = lt.thing_slot() - This function is a convenience wrapper around the `.ThingConnection` descriptor + This function is a convenience wrapper around the `.ThingSlot` descriptor class, and should be used in preference to using the descriptor directly. The main reason to use the function is that it suppresses type errors when using static type checkers such as `mypy` or `pyright` (see note below). @@ -400,9 +398,9 @@ class ThingA(lt.Thing): class ThingB(lt.Thing): "An example Thing with connections." - thing_a: ThingA = lt.thing_connection() - maybe_thing_a: ThingA | None = lt.thing_connection() - all_things_a: Mapping[str, ThingA] = lt.thing_connection() + thing_a: ThingA = lt.thing_slot() + maybe_thing_a: ThingA | None = lt.thing_slot() + all_things_a: Mapping[str, ThingA] = lt.thing_slot() @lt.thing_action def show_connections(self) -> str: @@ -436,18 +434,18 @@ def show_connections(self) -> str: If the default is omitted or set to ``...`` the server will attempt to find a matching `.Thing` instance (or instances). A default value of `None` is allowed if the connection is type hinted as optional. - :return: A `.ThingConnection` descriptor. + :return: A `.ThingSlot` descriptor. Typing notes: - In the example above, using `.ThingConnection` directly would assign an object - with type ``ThingConnection[ThingA]`` to the attribute ``thing_a``, which is + In the example above, using `.ThingSlot` directly would assign an object + with type ``ThingSlot[ThingA]`` to the attribute ``thing_a``, which is typed as ``ThingA``\ . This would cause a type error. Using - `.thing_connection` suppresses this error, as its return type is a`Any``\ . + `.thing_slot` suppresses this error, as its return type is a`Any``\ . The use of ``Any`` or an alternative type-checking exemption seems to be inevitable when implementing descriptors that are typed via attribute annotations, and it is done by established libraries such as `pydantic`\ . """ - return ThingConnection(default=default) + return ThingSlot(default=default) diff --git a/tests/test_server_config_model.py b/tests/test_server_config_model.py index 50c8e08..5ce29b8 100644 --- a/tests/test_server_config_model.py +++ b/tests/test_server_config_model.py @@ -18,7 +18,7 @@ def test_ThingConfig(): # In the absence of supplied arguments, default factories should be used assert len(direct.args) == 0 assert direct.kwargs == {} - assert direct.thing_connections == {} + assert direct.thing_slots == {} with pytest.raises(ValidationError, match="No module named"): cm.ThingConfig(cls="missing.module") diff --git a/tests/test_thing_connection.py b/tests/test_thing_connection.py index fa846a0..39a77b1 100644 --- a/tests/test_thing_connection.py +++ b/tests/test_thing_connection.py @@ -1,4 +1,4 @@ -"""Test the thing_connection module.""" +"""Test the thing_slot module.""" from collections.abc import Mapping import gc @@ -6,27 +6,27 @@ import labthings_fastapi as lt from fastapi.testclient import TestClient -from labthings_fastapi.exceptions import ThingConnectionError +from labthings_fastapi.exceptions import ThingSlotError class ThingOne(lt.Thing): """A class that will cause chaos if it can.""" - other_thing: "ThingTwo" = lt.thing_connection() - n_things: "Mapping[str, ThingThree]" = lt.thing_connection() - optional_thing: "ThingThree | None" = lt.thing_connection() + other_thing: "ThingTwo" = lt.thing_slot() + n_things: "Mapping[str, ThingThree]" = lt.thing_slot() + optional_thing: "ThingThree | None" = lt.thing_slot() class ThingTwo(lt.Thing): """A class that relies on ThingOne.""" - other_thing: ThingOne = lt.thing_connection() + other_thing: ThingOne = lt.thing_slot() class ThingN(lt.Thing): """A class that emulates ThingOne and ThingTwo more generically.""" - other_thing: "ThingN" = lt.thing_connection(None) + other_thing: "ThingN" = lt.thing_slot(None) class ThingThree(lt.Thing): @@ -38,7 +38,7 @@ class ThingThree(lt.Thing): class ThingThatMustBeConfigured(lt.Thing): """A Thing that has a default that won't work.""" - other_thing: lt.Thing = lt.thing_connection(None) + other_thing: lt.Thing = lt.thing_slot(None) class Dummy: @@ -58,43 +58,41 @@ class Dummy2(Dummy): class ThingWithManyConnections: - """A class with lots of ThingConnections. + """A class with lots of ThingSlots. This class is not actually meant to be used - it is a host for - the thing_connection attributes. It's not a Thing, to simplify + the thing_slot attributes. It's not a Thing, to simplify testing. The "thing" types it depends on are also not Things, again to simplify testing. """ name = "thing" - single_no_default: Dummy1 = lt.thing_connection() - optional_no_default: Dummy1 | None = lt.thing_connection() - multiple_no_default: Mapping[str, Dummy1] = lt.thing_connection() + single_no_default: Dummy1 = lt.thing_slot() + optional_no_default: Dummy1 | None = lt.thing_slot() + multiple_no_default: Mapping[str, Dummy1] = lt.thing_slot() - single_default_none: Dummy1 = lt.thing_connection(None) - optional_default_none: Dummy1 | None = lt.thing_connection(None) - multiple_default_none: Mapping[str, Dummy1] = lt.thing_connection(None) + single_default_none: Dummy1 = lt.thing_slot(None) + optional_default_none: Dummy1 | None = lt.thing_slot(None) + multiple_default_none: Mapping[str, Dummy1] = lt.thing_slot(None) - single_default_str: Dummy1 = lt.thing_connection("dummy_a") - optional_default_str: Dummy1 | None = lt.thing_connection("dummy_a") - multiple_default_str: Mapping[str, Dummy1] = lt.thing_connection("dummy_a") + single_default_str: Dummy1 = lt.thing_slot("dummy_a") + optional_default_str: Dummy1 | None = lt.thing_slot("dummy_a") + multiple_default_str: Mapping[str, Dummy1] = lt.thing_slot("dummy_a") - single_default_seq: Dummy1 = lt.thing_connection(["dummy_a", "dummy_b"]) - optional_default_seq: Dummy1 | None = lt.thing_connection(["dummy_a", "dummy_b"]) - multiple_default_seq: Mapping[str, Dummy1] = lt.thing_connection( - ["dummy_a", "dummy_b"] - ) + single_default_seq: Dummy1 = lt.thing_slot(["dummy_a", "dummy_b"]) + optional_default_seq: Dummy1 | None = lt.thing_slot(["dummy_a", "dummy_b"]) + multiple_default_seq: Mapping[str, Dummy1] = lt.thing_slot(["dummy_a", "dummy_b"]) class ThingWithFutureConnection: - """A class with a ThingConnection in the future.""" + """A class with a ThingSlot in the future.""" name = "thing" - single: "DummyFromTheFuture" = lt.thing_connection() - optional: "DummyFromTheFuture | None" = lt.thing_connection() - multiple: "Mapping[str, DummyFromTheFuture]" = lt.thing_connection() + single: "DummyFromTheFuture" = lt.thing_slot() + optional: "DummyFromTheFuture | None" = lt.thing_slot() + multiple: "Mapping[str, DummyFromTheFuture]" = lt.thing_slot() class DummyFromTheFuture(Dummy): @@ -194,12 +192,12 @@ def picked_names(things, target): # Check for the error if we specify the wrong type (for string and sequence) # Note that only one thing of the wrong type will still cause the error. for target in ["thing2_a", ["thing2_a"], ["thing1_a", "thing2_a"]]: - with pytest.raises(ThingConnectionError) as excinfo: + with pytest.raises(ThingSlotError) as excinfo: picked_names(mixed_things, target) assert "wrong type" in str(excinfo.value) # Check for a KeyError if we specify a missing Thing. This is converted to - # a ThingConnectionError by `connect`. + # a ThingSlotError by `connect`. for target in ["something_else", {"thing1_a", "something_else"}]: with pytest.raises(KeyError): picked_names(mixed_things, target) @@ -223,7 +221,7 @@ def test_connect(mixed_things): cls.multiple_default_none.connect(obj, dummy_things(names)) assert names_set(obj.multiple_default_none) == set() # single should fail, as it requires a Thing - with pytest.raises(ThingConnectionError) as excinfo: + with pytest.raises(ThingSlotError) as excinfo: cls.single_default_none.connect(obj, dummy_things(names)) assert "must be set" in str(excinfo.value) @@ -245,7 +243,7 @@ def test_connect(mixed_things): # but a single connection fails, as it can't be None. no_matches = {n: Dummy2(n) for n in ["one", "two"]} obj = cls() - with pytest.raises(ThingConnectionError) as excinfo: + with pytest.raises(ThingSlotError) as excinfo: cls.single_no_default.connect(obj, no_matches) assert "no matching Thing" in str(excinfo.value) cls.optional_no_default.connect(obj, no_matches) @@ -269,11 +267,11 @@ def test_connect(mixed_things): match2 = Dummy1("four") two_matches = {"four": match2, **one_match} obj = cls() - with pytest.raises(ThingConnectionError) as excinfo: + with pytest.raises(ThingSlotError) as excinfo: cls.single_no_default.connect(obj, two_matches) assert "multiple Things" in str(excinfo.value) assert "Things by type" in str(excinfo.value) - with pytest.raises(ThingConnectionError) as excinfo: + with pytest.raises(ThingSlotError) as excinfo: cls.optional_no_default.connect(obj, two_matches) assert "multiple Things" in str(excinfo.value) assert "Things by type" in str(excinfo.value) @@ -281,16 +279,16 @@ def test_connect(mixed_things): assert obj.multiple_no_default == {"three": match, "four": match2} # _pick_things raises KeyErrors for invalid names. - # Check KeyErrors are turned back into ThingConnectionErrors + # Check KeyErrors are turned back into ThingSlotErrors obj = cls() - with pytest.raises(ThingConnectionError) as excinfo: + with pytest.raises(ThingSlotError) as excinfo: cls.single_default_str.connect(obj, mixed_things) assert "not the name of a Thing" in str(excinfo.value) assert f"{obj.name}.single_default_str" in str(excinfo.value) assert "not configured, and used the default" in str(excinfo.value) # The error message changes if a target is specified. obj = cls() - with pytest.raises(ThingConnectionError) as excinfo: + with pytest.raises(ThingSlotError) as excinfo: cls.single_default_str.connect(obj, mixed_things, "missing") assert "not the name of a Thing" in str(excinfo.value) assert f"{obj.name}.single_default_str" in str(excinfo.value) @@ -358,10 +356,10 @@ def test_circular_connection(cls_1, cls_2, connections) -> None: server = lt.ThingServer( things={ "thing_one": lt.ThingConfig( - cls=cls_1, thing_connections=connections.get("thing_one", {}) + cls=cls_1, thing_slots=connections.get("thing_one", {}) ), "thing_two": lt.ThingConfig( - cls=cls_2, thing_connections=connections.get("thing_two", {}) + cls=cls_2, thing_slots=connections.get("thing_two", {}) ), } ) @@ -397,13 +395,13 @@ def test_connections_none_default(connections, error): """ things = { "thing_one": lt.ThingConfig( - cls=ThingN, thing_connections=connections.get("thing_one", {}) + cls=ThingN, thing_slots=connections.get("thing_one", {}) ), "thing_two": lt.ThingConfig( - cls=ThingN, thing_connections=connections.get("thing_two", {}) + cls=ThingN, thing_slots=connections.get("thing_two", {}) ), "thing_three": lt.ThingConfig( - cls=ThingThree, thing_connections=connections.get("thing_three", {}) + cls=ThingThree, thing_slots=connections.get("thing_three", {}) ), } @@ -415,7 +413,7 @@ def test_connections_none_default(connections, error): assert thing_one.other_thing is thing_one return - with pytest.raises(ThingConnectionError, match=error): + with pytest.raises(ThingSlotError, match=error): server = lt.ThingServer(things) @@ -445,13 +443,13 @@ def test_mapping_and_multiple(): } # We can't set up a server like this, because # thing_one.optional_thing will match multiple ThingThree instances. - with pytest.raises(ThingConnectionError, match="multiple Things"): + with pytest.raises(ThingSlotError, match="multiple Things"): server = lt.ThingServer(things) # Set optional thing to one specific name and it will start OK. things["thing_one"] = lt.ThingConfig( cls=ThingOne, - thing_connections={"optional_thing": "thing_3"}, + thing_slots={"optional_thing": "thing_3"}, ) server = lt.ThingServer(things) with TestClient(server.app): From 728baa580260a091c4c77d984537b59c1ce30372 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 27 Oct 2025 23:59:17 +0000 Subject: [PATCH 09/15] Remove the defunct object_reference_to_object This has been removed as its functionality is provided by `pydantic.ImportString`. It is no longer used for the fallback server: we gain nothing from the dynamic import. In the future, if we want to make it configurable, we could use `ImportString` there too. --- src/labthings_fastapi/server/__init__.py | 3 -- src/labthings_fastapi/server/cli.py | 10 ++---- .../utilities/object_reference_to_object.py | 34 ------------------- 3 files changed, 3 insertions(+), 44 deletions(-) delete mode 100644 src/labthings_fastapi/utilities/object_reference_to_object.py diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index e6cc1db..af75c40 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -22,9 +22,6 @@ from ..thing_slots import ThingSlot from ..utilities import class_attributes -from ..utilities.object_reference_to_object import ( - object_reference_to_object as object_reference_to_object, -) from ..actions import ActionManager from ..logs import configure_thing_logger from ..thing import Thing diff --git a/src/labthings_fastapi/server/cli.py b/src/labthings_fastapi/server/cli.py index 8a15761..5af8c01 100644 --- a/src/labthings_fastapi/server/cli.py +++ b/src/labthings_fastapi/server/cli.py @@ -23,13 +23,10 @@ from typing import Optional from pydantic import ValidationError - -from ..utilities.object_reference_to_object import ( - object_reference_to_object, -) import uvicorn from . import ThingServer +from . import fallback from .config_model import ThingServerConfig @@ -152,9 +149,8 @@ def serve_from_cli( except BaseException as e: if args.fallback: print(f"Error: {e}") - fallback_server = "labthings_fastapi.server.fallback:app" - print(f"Starting fallback server {fallback_server}.") - app = object_reference_to_object(fallback_server) + print("Starting fallback server.") + app = fallback.app app.labthings_config = config app.labthings_server = server app.labthings_error = e diff --git a/src/labthings_fastapi/utilities/object_reference_to_object.py b/src/labthings_fastapi/utilities/object_reference_to_object.py deleted file mode 100644 index 72ddceb..0000000 --- a/src/labthings_fastapi/utilities/object_reference_to_object.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Load objects from object references.""" - -import importlib -from typing import Any - - -def object_reference_to_object(object_reference: str) -> Any: - """Convert a string reference to an object. - - This is taken from: - https://packaging.python.org/en/latest/specifications/entry-points/ - - The format of the string is `module_name:qualname` where `qualname` - is the fully qualified name of the object within the module. This is - the same format used by entrypoints` in `setup.py` files. - - :param object_reference: a string referencing a Python object to import. - - :return: the Python object. - - :raise ImportError: if the referenced object cannot be found or imported. - """ - modname, qualname_separator, qualname = object_reference.partition(":") - obj = importlib.import_module(modname) - if qualname_separator: - for attr in qualname.split("."): - try: - obj = getattr(obj, attr) - except AttributeError as e: - raise ImportError( - f"Cannot import name {attr} from {obj} " - f"when loading '{object_reference}'" - ) from e - return obj From 655bf9d5ba3f050807166e5c2743cdff5d843080 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 28 Oct 2025 00:10:03 +0000 Subject: [PATCH 10/15] Remove an unused import --- src/labthings_fastapi/server/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index af75c40..bcf2d90 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -29,7 +29,6 @@ from ..thing_description._model import ThingDescription from ..dependencies.thing_server import _thing_servers # noqa: F401 from .config_model import ( - ThingConfig as ThingConfig, ThingsConfig, ThingServerConfig, normalise_things_config as normalise_things_config, From 1c23151944242480387ab64cfdfa7b53e67b822a Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 28 Oct 2025 00:14:45 +0000 Subject: [PATCH 11/15] Typing fixes for the fallback server Now that it's imported statically, `mypy` spots typing errors with the fallback server. These are now annotated correctly. --- src/labthings_fastapi/server/fallback.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/labthings_fastapi/server/fallback.py b/src/labthings_fastapi/server/fallback.py index 388a9f9..399656e 100644 --- a/src/labthings_fastapi/server/fallback.py +++ b/src/labthings_fastapi/server/fallback.py @@ -9,11 +9,15 @@ import json from traceback import format_exception -from typing import Any +from typing import Any, TYPE_CHECKING from fastapi import FastAPI from fastapi.responses import HTMLResponse from starlette.responses import RedirectResponse +if TYPE_CHECKING: + from . import ThingServer + from .config_model import ThingServerConfig + class FallbackApp(FastAPI): """A basic FastAPI application to serve a LabThings error page.""" @@ -28,9 +32,9 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: :param \**kwargs: is passed to `fastapi.FastAPI.__init__`\ . """ super().__init__(*args, **kwargs) - self.labthings_config = None - self.labthings_server = None - self.labthings_error = None + self.labthings_config: ThingServerConfig | None = None + self.labthings_server: ThingServer | None = None + self.labthings_error: BaseException | None = None self.log_history = None self.html_code = 500 From b1b98e2e58a8ef940fd9b85c18e5e76edd717a89 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 28 Oct 2025 00:43:27 +0000 Subject: [PATCH 12/15] Update index and core concepts I've reworked core concepts and removed DirectThingClient, replacing it with a new "structure" page that I think preserves most of the still-useful content. --- docs/source/actions.rst | 10 +++--- docs/source/index.rst | 33 ++++++++--------- docs/source/lt_core_concepts.rst | 61 -------------------------------- docs/source/structure.rst | 54 ++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 84 deletions(-) delete mode 100644 docs/source/lt_core_concepts.rst create mode 100644 docs/source/structure.rst diff --git a/docs/source/actions.rst b/docs/source/actions.rst index 1294a7c..d1aa4e1 100644 --- a/docs/source/actions.rst +++ b/docs/source/actions.rst @@ -5,7 +5,7 @@ Actions Actions are the way `.Thing` objects are instructed to do things. In Python terms, any method of a `.Thing` that we want to be able to call over HTTP -should be decorated as an Action, using :deco:`.thing_action`. +should be decorated as an Action, using `.thing_action`. This page gives an overview of how actions are implemented in LabThings-FastAPI. :ref:`wot_cc` includes a section on :ref:`wot_actions` that introduces the general concept. @@ -91,15 +91,17 @@ such that the action code can use module-level symbols rather than needing to explicitly pass the logger and cancel hook as arguments to the action method. -Usually, you don't need to consider this mechanism: simply use the invocation -logger or cancel hook as explained above. However, if you want to run actions +Usually, you don't need to consider this mechanism: simply use `.Thing.logger` +or `.cancellable_sleep` as explained above. However, if you want to run actions outside of the server (for example, for testing purposes) or if you want to call one action from another action, but not share the cancellation signal or log, functions are provided in `.invocation_contexts` to manage this. If you start a new thread from an action, code running in that thread will -not have the invocation ID set in a context variable. A subclass of +not have an invocation ID set in a context variable. A subclass of `threading.Thread` is provided to do this, `.ThreadWithInvocationID`\ . +This may be useful for test code, or if you wish to run actions in the +backgound, with the option of cancelling them. Raising exceptions ------------------ diff --git a/docs/source/index.rst b/docs/source/index.rst index 7f2486b..f43b163 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -7,7 +7,7 @@ Documentation for LabThings-FastAPI quickstart/quickstart.rst wot_core_concepts.rst - lt_core_concepts.rst + structure.rst tutorial/index.rst examples.rst actions.rst @@ -20,26 +20,21 @@ Documentation for LabThings-FastAPI autoapi/index -`labthings-fastapi` implements a Web of Things interface for laboratory hardware using Python. This is a ground-up rewrite of python-labthings_, replacing Flask 1 and Marshmallow with FastAPI and Pydantic. It is the underlying framework for v3 of the `OpenFlexure Microscope software `_. +`labthings-fastapi` is a Python library to simplify the process of making laboratory instruments available via a HTTP. It aims to create an API that is usable from any modern programming language, with API documentation in both :ref:`openapi` and :ref:`gen_td` formats. It is the underlying framework for v3 of the `OpenFlexure Microscope software `_. Key features and design aims are: -`labthings-fastapi` aims to simplify the process of making laboratory instruments available via an HTTP API. Key features and design aims are below: - -* Functionality together in `Thing` subclasses, which represent units of hardware or software (see :doc:`wot_core_concepts`) -* Methods and properties of `Thing` subclasses may be added to the HTTP API and Thing Description using decorators +* The functionality of a unit of hardware or software is described using `.Thing` subclasses. +* Methods and properties of `.Thing` subclasses may be added to the HTTP API and associated documentation using decorators. +* Datatypes of action input/outputs and properties are defined with Python type hints. +* Actions are decorated methods of a `.Thing` class. There is no need for separate schemas or endpoint definitions. +* Properties are defined either as typed attributes (similar to `pydantic` or `dataclasses`) or with a `property`\ -like decorator. +* Lifecycle and concurrency are appropriate for hardware: `Thing` code is always run in a thread, and each `Thing` is instantiated, started up, and shut down only once. * Vocabulary and concepts are aligned with the `W3C Web of Things `_ standard (see :doc:`wot_core_concepts`) - - Things are classes, with properties and actions defined exactly once - - Thing Descriptions are automatically generated, and validated with `pydantic` - - OpenAPI documentation is automatically generated by FastAPI -* We follow FastAPI_'s lead and try to use standard Python features to minimise unnecessary code - - Datatypes of action input/outputs and properties are defined with Python type hints - - Actions are defined exactly once, as a method of a `Thing` class - - Properties and actions are declared using decorators (or descriptors if that's preferred) - - FastAPI_ "Dependency injection" is used to manage relationships between Things and dependency on the server -* Lifecycle and concurrency are appropriate for hardware: `Thing` code is always run in a thread, and each `Thing` is instantiated and shut down only once. - - Starlette (used by FastAPI) can handle requests asynchronously - this improves performance and enables websockets and other long-lived connections. - - `Thing` code is still, for now, threaded. In the future it may become possible to us other concurrency models in `Thing` code. - -Compared to `python-labthings`_, this framework updates dependencies, shrinks the codebase, and simplifies the API (see :doc:`lt_core_concepts`). + +Previous version +---------------- + +This is a ground-up rewrite of python-labthings_, replacing Flask 1 and Marshmallow with FastAPI and Pydantic. +Compared to `python-labthings`_, this framework updates dependencies, shrinks the codebase, and simplifies the API (see :doc:`lt_structure`). * FastAPI more or less completely eliminates OpenAPI generation code from our codebase * Marshmallow schemas and endpoint classes are replaced with Python type hints, eliminating double- or triple-definition of actions and their inputs/outputs. * Thing Description generation is very much simplified by the new structure (multiple Things instead of one massive Thing with many extensions) diff --git a/docs/source/lt_core_concepts.rst b/docs/source/lt_core_concepts.rst deleted file mode 100644 index 08ef280..0000000 --- a/docs/source/lt_core_concepts.rst +++ /dev/null @@ -1,61 +0,0 @@ -.. _labthings_cc: - -LabThings Core Concepts -======================= - -LabThings FastAPI is a ground-up rewrite of LabThings using FastAPI. Many of the core concepts from FastAPI such as dependency injection are used heavily - -The LabThings Server --------------------- - -At its core LabThings FastAPI is a server-based framework. To use LabThings FastAPI a LabThings Server is created, and `.Thing` objects are added to the the server to provide functionality. - -The server API is accessed over an HTTP requests, allowing client code (see below) to be written in any language that can send an HTTP request. - -Everything is a Thing ---------------------- - -As described in :ref:`wot_cc`, a Thing represents a piece of hardware or software. LabThings-FastAPI automatically generates a :ref:`wot_td` to describe each Thing. Each function offered by the Thing is either a Property or Action (LabThings-FastAPI does not yet support Events). These are termed "interaction affordances" in WoT_ terminology. - -Code on the LabThings FastAPI Server is composed of Things, however these can call generic Python functions/classes. The entire HTTP API served by the server is defined by `.Thing` objects. As such the full API is composed of the actions and properties (and perhaps eventually events) defined in each Thing. - -_`WoT`: wot_core_concepts - -Properties vs Settings ----------------------- - -A Thing in LabThings-FastAPI can have Settings as well as Properties. "Setting" is LabThings-FastAPI terminology for a "Property" with a value that persists after the server is restarted. All Settings are Properties, and -- except for persisting after a server restart -- Settings are identical to any other Properties. - -Client Code ------------ - -Clients or client code (Not to be confused with a `.ThingClient`, see below) is the terminology used to describe any software that uses HTTP requests to access the LabThing Server. Clients can be written in any language that supports an HTTP request. However, LabThings FastAPI provides additional functionality that makes writing client code in Python easier. - -ThingClients ------------- - -When writing client code in Python it would be possible to formulate every interaction as an HTTP request. This has two major downsides: - -1. The code must establish a new connection to the server for each request. -2. Each request is formulated as a string pointing to the endpoint and ``json`` headers for sending any data. This leads to very messy code. - -Ideally the client would be able to run the `Thing` object's actions and read its properties in native python code. However, as the client code is running in a different process, and probably in a different python environment (or even on a different machine entirely!) there is no way to directly import the Python objectfor the `Thing`. - -To mitigate this client code can ask the server for a description of all of a `Thing`'s properties and actions, this is known as a `ThingDescription`. From this `ThingDescription` the client code can dynamically generate a new object with methods matching each `ThingAction` and properties matching each `ThingProperty`. **This dynamically generated object is called a ThingClient**. - -The :class:`.ThingClient` also handle supplying certain arguments to ThingActions without them needing to be explicitly passed each time the method is called. More detail on this is provided in the :doc:`dependencies/dependencies` page. - -DirectThingClients ------------------- - -When writing code to run on the server one Thing will need to call another Thing. Ideally this code should be identical to code written in a client. This way the code can be prototyped in a client notebook before being ported to the server. - -It would be possible to directly call the Thing object, however in this case the Python API would not be the same as for client code, because the dependencies would not automatically be supplied. -**RICHARD, Are there other reasons too?** - -To provide the same interface in server code as is provided in client code LabThings FastAPI can dynamically create a new object with the same (or at least very similar) API as the `ThingClient`, this is called a **DirectThingClient**. - -The key difference between a `ThingClient` and a `DirectThingClient` is that the `ThingClient` calls the `Thing` over HTTP from client code, whereas the `DirectThingClient` calls directly through the Python API from within the Server. - - - diff --git a/docs/source/structure.rst b/docs/source/structure.rst new file mode 100644 index 0000000..c01c225 --- /dev/null +++ b/docs/source/structure.rst @@ -0,0 +1,54 @@ +.. _labthings_cc: +.. _labthings_structure: + +LabThings structure +=================== + +LabThings is intended to simplify the process of making a piece of hardware available through an HTTP API and documenting that API with :ref:`gen_docs`\ . + +Server +------ + +LabThings is a server-based framework. +The `.ThingServer` creates and manages the `.Thing` instances that represent individual hardware or software units. The functionality of those `.Thing`\ s is accessed via HTTP requests, which can be made from a web browser, the command line, or any programming language with an HTTP library. + +LabThings-FastAPI is built on top of `fastapi`\ , which is a fast, modern HTTP framework. LabThings provides functionality to manage `.Thing`\ s and their actions, including: + +* Initialising, starting up, and shutting down the `.Thing` instances, so that hardware is correctly started up and shut down. +* Managing actions, including making logs and output values available over HTTP. +* Managing `.Blob` input and output (i.e. binary objects that are best not serialised to JSON). +* Generating a :ref:`gen_td` in addition to the :ref:`openapi` documentation produced by `fastapi`\ . +* Making connections between `.Thing` instances as required. + +`.Thing`\ s +----------- + +Each unit of hardware (or software) that should be exposed by the server is implemented as a subclass of `.Thing`\ . A `.Thing` subclass represents a particular type of instrument (whether hardware or software), and its functionality is described using actions and properties, described below. `.Thing`\ s don't have to correspond to separate pieces of hardware: it's possible (and indeed recommended) to use `.Thing` subclasses for software components, plug-ins, swappable modules, or anything else that needs to add functionality to the server. `.Thing`\ s may access each other's attributes, so you can write a `.Thing` that implements a particular measurement protocol or task, using hardware that's accessed through other `.Thing` instances on the server. Each `.Thing` is documented by a :ref:`gen_td` which outlines its features in a higher-level way than :ref:`openapi`\ . + +The attributes of a `.Thing` are made available over HTTP by decorating or marking them with the following functions: + +* `.property` may be used as a decorator analogous to Python's built-in ``@property``\ . It can also be used to mark class attributes as variables that should be available over HTTP. +* `.setting` works similarly to `.property` but it is persisted to disk when the server stops, so the value is remembered. +* `.thing_action` is a decorator that makes methods available over HTTP. +* `.thing_slot` tells LabThings to supply an instance of another `.Thing` at runtime, so your `.Thing` can make use of it. + +.. + + `.Thing` Lifecycle + ------------------ + + As a `.Thing` often represents a piece of hardware, it can't be dynamically created and destroyed in the way many resources of web applications are. In LabThings, the lifecycle of a Thing calls several methods to manage the hardware and configuration. In order, these are: + + * ``__init__`` is called when the `.Thing` is created by the server. It shouldn't talk to the hardware yet, but it may store its arguments as configuration. For example, you might accept + + When implementing a `.Thing` it is important to include code to set up any required hardware connections in ``__enter__`` and code to shut it down again in ``__exit__`` as this will be used by the server to set up and tear down the hardware connections. The ``__init__`` method is called when the `.Thing` is first created by the server, and is primarily used + + +Client Code +----------- + +Client code can be written in any language that supports an HTTP request. However, LabThings FastAPI provides additional functionality that makes writing client code in Python easier. + +`.ThingClient` is a class that wraps up the required HTTP requests into a simpler interface. It can retrieve the :ref:`gen_td` over HTTP and use it to generate a new object with methods matching each `.thing_action` and properties matching each `.property`. + +While the current dynamic implemenation of `.ThingClient` can be inspected with functions like `help` at runtime, it does not work well with static tools like `mypy` or `pyright`\ . In the future, LabThings should be able to generate static client code that works better with autocompletion and type checking. \ No newline at end of file From c496f963120f76b4f8eda9e0c516c34b83484ae8 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 28 Oct 2025 01:08:50 +0000 Subject: [PATCH 13/15] Allow specifying folder in create_thing_without_server --- .../thing_server_interface.py | 20 ++++++++++++++++--- tests/test_thing_server_interface.py | 11 ++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/labthings_fastapi/thing_server_interface.py b/src/labthings_fastapi/thing_server_interface.py index c11116f..3f20eec 100644 --- a/src/labthings_fastapi/thing_server_interface.py +++ b/src/labthings_fastapi/thing_server_interface.py @@ -140,15 +140,18 @@ def get_thing_states(self) -> Mapping[str, Any]: class MockThingServerInterface(ThingServerInterface): """A mock class that simulates a ThingServerInterface without the server.""" - def __init__(self, name: str) -> None: + def __init__(self, name: str, settings_folder: str | None = None) -> None: """Initialise a ThingServerInterface. :param name: The name of the Thing we're providing an interface to. + :param settings_folder: The location where we should save settings. + By default, this is a temporary directory. """ # We deliberately don't call super().__init__(), as it won't work without # a server. self._name: str = name self._settings_tempdir: TemporaryDirectory | None = None + self._settings_folder = settings_folder def start_async_task_soon( self, async_function: Callable[Params, Awaitable[ReturnType]], *args: Any @@ -183,6 +186,8 @@ def settings_folder(self) -> str: :returns: the path to a temporary folder. """ + if self._settings_folder: + return self._settings_folder if not self._settings_tempdir: self._settings_tempdir = TemporaryDirectory() return self._settings_tempdir.name @@ -208,7 +213,10 @@ def get_thing_states(self) -> Mapping[str, Any]: def create_thing_without_server( - cls: type[ThingSubclass], *args: Any, **kwargs: Any + cls: type[ThingSubclass], + *args: Any, + settings_folder: str | None = None, + **kwargs: Any, ) -> ThingSubclass: r"""Create a `.Thing` and supply a mock ThingServerInterface. @@ -220,6 +228,8 @@ def create_thing_without_server( :param cls: The `.Thing` subclass to instantiate. :param \*args: positional arguments to ``__init__``. + :param settings_folder: The path to the settings folder. A temporary folder + is used by default. :param \**kwargs: keyword arguments to ``__init__``. :returns: an instance of ``cls`` with a `.MockThingServerInterface` @@ -233,7 +243,11 @@ def create_thing_without_server( msg = "You may not supply a keyword argument called 'thing_server_interface'." raise ValueError(msg) return cls( - *args, **kwargs, thing_server_interface=MockThingServerInterface(name=name) + *args, + **kwargs, + thing_server_interface=MockThingServerInterface( + name=name, settings_folder=settings_folder + ), ) # type: ignore[misc] # Note: we must ignore misc typing errors above because mypy flags an error # that `thing_server_interface` is multiply specified. diff --git a/tests/test_thing_server_interface.py b/tests/test_thing_server_interface.py index c63a0b1..f8435de 100644 --- a/tests/test_thing_server_interface.py +++ b/tests/test_thing_server_interface.py @@ -168,3 +168,14 @@ def test_create_thing_without_server(): assert isinstance(example, ExampleThing) assert example.path == "/examplething/" assert isinstance(example._thing_server_interface, tsi.MockThingServerInterface) + + # Check we can specify the settings location + with tempfile.TemporaryDirectory() as folder: + ex2 = tsi.create_thing_without_server(ExampleThing, settings_folder=folder) + assert ex2._thing_server_interface.settings_file_path == os.path.join( + folder, "settings.json" + ) + + # We can't supply the interface as a kwarg + with pytest.raises(ValueError, match="may not supply"): + tsi.create_thing_without_server(ExampleThing, thing_server_interface=None) From aa04aff2cc50ac5ceab2aab1a9433adc99265d47 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 28 Oct 2025 01:08:59 +0000 Subject: [PATCH 14/15] Spelling fixes in docs. --- docs/source/actions.rst | 2 +- docs/source/structure.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/actions.rst b/docs/source/actions.rst index d1aa4e1..68e1d1b 100644 --- a/docs/source/actions.rst +++ b/docs/source/actions.rst @@ -101,7 +101,7 @@ If you start a new thread from an action, code running in that thread will not have an invocation ID set in a context variable. A subclass of `threading.Thread` is provided to do this, `.ThreadWithInvocationID`\ . This may be useful for test code, or if you wish to run actions in the -backgound, with the option of cancelling them. +background, with the option of cancelling them. Raising exceptions ------------------ diff --git a/docs/source/structure.rst b/docs/source/structure.rst index c01c225..02514a1 100644 --- a/docs/source/structure.rst +++ b/docs/source/structure.rst @@ -51,4 +51,4 @@ Client code can be written in any language that supports an HTTP request. Howeve `.ThingClient` is a class that wraps up the required HTTP requests into a simpler interface. It can retrieve the :ref:`gen_td` over HTTP and use it to generate a new object with methods matching each `.thing_action` and properties matching each `.property`. -While the current dynamic implemenation of `.ThingClient` can be inspected with functions like `help` at runtime, it does not work well with static tools like `mypy` or `pyright`\ . In the future, LabThings should be able to generate static client code that works better with autocompletion and type checking. \ No newline at end of file +While the current dynamic implementation of `.ThingClient` can be inspected with functions like `help` at runtime, it does not work well with static tools like `mypy` or `pyright`\ . In the future, LabThings should be able to generate static client code that works better with autocompletion and type checking. \ No newline at end of file From 6aaf7b220c9b16923315c0114e6982a8c286f766 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 28 Oct 2025 16:26:13 +0000 Subject: [PATCH 15/15] silence a spurious type warning I've added an explanation comment with a link to the open pydantic issue. --- src/labthings_fastapi/server/config_model.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/labthings_fastapi/server/config_model.py b/src/labthings_fastapi/server/config_model.py index 1d8e9dc..38e4ca8 100644 --- a/src/labthings_fastapi/server/config_model.py +++ b/src/labthings_fastapi/server/config_model.py @@ -11,7 +11,9 @@ from collections.abc import Mapping, Sequence, Iterable -class ThingConfig(BaseModel): +# The type: ignore below is a spurious warning about `kwargs`. +# see https://github.com/pydantic/pydantic/issues/3125 +class ThingConfig(BaseModel): # type: ignore[no-redef] r"""The information needed to add a `.Thing` to a `.ThingServer`\ .""" cls: ImportString = Field(