From e8986f731509541b03c9791c304a852812e9bd7c Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 29 Apr 2026 12:52:46 +0100 Subject: [PATCH 1/9] Allow `ThingServer` to accept `ThingServerConfig` instances directly This avoids some argument duplication and avoids revalidation of the config model. This should allow subclassing of the config model, which ought to come in handy when customising the server downstream. I've also added a property for `debug` to allow downstream code to see it. --- src/labthings_fastapi/server/__init__.py | 150 ++++++++++++++++++----- src/labthings_fastapi/server/cli.py | 2 +- 2 files changed, 121 insertions(+), 31 deletions(-) diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index 9483baa5..5d0ae266 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -6,8 +6,9 @@ See the :ref:`tutorial` for examples of how to set up a `~lt.ThingServer`. """ -from __future__ import annotations -from typing import Any, AsyncGenerator, Optional, TypeVar +import warnings +from pydantic import ValidationError +from typing import Any, AsyncGenerator, Optional, TypeVar, overload from typing_extensions import Self import os import logging @@ -61,50 +62,55 @@ class ThingServer: an `anyio.from_thread.BlockingPortal`. """ + @overload + def __init__(self, config: ThingServerConfig, debug: bool = False) -> None: ... + + @overload + def __init__( + self, config: ThingsConfig, debug: bool = False, **kwargs: Any + ) -> None: ... + def __init__( self, - things: ThingsConfig, - settings_folder: Optional[str] = None, - api_prefix: str = "", - application_config: Optional[Mapping[str, Any]] = None, + config: ThingServerConfig | ThingsConfig | None = None, debug: bool = False, + **kwargs: Any, ) -> None: r"""Initialise a LabThings server. + The `~lt.ThingServer` is responsible for running the code in `~lt.Thing` + instances, and making them available over the network. It should be configured + by passing a `~lt.ThingServerConfig` object (or a dictionary that can + be validated as a `~lt.ThingServerConfig` object). + + For convenience, it's also possible to supply a dictionary mapping Thing + names to Thing configurations or classes. In this case, any extra keyword + arguments will be interpreted as additional keys in the server configuration. + Setting up the `~lt.ThingServer` involves creating the underlying `fastapi.FastAPI` app, setting its lifespan function (used to set up and shut down the `~lt.Thing` instances), and configuring it to allow cross-origin requests. - 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 `~lt.Thing` subclasses, or - `~lt.ThingConfig` objects specifying the subclass, its initialisation - arguments, and any connections to other `~lt.Thing`\ s. - :param settings_folder: the location on disk where `~lt.Thing` - settings will be saved. - :param api_prefix: A prefix for all API routes. This must either - be empty, or start with a slash and not end with a slash. - :param application_config: A mapping containing custom configuration for the - application. This is not processed by LabThings. Each `~lt.Thing` can - access this via the Thing-Server interface. + :param config: A `~lt.ThingServerConfig` object that configures the server, + or a mapping of Thing names to `~lt.Thing` subclasses or + `~lt.ThingConfig` objects. :param debug: If ``True``, set the log level for `~lt.Thing` instances to - DEBUG. + DEBUG. + :param \**kwargs: If keyword arguments are supplied, they will be passed + to the constructor of `~lt.ThingServerConfig`\ . This is not allowed + if `config` is a `~lt.ThingServerConfig` object. """ self.startup_failure: dict | None = None + self._debug = debug # Note: this is safe to call multiple times. - configure_thing_logger(logging.DEBUG if debug else None) - self._config = ThingServerConfig( - things=things, - settings_folder=settings_folder, - api_prefix=api_prefix, - application_config=application_config, - ) + configure_thing_logger(logging.DEBUG if self._debug else None) + self._config = self.config_from_args(config, **kwargs) + if self._config.settings_folder is None: + self._config.settings_folder = "./settings" self.app = FastAPI(lifespan=self.lifespan) self._set_cors_middleware() self._set_url_for_middleware() - self.settings_folder = settings_folder or "./settings" self.action_manager = ActionManager() self.app.include_router(self.action_manager.router(), prefix=self._api_prefix) self.app.include_router(blob.router, prefix=self._api_prefix) @@ -117,18 +123,82 @@ def __init__( self._connect_things() self._attach_things_to_server() + @classmethod + def config_from_args( + cls, + config: ThingServerConfig | ThingsConfig | None, + **kwargs: Any, + ) -> ThingServerConfig: + r"""Parse the arguments to __init__ to generate a config instance. + + See the `__init__` docstring for details of valid arguments. + + :param config: The configuration model or the `things` dictionary. + :param \**kwargs: Additional keyword arguments. + :return: A valid server configuration. + + :raises ValueError: if no configuration is supplied, or if arguments + are inconsistent. + """ + # The next step is to figure out our config. We support a few different ways + # of specifying the config: see the docstring for details. + if isinstance(config, ThingServerConfig): + # If an instance of the config model is supplied, there should be no kwargs. + if kwargs != {}: + raise ValueError( + f"Extra keyword arguments supplied to `ThingServer()`: {kwargs}. " + "When a `ThingServerConfig` object is specified, no extra keyword " + "arguments may be supplied." + ) + return config + if kwargs == {} and config is not None: + # If there are not additional keyword arguments, attempt to validate the + # `config` argument as a config model. This allows a dictionary to be + # supplied instead of a model instance. + try: + return ThingServerConfig.model_validate(config) + except ValidationError: + pass + if config is None and "things" in kwargs: + # The first argument used to be called "things" so this adds backwards + # compatibility. + config = kwargs.pop("things") + warnings.warn( + DeprecationWarning( + "The `things` argument has been renamed to `config`. See the " + "documentation for `ThingServer()`." + ), + stacklevel=3, + ) + if config is None: + # No config has been supplied, and no things argument either. + raise ValueError("No server configuration has been supplied.") + # If we get to here, the only remaining option is that we've been supplied a + # dictionary of Things, so we try to build a config model using that. + return ThingServerConfig( + things=config, + **kwargs, + ) + @classmethod def from_config(cls, config: ThingServerConfig, debug: bool = False) -> Self: r"""Create a ThingServer from a configuration model. - This is equivalent to ``ThingServer(**dict(config))``\ . + This is equivalent to ``ThingServer(config, debug=debug)``\ . :param config: The configuration parameters for the server. :param debug: If ``True``, set the log level for `~lt.Thing` instances to DEBUG. :return: A `~lt.ThingServer` configured as per the model. """ - return cls(**dict(config), debug=debug) + warnings.warn( + DeprecationWarning( + "`ThingServer.from_config()` is redundant and will be removed in " + "a future release. Use `ThingServer()` instead." + ), + stacklevel=2, + ) + return cls(config, debug=debug) def _set_cors_middleware(self) -> None: """Configure the server to allow requests from other origins. @@ -157,6 +227,26 @@ def _set_url_for_middleware(self) -> None: """ self.app.middleware("http")(url_for_middleware) + @property + def debug(self) -> bool: + """Whether the server is in debug mode.""" + return self._debug + + @property + def settings_folder(self) -> str: + """The folder in which we will store `Thing` settings. + + :raises ValueError: if there is no settings folder set. + This should never happen, as it's set in `__init__`. + """ + if self._config.settings_folder is None: + raise ValueError( + "The settings folder should be set during initialisation. " + "This may indicate a LabThings bug, or incorrect subclassing " + "of `ThingServer`." + ) + return self._config.settings_folder + @property def things(self) -> Mapping[str, Thing]: """Return a dictionary of all the things. diff --git a/src/labthings_fastapi/server/cli.py b/src/labthings_fastapi/server/cli.py index 3369b5c3..b585574b 100644 --- a/src/labthings_fastapi/server/cli.py +++ b/src/labthings_fastapi/server/cli.py @@ -158,7 +158,7 @@ def serve_from_cli( try: config, server = None, None config = config_from_args(args) - server = ThingServer.from_config(config, True if args.debug else False) + server = ThingServer(config, True if args.debug else False) if dry_run: return server uvicorn.run(server.app, host=args.host, port=args.port, ws="websockets-sansio") From 853b74e20d6cd45183a77585ae2839bc28fd5dd8 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 29 Apr 2026 13:06:13 +0100 Subject: [PATCH 2/9] Add convenience methods for `uvicorn.run` and `TestClient` to `ThingServer`. These methods don't do anything new, but might tidy up downstream code slightly. --- src/labthings_fastapi/server/__init__.py | 42 ++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index 5d0ae266..5f174d19 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -7,6 +7,7 @@ """ import warnings +from fastapi.testclient import TestClient from pydantic import ValidationError from typing import Any, AsyncGenerator, Optional, TypeVar, overload from typing_extensions import Self @@ -16,9 +17,10 @@ from fastapi import APIRouter, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from anyio.from_thread import BlockingPortal -from contextlib import asynccontextmanager, AsyncExitStack -from collections.abc import Mapping, Sequence +from contextlib import asynccontextmanager, AsyncExitStack, contextmanager +from collections.abc import Iterator, Mapping, Sequence from types import MappingProxyType +import uvicorn from ..middleware.url_for import url_for_middleware from ..thing_slots import ThingSlot @@ -474,3 +476,39 @@ def thing_paths(request: Request) -> Mapping[str, str]: } return router + + def serve(self, host: str = "localhost", port: int = 5000) -> None: + r"""Run the server in `uvicorn`\ . + + This method will run the server from Python, using `uvicorn.run`\ . + This is the most convenient way to run a LabThings server from Python, and + is identical to what happens when it is run from the command line. + + :param host: The IP address or hostname on which to serve. By default, this + is ``localhost`` which is only accessible from your computer. To serve + over a network on all available IPv4 addresses, use ``"0.0.0.0"``. + :param port: The port on which to serve. This defaults to 5000. + """ + uvicorn.run(self.app, host=host, port=port, ws="websockets-sansio") + + @contextmanager + def test_client(self) -> Iterator[TestClient]: + """A context manager to test out a server without binding to a port. + + This context manager will start up the server and run an event loop, but + instead of responding to requests on a network port, it uses + `fastapi.testclient.TestClient` to simulate HTTP requests. + + This is provided to simplify test code, and should not be used in production. + + :yields: a `fastapi.testclient.TestClient` to simulate HTTP requests. + + .. warning:: + + Usually, a server is only started up and shut down once. Calling this + method multiple times may have unexpected results. + + As a rule, only ever use this method in your test suite. + """ + with TestClient(self.app) as client: + yield client From 0138cb78514d957bdf22a15ad9add4b161453b86 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 29 Apr 2026 13:37:23 +0100 Subject: [PATCH 3/9] Add tests for new features --- src/labthings_fastapi/server/__init__.py | 4 +- src/labthings_fastapi/server/config_model.py | 17 +++++ tests/test_server.py | 65 ++++++++++++++++++++ tests/test_server_config_model.py | 2 + 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index 5f174d19..d7462f93 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -238,11 +238,11 @@ def debug(self) -> bool: def settings_folder(self) -> str: """The folder in which we will store `Thing` settings. - :raises ValueError: if there is no settings folder set. + :raises RuntimeError: if there is no settings folder set. This should never happen, as it's set in `__init__`. """ if self._config.settings_folder is None: - raise ValueError( + raise RuntimeError( "The settings folder should be set during initialisation. " "This may indicate a LabThings bug, or incorrect subclassing " "of `ThingServer`." diff --git a/src/labthings_fastapi/server/config_model.py b/src/labthings_fastapi/server/config_model.py index 679a0d3a..8cff6a97 100644 --- a/src/labthings_fastapi/server/config_model.py +++ b/src/labthings_fastapi/server/config_model.py @@ -8,6 +8,7 @@ """ from pydantic import ( + AfterValidator, BaseModel, Field, ImportString, @@ -118,9 +119,25 @@ class ThingConfig(BaseModel): # type: ignore[no-redef] ) +RESERVED_THING_NAMES = ("things", "cls") + + +def check_reserved_thing_names(name: str) -> str: + """Validate a Thing name by checking it's not in a list of banned names. + + :param name: the name to check. + :return: the name, if valid. + :raises ValueError: if the name is not valid. + """ + if name in RESERVED_THING_NAMES: + raise ValueError(f"{name} is not allowed as the name for a Thing.") + return name + + ThingName = Annotated[ str, Field(min_length=1, pattern=r"^([a-zA-Z0-9\-_]+)$"), + AfterValidator(check_reserved_thing_names), ] diff --git a/tests/test_server.py b/tests/test_server.py index 455229e5..a0216e7f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -10,6 +10,7 @@ from starlette.routing import Route from labthings_fastapi.example_things import MyThing +from labthings_fastapi.server.config_model import ThingServerConfig def test_server_from_config_non_thing_error(): @@ -137,3 +138,67 @@ def test_things_endpoints(): td = response.json() assert td["title"] == "MyThing" assert tds["thing_a"] == td + + +@pytest.mark.parametrize( + ("input", "validated"), + [ + (None, False), + (True, True), + (False, False), + ], +) +def test_debug_flag(input, validated): + """Check that the debug flag can be retrieved.""" + kwargs = {} + if input is not None: + kwargs["debug"] = input + server = lt.ThingServer({}, **kwargs) + assert server.debug is validated + with pytest.raises(AttributeError): + server.debug = False + + +def test_settings_folder(): + """Check that the settings folder behaves correctly.""" + # Without setting a value, it should take the default value + server = lt.ThingServer({}) + assert server.settings_folder == "./settings" + server._config.settings_folder = None # Deliberately induce error + with pytest.raises(RuntimeError): + # If the config object has None for the settings folder, + # an error should be raised. This is set to a string in + # __init__. + _ = server.settings_folder + + # The settings folder should be settable from an argument or config + server = lt.ThingServer({}, settings_folder="./mysettings") + assert server.settings_folder == "./mysettings" + + # The settings folder should be settable from an argument or config + server = lt.ThingServer({"things": {}, "settings_folder": "./mysettings"}) + assert server.settings_folder == "./mysettings" + + +def test_server_init(): + """Check the various different ways in which the server may be initialised.""" + config_dict = { + "things": { + "my_thing": MyThing, + }, + "api_prefix": "/api/v3", + } + config_model = ThingServerConfig(**config_dict) + + def check_server(server: lt.ThingServer, debug: bool = False): + """Make sure the server config is as expected.""" + assert len(server.things) == 1 + assert isinstance(server.things["my_thing"], MyThing) + assert server._api_prefix == "/api/v3" + assert server.debug == debug + + check_server(lt.ThingServer(config_dict)) + check_server(lt.ThingServer(config_model)) + check_server(lt.ThingServer(config_dict["things"], api_prefix="/api/v3")) + check_server(lt.ThingServer(config_model.thing_configs, api_prefix="/api/v3")) + check_server(lt.ThingServer(config_model, debug=True), debug=True) diff --git a/tests/test_server_config_model.py b/tests/test_server_config_model.py index 0ead57fb..272ba4eb 100644 --- a/tests/test_server_config_model.py +++ b/tests/test_server_config_model.py @@ -68,6 +68,8 @@ def test_ThingConfig(): "thing/with/slashes", "trailingslash/", "/leadingslash", + "things", + "cls", ] From 1eba113586bca5ab74910633e9fe9124581a570a Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 29 Apr 2026 16:00:22 +0100 Subject: [PATCH 4/9] Remove the ability to pass things instead of `ThingServerConfig`. This makes the initialiser of `ThingServer` much simpler, and makes it clearer what should go where. It's technically a breaking change, but I think it's easy enough to fix that Julian and I are comfortable it's the right call here. --- src/labthings_fastapi/server/__init__.py | 131 ++++++++++++----------- src/labthings_fastapi/server/cli.py | 2 +- 2 files changed, 67 insertions(+), 66 deletions(-) diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index d7462f93..82fe5ab5 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -65,16 +65,15 @@ class ThingServer: """ @overload - def __init__(self, config: ThingServerConfig, debug: bool = False) -> None: ... + def __init__(self, config: ThingServerConfig, *, debug: bool = False) -> None: ... @overload - def __init__( - self, config: ThingsConfig, debug: bool = False, **kwargs: Any - ) -> None: ... + def __init__(self, *, debug: bool = False, **kwargs: Any) -> None: ... def __init__( self, - config: ThingServerConfig | ThingsConfig | None = None, + config: ThingServerConfig | None = None, + *, debug: bool = False, **kwargs: Any, ) -> None: @@ -85,29 +84,57 @@ def __init__( by passing a `~lt.ThingServerConfig` object (or a dictionary that can be validated as a `~lt.ThingServerConfig` object). - For convenience, it's also possible to supply a dictionary mapping Thing - names to Thing configurations or classes. In this case, any extra keyword - arguments will be interpreted as additional keys in the server configuration. + For convenience and backwards compatibility, if `config` is `None` the keyword + arguments will be passed to `~lt.ThingServerConfig` instead. Keyword arguments + may not be used if the `config` argument is used, and may be removed in the + future. Setting up the `~lt.ThingServer` involves creating the underlying `fastapi.FastAPI` app, setting its lifespan function (used to set up and shut down the `~lt.Thing` instances), and configuring it to allow cross-origin requests. - :param config: A `~lt.ThingServerConfig` object that configures the server, - or a mapping of Thing names to `~lt.Thing` subclasses or - `~lt.ThingConfig` objects. - :param debug: If ``True``, set the log level for `~lt.Thing` instances to + :param config: a `~lt.ThingServerConfig` object that configures the server, + or something that may be converted to one. + :param debug: ff ``True``, set the log level for `~lt.Thing` instances to DEBUG. - :param \**kwargs: If keyword arguments are supplied, they will be passed + :param \**kwargs: ff keyword arguments are supplied, they will be passed to the constructor of `~lt.ThingServerConfig`\ . This is not allowed if `config` is a `~lt.ThingServerConfig` object. + + :raises TypeError: if the value of `config` cannot be parsed as a + `~lt.ThingServerConfig`\ . + :raises ValueError: if keyword arguments are supplied together with `config`\ . """ self.startup_failure: dict | None = None self._debug = debug # Note: this is safe to call multiple times. configure_thing_logger(logging.DEBUG if self._debug else None) - self._config = self.config_from_args(config, **kwargs) + if config is not None: + try: + self._config = ThingServerConfig.model_validate(config) + except ValidationError as e: + raise TypeError( + "The value passed to `ThingServer()` could not be validated as " + "a server configuration. If you are passing a dictionary of " + "Things, this must be done using `ThingServer.from_things` instead." + ) from e + if kwargs != {}: + raise ValueError( + f"Extra keyword arguments supplied to `ThingServer()`: {kwargs}. " + "When a `ThingServerConfig` object is specified, no extra keyword " + "arguments may be supplied." + ) + else: + warnings.warn( + DeprecationWarning( + "`ThingServer` should be initialised with the `config` parameter. " + "Taking configuration options from keyword arguments will be " + "removed in a future release." + ), + stacklevel=2, + ) + self._config = ThingServerConfig(**kwargs) if self._config.settings_folder is None: self._config.settings_folder = "./settings" self.app = FastAPI(lifespan=self.lifespan) @@ -126,60 +153,34 @@ def __init__( self._attach_things_to_server() @classmethod - def config_from_args( + def from_things( cls, - config: ThingServerConfig | ThingsConfig | None, + things: ThingsConfig, + debug: bool = False, **kwargs: Any, - ) -> ThingServerConfig: - r"""Parse the arguments to __init__ to generate a config instance. - - See the `__init__` docstring for details of valid arguments. - - :param config: The configuration model or the `things` dictionary. - :param \**kwargs: Additional keyword arguments. - :return: A valid server configuration. - - :raises ValueError: if no configuration is supplied, or if arguments - are inconsistent. + ) -> Self: + r"""Create a ThingServer using a dictionary of `~lt.Thing` subclasses. + + In test and example code, it's convenient to be able to pass server and + `Thing` configurations as keyword arguments rather than a config model. + + This convenience method will turn its keyword arguments into a server + configuration and create a server based on it. + + :param things: A mapping of names to `Thing` configurations. These may + be specified as a `~lt.ThingConfig` object, a `~lt.Thing` subclass, + or an import string referencing a `~lt.Thing` subclass. + :param debug: Whether to start the server in debug mode. + :param \**kwargs: Additional keyword arguments are passed to + `~lt.ThingServerConfig`\ . + :return: a `~lt.ThingServer` instance. """ - # The next step is to figure out our config. We support a few different ways - # of specifying the config: see the docstring for details. - if isinstance(config, ThingServerConfig): - # If an instance of the config model is supplied, there should be no kwargs. - if kwargs != {}: - raise ValueError( - f"Extra keyword arguments supplied to `ThingServer()`: {kwargs}. " - "When a `ThingServerConfig` object is specified, no extra keyword " - "arguments may be supplied." - ) - return config - if kwargs == {} and config is not None: - # If there are not additional keyword arguments, attempt to validate the - # `config` argument as a config model. This allows a dictionary to be - # supplied instead of a model instance. - try: - return ThingServerConfig.model_validate(config) - except ValidationError: - pass - if config is None and "things" in kwargs: - # The first argument used to be called "things" so this adds backwards - # compatibility. - config = kwargs.pop("things") - warnings.warn( - DeprecationWarning( - "The `things` argument has been renamed to `config`. See the " - "documentation for `ThingServer()`." - ), - stacklevel=3, - ) - if config is None: - # No config has been supplied, and no things argument either. - raise ValueError("No server configuration has been supplied.") - # If we get to here, the only remaining option is that we've been supplied a - # dictionary of Things, so we try to build a config model using that. - return ThingServerConfig( - things=config, - **kwargs, + return cls( + ThingServerConfig( + things=things, + **kwargs, + ), + debug=debug, ) @classmethod diff --git a/src/labthings_fastapi/server/cli.py b/src/labthings_fastapi/server/cli.py index b585574b..245ae645 100644 --- a/src/labthings_fastapi/server/cli.py +++ b/src/labthings_fastapi/server/cli.py @@ -158,7 +158,7 @@ def serve_from_cli( try: config, server = None, None config = config_from_args(args) - server = ThingServer(config, True if args.debug else False) + server = ThingServer(config, debug=True if args.debug else False) if dry_run: return server uvicorn.run(server.app, host=args.host, port=args.port, ws="websockets-sansio") From c783bab711c0c582e70b0f57c891378d6639ffa3 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 29 Apr 2026 16:07:03 +0100 Subject: [PATCH 5/9] Update test and example code with new syntax. Passing a dictionary of things is common in test/example code, and needs to be updated to use the new form. This commit is more or less a search and replace to do that. `ThingServer({}, **kwargs)` becomes `ThingServer.from_things({}, **kwargs)` `ThingServer.from_config(config)` becomes `ThingServer(config)` --- docs/source/quickstart/counter.py | 2 +- docs/source/thing_slots.rst | 2 +- docs/source/tutorial/writing_a_thing.rst | 2 +- src/labthings_fastapi/outputs/mjpeg_stream.py | 2 +- tests/test_action_cancel.py | 2 +- tests/test_action_logging.py | 2 +- tests/test_action_manager.py | 2 +- tests/test_actions.py | 2 +- tests/test_blob_output.py | 2 +- tests/test_endpoint_decorator.py | 2 +- tests/test_fallback.py | 4 +- tests/test_locking_decorator.py | 2 +- tests/test_logs.py | 2 +- tests/test_mjpeg_stream.py | 4 +- tests/test_numpy_type.py | 2 +- tests/test_properties.py | 6 ++- tests/test_server.py | 26 ++++++---- tests/test_server_cli.py | 2 +- tests/test_settings.py | 48 ++++++++++++++----- tests/test_thing.py | 4 +- tests/test_thing_client.py | 4 +- tests/test_thing_lifecycle.py | 2 +- tests/test_thing_server_interface.py | 4 +- tests/test_thing_slots.py | 12 ++--- tests/test_websocket.py | 2 +- 25 files changed, 89 insertions(+), 55 deletions(-) diff --git a/docs/source/quickstart/counter.py b/docs/source/quickstart/counter.py index 187bb887..76f3fabf 100644 --- a/docs/source/quickstart/counter.py +++ b/docs/source/quickstart/counter.py @@ -31,7 +31,7 @@ def slowly_increase_counter(self) -> None: if __name__ == "__main__": import uvicorn - server = lt.ThingServer({"counter": TestThing}) + server = lt.ThingServer.from_things({"counter": TestThing}) # We run the server using `uvicorn`: uvicorn.run(server.app, port=5000, ws="websockets-sansio") diff --git a/docs/source/thing_slots.rst b/docs/source/thing_slots.rst index 32034d2f..13a057be 100644 --- a/docs/source/thing_slots.rst +++ b/docs/source/thing_slots.rst @@ -43,7 +43,7 @@ The following example shows the use of a `~lt.thing_slot`: things = {"thing_a": ThingA, "thing_b": ThingB} - server = lt.ThingServer(things) + server = lt.ThingServer.from_things(things) In this example, ``ThingB.thing_a`` is the simplest form of `~lt.thing_slot`: it diff --git a/docs/source/tutorial/writing_a_thing.rst b/docs/source/tutorial/writing_a_thing.rst index 1df0595a..d2996cc1 100644 --- a/docs/source/tutorial/writing_a_thing.rst +++ b/docs/source/tutorial/writing_a_thing.rst @@ -30,7 +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({"light": Light}) + server = lt.ThingServer.from_things({"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 0a79aa78..2688fe21 100644 --- a/src/labthings_fastapi/outputs/mjpeg_stream.py +++ b/src/labthings_fastapi/outputs/mjpeg_stream.py @@ -456,7 +456,7 @@ class Camera(lt.Thing): stream = MJPEGStreamDescriptor() - server = lt.ThingServer({"camera": Camera}) + server = lt.ThingServer.from_things({"camera": Camera}) :param app: the `fastapi.FastAPI` application to which we are being added. :param thing: the host `~lt.Thing` instance. diff --git a/tests/test_action_cancel.py b/tests/test_action_cancel.py index ea07c241..e12d3c6c 100644 --- a/tests/test_action_cancel.py +++ b/tests/test_action_cancel.py @@ -68,7 +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({"counting_thing": CancellableCountingThing}) + server = lt.ThingServer.from_things({"counting_thing": CancellableCountingThing}) return server diff --git a/tests/test_action_logging.py b/tests/test_action_logging.py index c056b7e1..c7c75026 100644 --- a/tests/test_action_logging.py +++ b/tests/test_action_logging.py @@ -34,7 +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({"log_and_error_thing": ThingThatLogsAndErrors}) + server = lt.ThingServer.from_things({"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 83d67a18..78de3254 100644 --- a/tests/test_action_manager.py +++ b/tests/test_action_manager.py @@ -28,7 +28,7 @@ def increment_counter_longlife(self): @pytest.fixture def client(): """Yield a TestClient connected to a ThingServer.""" - server = lt.ThingServer({"thing": CounterThing}) + server = lt.ThingServer.from_things({"thing": CounterThing}) with TestClient(server.app) as client: yield client diff --git a/tests/test_actions.py b/tests/test_actions.py index bfc26422..22e4518f 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -30,7 +30,7 @@ def say_hello(self) -> str: @pytest.fixture def client(): """Yield a client connected to a ThingServer""" - server = lt.ThingServer({"thing": MyThing}) + server = lt.ThingServer.from_things({"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 60144a3e..4ba6fe01 100644 --- a/tests/test_blob_output.py +++ b/tests/test_blob_output.py @@ -80,7 +80,7 @@ def check_passthrough(self) -> bool: @pytest.fixture def client(): """Yield a test client connected to a ThingServer.""" - server = lt.ThingServer( + server = lt.ThingServer.from_things( { "thing_one": ThingOne, "thing_two": ThingTwo, diff --git a/tests/test_endpoint_decorator.py b/tests/test_endpoint_decorator.py index def69c06..e25ff7ab 100644 --- a/tests/test_endpoint_decorator.py +++ b/tests/test_endpoint_decorator.py @@ -24,7 +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({"thing": MyThing}) + server = lt.ThingServer.from_things({"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 e3a1136f..572473b4 100644 --- a/tests/test_fallback.py +++ b/tests/test_fallback.py @@ -136,7 +136,7 @@ def test_fallback_with_error(): def test_fallback_with_server(): config = lt.ThingServerConfig.model_validate(CONFIG_DICT) - server = lt.ThingServer.from_config(config) + server = lt.ThingServer(config) app.set_context(FallbackContext(server=server)) with TestClient(app) as client: response = client.get("/") @@ -166,7 +166,7 @@ def test_actual_server_fallback(): fallback. """ # ThingThatCantStart has an error in __enter__ - server = lt.ThingServer({"bad_thing": ThingThatCantStart}) + server = lt.ThingServer.from_things({"bad_thing": ThingThatCantStart}) # Starting the server is a SystemExit with pytest.raises(SystemExit, match="3") as excinfo: diff --git a/tests/test_locking_decorator.py b/tests/test_locking_decorator.py index 8caa8e15..6e64dd2c 100644 --- a/tests/test_locking_decorator.py +++ b/tests/test_locking_decorator.py @@ -115,7 +115,7 @@ def echo_via_client(client): def test_locking_in_server(): """Check the lock works within LabThings.""" - server = lt.ThingServer({"thing": LockedExample}) + server = lt.ThingServer.from_things({"thing": LockedExample}) thing = server.things["thing"] with TestClient(server.app) as client: # Start a long task diff --git a/tests/test_logs.py b/tests/test_logs.py index ead3152d..566f492c 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -266,7 +266,7 @@ def test_add_thing_log_destination(): def _call_action_can_get_logs(): """Run `log_and_capture` as an action, Return the final HTTP response.""" - server = lt.ThingServer({"logging_thing": ThingThatLogs}) + server = lt.ThingServer.from_things({"logging_thing": ThingThatLogs}) with TestClient(server.app) as client: response = client.post("/logging_thing/log_and_capture", json={"msg": "foobar"}) response.raise_for_status() diff --git a/tests/test_mjpeg_stream.py b/tests/test_mjpeg_stream.py index f00b43b1..ba818ec5 100644 --- a/tests/test_mjpeg_stream.py +++ b/tests/test_mjpeg_stream.py @@ -46,7 +46,7 @@ def _make_images(self): @pytest.fixture def client(): """Yield a test client connected to a ThingServer""" - server = lt.ThingServer({"telly": Telly}) + server = lt.ThingServer.from_things({"telly": Telly}) with TestClient(server.app) as client: yield client @@ -73,7 +73,7 @@ def test_mjpeg_stream(client): if __name__ == "__main__": import uvicorn - server = lt.ThingServer({"telly": Telly}) + server = lt.ThingServer.from_things({"telly": Telly}) telly = server.things["telly"] assert isinstance(telly, Telly) telly.framerate = 6 diff --git a/tests/test_numpy_type.py b/tests/test_numpy_type.py index cf1d5c9d..1850ba78 100644 --- a/tests/test_numpy_type.py +++ b/tests/test_numpy_type.py @@ -115,7 +115,7 @@ def test_rootmodel(): def test_numpy_over_http(): """Read numpy array over http.""" - server = lt.ThingServer({"np_thing": MyNumpyThing}) + server = lt.ThingServer.from_things({"np_thing": MyNumpyThing}) with TestClient(server.app) as client: np_thing_client = lt.ThingClient.from_url("/np_thing/", client=client) diff --git a/tests/test_properties.py b/tests/test_properties.py index 7b6b9b6e..b4cb3031 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -204,7 +204,9 @@ class ConstrainedPropInfo: @pytest.fixture def server(): with TemporaryDirectory() as dirpath: - server = lt.ThingServer({"thing": PropertyTestThing}, settings_folder=dirpath) + server = lt.ThingServer.from_things( + {"thing": PropertyTestThing}, settings_folder=dirpath + ) yield server @@ -389,7 +391,7 @@ def test_setting_without_event_loop(): # This test may need to change, if we change the intended behaviour # Currently it should never be necessary to change properties from the # main thread, so we raise an error if you try to do so - server = lt.ThingServer({"thing": PropertyTestThing}) + server = lt.ThingServer.from_things({"thing": PropertyTestThing}) thing = server.things["thing"] assert isinstance(thing, PropertyTestThing) with pytest.raises(ServerNotRunningError): diff --git a/tests/test_server.py b/tests/test_server.py index a0216e7f..376b8919 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -16,7 +16,7 @@ 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"): - lt.ThingServer.from_config( + lt.ThingServer( lt.ThingServerConfig( things={"thingone": lt.ThingConfig(cls="builtins:object")} ) @@ -51,7 +51,7 @@ def test_server_thing_descriptions(): "slowly_increase_counter", ] - server = lt.ThingServer.from_config(conf) + server = lt.ThingServer(conf) with TestClient(server.app) as client: response = client.get("/api/thing_descriptions/") response.raise_for_status() @@ -81,7 +81,7 @@ def test_api_prefix(api_prefix): class Example(lt.Thing): """An example Thing""" - server = lt.ThingServer(things={"example": Example}, api_prefix=api_prefix) + server = lt.ThingServer.from_things({"example": Example}, api_prefix=api_prefix) paths = [route.path for route in server.app.routes if isinstance(route, Route)] # Dynamically generate expected paths based on the parametrized prefix @@ -111,7 +111,7 @@ class Example(lt.Thing): def test_things_endpoints(): """Test that the two endpoints for listing Things work.""" - server = lt.ThingServer( + server = lt.ThingServer.from_things( { "thing_a": MyThing, "thing_b": MyThing, @@ -153,7 +153,7 @@ def test_debug_flag(input, validated): kwargs = {} if input is not None: kwargs["debug"] = input - server = lt.ThingServer({}, **kwargs) + server = lt.ThingServer.from_things({}, **kwargs) assert server.debug is validated with pytest.raises(AttributeError): server.debug = False @@ -162,7 +162,7 @@ def test_debug_flag(input, validated): def test_settings_folder(): """Check that the settings folder behaves correctly.""" # Without setting a value, it should take the default value - server = lt.ThingServer({}) + server = lt.ThingServer.from_things({}) assert server.settings_folder == "./settings" server._config.settings_folder = None # Deliberately induce error with pytest.raises(RuntimeError): @@ -172,11 +172,13 @@ def test_settings_folder(): _ = server.settings_folder # The settings folder should be settable from an argument or config - server = lt.ThingServer({}, settings_folder="./mysettings") + server = lt.ThingServer.from_things({}, settings_folder="./mysettings") assert server.settings_folder == "./mysettings" # The settings folder should be settable from an argument or config - server = lt.ThingServer({"things": {}, "settings_folder": "./mysettings"}) + server = lt.ThingServer( + lt.ThingServerConfig(things={}, settings_folder="./mysettings") + ) assert server.settings_folder == "./mysettings" @@ -199,6 +201,10 @@ def check_server(server: lt.ThingServer, debug: bool = False): check_server(lt.ThingServer(config_dict)) check_server(lt.ThingServer(config_model)) - check_server(lt.ThingServer(config_dict["things"], api_prefix="/api/v3")) - check_server(lt.ThingServer(config_model.thing_configs, api_prefix="/api/v3")) + check_server( + lt.ThingServer.from_things(config_dict["things"], api_prefix="/api/v3") + ) + check_server( + lt.ThingServer.from_things(config_model.thing_configs, api_prefix="/api/v3") + ) check_server(lt.ThingServer(config_model, debug=True), debug=True) diff --git a/tests/test_server_cli.py b/tests/test_server_cli.py index 2a391091..9022339b 100644 --- a/tests/test_server_cli.py +++ b/tests/test_server_cli.py @@ -81,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 = ThingServer.from_config(CONFIG) + server = ThingServer(CONFIG) assert isinstance(server, ThingServer) diff --git a/tests/test_settings.py b/tests/test_settings.py index eb7ac54e..1ae605e3 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -176,7 +176,9 @@ def test_functional_settings_save(tempdir): ``floatsetting`` is a functional setting, we should also test a `.DataSetting` for completeness. """ - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"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) @@ -202,7 +204,9 @@ def test_data_settings_save(tempdir): This uses ``intsetting`` which is a `.DataSetting` so it tests a different code path to the functional setting above.""" - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"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. @@ -225,7 +229,9 @@ def test_data_settings_save(tempdir): def test_settings_dict_save(tempdir): """Check settings are saved if the dict is updated in full""" - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) setting_file = _get_setting_file(server, "thing") thing = server.things["thing"] assert isinstance(thing, ThingWithSettings) @@ -245,7 +251,9 @@ def test_settings_dict_internal_update(tempdir): This behaviour is not ideal, but it is documented. If the behaviour is updated then the documentation should be updated and this test removed """ - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) setting_file = _get_setting_file(server, "thing") thing = server.things["thing"] assert isinstance(thing, ThingWithSettings) @@ -260,7 +268,9 @@ def test_settings_dict_internal_update(tempdir): def test_settings_load(tempdir, caplog): """Check settings can be loaded from disk when added to server""" - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) setting_file = _get_setting_file(server, "thing") del server setting_json = json.dumps( @@ -276,7 +286,9 @@ def test_settings_load(tempdir, caplog): file_obj.write(setting_json) with caplog.at_level(logging.WARNING): # Add thing to server and check new settings are loaded - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) assert len(caplog.records) == 0 thing = server.things["thing"] assert isinstance(thing, ThingWithSettings) @@ -289,7 +301,9 @@ def test_settings_load(tempdir, caplog): def test_load_extra_settings(caplog, tempdir): """Load from setting file. Extra setting in file should create a warning.""" - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) setting_file = _get_setting_file(server, "thing") del server setting_dict = _settings_dict(floatsetting=3.0, stringsetting="bar") @@ -302,7 +316,9 @@ def test_load_extra_settings(caplog, tempdir): # Recreate the server and check for the error with caplog.at_level(logging.WARNING): # Add thing to server - _ = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + _ = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) assert len(caplog.records) == 1 assert caplog.records[0].levelname == "WARNING" assert caplog.records[0].name == "labthings_fastapi.things.thing" @@ -311,7 +327,9 @@ def test_load_extra_settings(caplog, tempdir): def test_try_loading_corrupt_settings(tempdir, caplog): """Load from setting file. Extra setting in file should create a warning.""" # Create the server once, so we can get the settings path - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) setting_file = _get_setting_file(server, "thing") del server @@ -327,7 +345,9 @@ def test_try_loading_corrupt_settings(tempdir, caplog): # Recreate the server and check for the warning in logs with caplog.at_level(logging.WARNING): # Add thing to server - _ = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + _ = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) assert len(caplog.records) == 1 assert caplog.records[0].levelname == "WARNING" assert caplog.records[0].name == "labthings_fastapi.things.thing" @@ -336,7 +356,9 @@ def test_try_loading_corrupt_settings(tempdir, caplog): def test_try_loading_setting_file_without_mapping(tempdir, caplog): """Load from setting file that isn't a JSON object.""" # Create the server once, so we can get the settings path - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) setting_file = _get_setting_file(server, "thing") del server @@ -345,7 +367,9 @@ def test_try_loading_setting_file_without_mapping(tempdir, caplog): # Recreate the server and check for the warning in logs with caplog.at_level(logging.WARNING): # Add thing to server - _ = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + _ = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) assert len(caplog.records) == 1 assert caplog.records[0].levelname == "WARNING" assert caplog.records[0].name == "labthings_fastapi.things.thing" diff --git a/tests/test_thing.py b/tests/test_thing.py index 523bda34..92183fa7 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -11,7 +11,7 @@ def test_td_validates(): def test_add_thing(): """Check that thing can be added to the server""" - server = ThingServer({"thing": MyThing}) + server = ThingServer.from_things({"thing": MyThing}) assert isinstance(server.things["thing"], MyThing) @@ -22,7 +22,7 @@ def test_thing_can_access_application_config(): "application_config": {"foo": "bar", "mock": True}, } - server = ThingServer.from_config(conf) + server = ThingServer(conf) thing1 = server.things["thing1"] thing2 = server.things["thing2"] diff --git a/tests/test_thing_client.py b/tests/test_thing_client.py index 04333a52..d8e283a5 100644 --- a/tests/test_thing_client.py +++ b/tests/test_thing_client.py @@ -63,7 +63,9 @@ def throw_value_error(self) -> None: @pytest.fixture def thing_client_and_thing(): """Yield a test client connected to a ThingServer and the Thing itself.""" - server = lt.ThingServer({"test_thing": ThingToTest}, api_prefix="/api/v1") + server = lt.ThingServer.from_things( + {"test_thing": ThingToTest}, api_prefix="/api/v1" + ) with TestClient(server.app) as client: thing_client = lt.ThingClient.from_url("/api/v1/test_thing/", client=client) thing = server.things["test_thing"] diff --git a/tests/test_thing_lifecycle.py b/tests/test_thing_lifecycle.py index e737506d..23deada2 100644 --- a/tests/test_thing_lifecycle.py +++ b/tests/test_thing_lifecycle.py @@ -20,7 +20,7 @@ def __exit__(self, *args): @pytest.fixture def server(): """A ThingServer with a LifecycleThing.""" - return lt.ThingServer({"thing": LifecycleThing}) + return lt.ThingServer.from_things({"thing": LifecycleThing}) @pytest.fixture diff --git a/tests/test_thing_server_interface.py b/tests/test_thing_server_interface.py index 27ee091f..f08cffb4 100644 --- a/tests/test_thing_server_interface.py +++ b/tests/test_thing_server_interface.py @@ -69,7 +69,7 @@ class ExampleWithSlots(lt.Thing): def server(): """Return a LabThings server""" with tempfile.TemporaryDirectory() as dir: - server = lt.ThingServer( + server = lt.ThingServer.from_things( things={"example": ExampleThing}, settings_folder=dir, ) @@ -102,7 +102,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(things={}) + server = lt.ThingServer.from_things(things={}) interface = ThingServerInterface(server, NAME) assert interface._get_server() is server del server diff --git a/tests/test_thing_slots.py b/tests/test_thing_slots.py index 08f83161..d3bdeb0b 100644 --- a/tests/test_thing_slots.py +++ b/tests/test_thing_slots.py @@ -344,7 +344,7 @@ 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( + server = lt.ThingServer.from_things( things={ "thing_one": lt.ThingConfig( cls=cls_1, thing_slots=connections.get("thing_one", {}) @@ -397,7 +397,7 @@ def test_connections_none_default(connections, error): } if error is None: - server = lt.ThingServer(things) + server = lt.ThingServer.from_things(things) with TestClient(server.app): thing_one = server.things["thing_one"] assert isinstance(thing_one, ThingN) @@ -405,12 +405,12 @@ def test_connections_none_default(connections, error): return with pytest.raises(ThingSlotError, match=error): - server = lt.ThingServer(things) + server = lt.ThingServer.from_things(things) def test_optional_and_empty(): """Check that an optional or mapping connection can be None/empty.""" - server = lt.ThingServer({"thing_one": ThingOne, "thing_two": ThingTwo}) + server = lt.ThingServer.from_things({"thing_one": ThingOne, "thing_two": ThingTwo}) with TestClient(server.app): thing_one = server.things["thing_one"] @@ -435,14 +435,14 @@ 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(ThingSlotError, match="multiple Things"): - server = lt.ThingServer(things) + server = lt.ThingServer.from_things(things) # Set optional thing to one specific name and it will start OK. things["thing_one"] = lt.ThingConfig( cls=ThingOne, thing_slots={"optional_thing": "thing_3"}, ) - server = lt.ThingServer(things) + server = lt.ThingServer.from_things(things) with TestClient(server.app): thing_one = server.things["thing_one"] assert isinstance(thing_one, ThingOne) diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 2781e04f..d66e397c 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -55,7 +55,7 @@ def cancel_myself(self): @pytest.fixture def server(): """Create a server, and add a MyThing test Thing to it.""" - server = lt.ThingServer({"thing": ThingWithProperties}) + server = lt.ThingServer.from_things({"thing": ThingWithProperties}) return server From ed12ed93b49c40d9e1d28123d39774f2c493b81c Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 29 Apr 2026 16:15:28 +0100 Subject: [PATCH 6/9] Use `ThingServer.test_client()` in the test suite. This seems tidier than `TestClient(server.app)`. --- tests/test_action_cancel.py | 3 +-- tests/test_action_logging.py | 3 +-- tests/test_action_manager.py | 3 +-- tests/test_actions.py | 2 +- tests/test_blob_output.py | 2 +- tests/test_endpoint_decorator.py | 3 +-- tests/test_locking_decorator.py | 3 +-- tests/test_logs.py | 3 +-- tests/test_mjpeg_stream.py | 3 +-- tests/test_numpy_type.py | 3 +-- tests/test_properties.py | 15 +++++++-------- tests/test_server.py | 5 ++--- tests/test_settings.py | 9 ++++----- tests/test_thing_client.py | 3 +-- tests/test_thing_lifecycle.py | 7 +++---- tests/test_thing_server_interface.py | 5 ++--- tests/test_thing_slots.py | 9 ++++----- tests/test_websocket.py | 3 +-- 18 files changed, 34 insertions(+), 50 deletions(-) diff --git a/tests/test_action_cancel.py b/tests/test_action_cancel.py index e12d3c6c..891a1504 100644 --- a/tests/test_action_cancel.py +++ b/tests/test_action_cancel.py @@ -4,7 +4,6 @@ import uuid import pytest -from fastapi.testclient import TestClient from .temp_client import poll_task, task_href import labthings_fastapi as lt import time @@ -80,7 +79,7 @@ def counting_thing(server): @pytest.fixture def client(server): - with TestClient(server.app) as client: + with server.test_client() as client: yield client diff --git a/tests/test_action_logging.py b/tests/test_action_logging.py index c7c75026..3bf4eb9a 100644 --- a/tests/test_action_logging.py +++ b/tests/test_action_logging.py @@ -3,7 +3,6 @@ """ import logging -from fastapi.testclient import TestClient import pytest from .temp_client import poll_task import labthings_fastapi as lt @@ -35,7 +34,7 @@ def action_with_invocation_error(self): def client(): """Set up a Thing Server and yield a client to it.""" server = lt.ThingServer.from_things({"log_and_error_thing": ThingThatLogsAndErrors}) - with TestClient(server.app) as client: + with server.test_client() as client: yield client diff --git a/tests/test_action_manager.py b/tests/test_action_manager.py index 78de3254..37242d42 100644 --- a/tests/test_action_manager.py +++ b/tests/test_action_manager.py @@ -1,4 +1,3 @@ -from fastapi.testclient import TestClient import pytest import httpx from .temp_client import poll_task @@ -29,7 +28,7 @@ def increment_counter_longlife(self): def client(): """Yield a TestClient connected to a ThingServer.""" server = lt.ThingServer.from_things({"thing": CounterThing}) - with TestClient(server.app) as client: + with server.test_client() as client: yield client diff --git a/tests/test_actions.py b/tests/test_actions.py index 22e4518f..fbf892ba 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -31,7 +31,7 @@ def say_hello(self) -> str: def client(): """Yield a client connected to a ThingServer""" server = lt.ThingServer.from_things({"thing": MyThing}) - with TestClient(server.app) as client: + with server.test_client() as client: yield client diff --git a/tests/test_blob_output.py b/tests/test_blob_output.py index 4ba6fe01..830bbe84 100644 --- a/tests/test_blob_output.py +++ b/tests/test_blob_output.py @@ -86,7 +86,7 @@ def client(): "thing_two": ThingTwo, } ) - with TestClient(server.app) as client: + with server.test_client() as client: yield client diff --git a/tests/test_endpoint_decorator.py b/tests/test_endpoint_decorator.py index e25ff7ab..1b836197 100644 --- a/tests/test_endpoint_decorator.py +++ b/tests/test_endpoint_decorator.py @@ -1,4 +1,3 @@ -from fastapi.testclient import TestClient from pydantic import BaseModel import labthings_fastapi as lt @@ -26,7 +25,7 @@ def test_endpoints(): """Check endpoints may be added to the app and work as expected.""" server = lt.ThingServer.from_things({"thing": MyThing}) thing = server.things["thing"] - with TestClient(server.app) as client: + with server.test_client() as client: # Check the function works when used directly assert thing.path_from_name() == "path_from_name" # Check it works identically over HTTP. The path is diff --git a/tests/test_locking_decorator.py b/tests/test_locking_decorator.py index 6e64dd2c..391c5123 100644 --- a/tests/test_locking_decorator.py +++ b/tests/test_locking_decorator.py @@ -3,7 +3,6 @@ import functools from threading import RLock, Event, Thread -from fastapi.testclient import TestClient import pytest import labthings_fastapi as lt @@ -117,7 +116,7 @@ def test_locking_in_server(): """Check the lock works within LabThings.""" server = lt.ThingServer.from_things({"thing": LockedExample}) thing = server.things["thing"] - with TestClient(server.app) as client: + with server.test_client() as client: # Start a long task r1 = client.post("/thing/wait_wrapper", json={}) # Wait for it to start diff --git a/tests/test_logs.py b/tests/test_logs.py index 566f492c..d6a697a2 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -11,7 +11,6 @@ from types import EllipsisType import pytest from uuid import UUID, uuid4 -from fastapi.testclient import TestClient from labthings_fastapi import logs from labthings_fastapi.invocations import LogRecordModel from labthings_fastapi.invocation_contexts import ( @@ -267,7 +266,7 @@ def test_add_thing_log_destination(): def _call_action_can_get_logs(): """Run `log_and_capture` as an action, Return the final HTTP response.""" server = lt.ThingServer.from_things({"logging_thing": ThingThatLogs}) - with TestClient(server.app) as client: + with server.test_client() as client: response = client.post("/logging_thing/log_and_capture", json={"msg": "foobar"}) response.raise_for_status() return poll_task(client, response.json()) diff --git a/tests/test_mjpeg_stream.py b/tests/test_mjpeg_stream.py index ba818ec5..f1d48195 100644 --- a/tests/test_mjpeg_stream.py +++ b/tests/test_mjpeg_stream.py @@ -2,7 +2,6 @@ import threading import time from PIL import Image -from fastapi.testclient import TestClient import pytest import labthings_fastapi as lt @@ -47,7 +46,7 @@ def _make_images(self): def client(): """Yield a test client connected to a ThingServer""" server = lt.ThingServer.from_things({"telly": Telly}) - with TestClient(server.app) as client: + with server.test_client() as client: yield client diff --git a/tests/test_numpy_type.py b/tests/test_numpy_type.py index 1850ba78..0d200492 100644 --- a/tests/test_numpy_type.py +++ b/tests/test_numpy_type.py @@ -2,7 +2,6 @@ from pydantic import BaseModel, RootModel import numpy as np -from fastapi.testclient import TestClient from labthings_fastapi.testing import create_thing_without_server from labthings_fastapi.types.numpy import NDArray, DenumpifyingDict @@ -116,7 +115,7 @@ def test_rootmodel(): def test_numpy_over_http(): """Read numpy array over http.""" server = lt.ThingServer.from_things({"np_thing": MyNumpyThing}) - with TestClient(server.app) as client: + with server.test_client() as client: np_thing_client = lt.ThingClient.from_url("/np_thing/", client=client) arrayprop = np_thing_client.array_property diff --git a/tests/test_properties.py b/tests/test_properties.py index b4cb3031..65e39c7d 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -5,7 +5,6 @@ from annotated_types import Ge, Le, Gt, Lt, MultipleOf, MinLen, MaxLen from pydantic import BaseModel, RootModel, ValidationError -from fastapi.testclient import TestClient import pytest import labthings_fastapi as lt @@ -283,7 +282,7 @@ def test_property_get_and_set(server): to set a known value, and check it comes back when we read it with a GET request. """ - with TestClient(server.app) as client: + with server.test_client() as client: test_str = "A silly test string" # Write to the property: response = client.put("/thing/stringprop", json=test_str) @@ -300,7 +299,7 @@ def test_boolprop(server): PUT requests write to the property, and GET reads it. """ - with TestClient(server.app) as client: + with server.test_client() as client: r = client.get("/thing/boolprop") assert r.status_code == 200 # Successful read assert r.json() is False # Known initial value @@ -313,7 +312,7 @@ def test_boolprop(server): def test_decorator_with_no_annotation(server): """Test a property made with an un-annotated function.""" - with TestClient(server.app) as client: + with server.test_client() as client: r = client.get("/thing/undoc") assert r.status_code == 200 # Read the property OK assert r.json() is None # The return value was None @@ -323,7 +322,7 @@ def test_decorator_with_no_annotation(server): def test_readwrite_with_getter_and_setter(server): """Test floatprop can be read and written with a getter/setter.""" - with TestClient(server.app) as client: + with server.test_client() as client: r = client.get("/thing/floatprop") assert r.status_code == 200 # Read the property OK assert r.json() == 1.0 # Got the expected value @@ -342,7 +341,7 @@ def test_sync_action(server): This action doesn't start any extra threads. """ - with TestClient(server.app) as client: + with server.test_client() as client: # Write to the property so it has a known value r = client.put("/thing/boolprop", json=False) assert r.status_code == 201 # successful write @@ -370,7 +369,7 @@ def test_setting_from_thread(server): This checks there's nothing special about the action thread. """ - with TestClient(server.app) as client: + with server.test_client() as client: # Reset boolprop to a known state r = client.put("/thing/boolprop", json=False) assert r.status_code == 201 @@ -467,7 +466,7 @@ def test_constrained_properties_http(server, prop_info): It also checks that the constraints propagate to the JSONSchema. """ - with TestClient(server.app) as client: + with server.test_client() as client: r = client.get("/thing/") r.raise_for_status() thing_description = r.json() diff --git a/tests/test_server.py b/tests/test_server.py index 376b8919..44dd5e00 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -6,7 +6,6 @@ import pytest import labthings_fastapi as lt -from fastapi.testclient import TestClient from starlette.routing import Route from labthings_fastapi.example_things import MyThing @@ -52,7 +51,7 @@ def test_server_thing_descriptions(): ] server = lt.ThingServer(conf) - with TestClient(server.app) as client: + with server.test_client() as client: response = client.get("/api/thing_descriptions/") response.raise_for_status() thing_descriptions = response.json() @@ -117,7 +116,7 @@ def test_things_endpoints(): "thing_b": MyThing, } ) - with TestClient(server.app) as client: + with server.test_client() as client: # Check the thing_descriptions endpoint response = client.get("/thing_descriptions/") response.raise_for_status() diff --git a/tests/test_settings.py b/tests/test_settings.py index 1ae605e3..1911a796 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -7,7 +7,6 @@ import os from pydantic import BaseModel -from fastapi.testclient import TestClient import labthings_fastapi as lt from labthings_fastapi.testing import create_thing_without_server @@ -182,7 +181,7 @@ def test_functional_settings_save(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: + with server.test_client() as client: # We write a new value to the property with a PUT request r = client.put("/thing/floatsetting", json=2.0) # A 201 return code means the operation succeeded (i.e. @@ -211,7 +210,7 @@ def test_data_settings_save(tempdir): # 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) - with TestClient(server.app) as client: + with server.test_client() as client: # Change the value using a PUT request r = client.put("/thing/boolsetting", json=True) # Check the value was written successfully (201 response code) @@ -237,7 +236,7 @@ def test_settings_dict_save(tempdir): assert isinstance(thing, ThingWithSettings) # No setting file created when first added assert not os.path.isfile(setting_file) - with TestClient(server.app): + with server.test_client(): thing.dictsetting = {"c": 3} assert os.path.isfile(setting_file) with open(setting_file, "r", encoding="utf-8") as file_obj: @@ -259,7 +258,7 @@ def test_settings_dict_internal_update(tempdir): assert isinstance(thing, ThingWithSettings) # No setting file created when first added assert not os.path.isfile(setting_file) - with TestClient(server.app): + with server.test_client(): thing.dictsetting["a"] = 4 # As only an internal member of the dictornary was set, the saving was not # triggered. diff --git a/tests/test_thing_client.py b/tests/test_thing_client.py index d8e283a5..c170535a 100644 --- a/tests/test_thing_client.py +++ b/tests/test_thing_client.py @@ -4,7 +4,6 @@ import pytest import labthings_fastapi as lt -from fastapi.testclient import TestClient from labthings_fastapi.exceptions import ClientPropertyError, FailedToInvokeActionError @@ -66,7 +65,7 @@ def thing_client_and_thing(): server = lt.ThingServer.from_things( {"test_thing": ThingToTest}, api_prefix="/api/v1" ) - with TestClient(server.app) as client: + with server.test_client() as client: thing_client = lt.ThingClient.from_url("/api/v1/test_thing/", client=client) thing = server.things["test_thing"] yield thing_client, thing diff --git a/tests/test_thing_lifecycle.py b/tests/test_thing_lifecycle.py index 23deada2..ed5c1512 100644 --- a/tests/test_thing_lifecycle.py +++ b/tests/test_thing_lifecycle.py @@ -1,6 +1,5 @@ import pytest import labthings_fastapi as lt -from fastapi.testclient import TestClient class LifecycleThing(lt.Thing): @@ -31,7 +30,7 @@ def thing(server): def test_thing_alive(server, thing): assert thing.alive is False - with TestClient(server.app) as client: + with server.test_client() as client: assert thing.alive is True r = client.get("/thing/alive") assert r.json() is True @@ -44,10 +43,10 @@ def test_thing_alive_twice(server, thing): sure our lifecycle stuff is closing down cleanly and can restart. """ assert thing.alive is False - with TestClient(server.app) as client: + with server.test_client() as client: r = client.get("/thing/alive") assert r.json() is True assert thing.alive is False - with TestClient(server.app) as client: + with server.test_client() as client: r = client.get("/thing/alive") assert r.json() is True diff --git a/tests/test_thing_server_interface.py b/tests/test_thing_server_interface.py index f08cffb4..8bed3a6e 100644 --- a/tests/test_thing_server_interface.py +++ b/tests/test_thing_server_interface.py @@ -6,7 +6,6 @@ from typing import Mapping from unittest.mock import Mock -from fastapi.testclient import TestClient import pytest import labthings_fastapi as lt @@ -124,7 +123,7 @@ async def set_mutable(val): # error. interface.start_async_task_soon(set_mutable, True) - with TestClient(server.app) as _: + with server.test_client() as _: # TestClient starts an event loop in the background # so this should work interface.start_async_task_soon(set_mutable, True) @@ -149,7 +148,7 @@ async def async_shout(input: str): # error. interface.call_async_task(async_shout, "foobar") - with TestClient(server.app) as _: + with server.test_client() as _: # TestClient starts an event loop in the background # so this should work assert interface.call_async_task(async_shout, "foobar") == "FOOBAR" diff --git a/tests/test_thing_slots.py b/tests/test_thing_slots.py index d3bdeb0b..acf23896 100644 --- a/tests/test_thing_slots.py +++ b/tests/test_thing_slots.py @@ -4,7 +4,6 @@ import gc import pytest import labthings_fastapi as lt -from fastapi.testclient import TestClient from labthings_fastapi.exceptions import ThingSlotError @@ -356,7 +355,7 @@ def test_circular_connection(cls_1, cls_2, connections) -> None: ) things = [server.things[n] for n in ["thing_one", "thing_two"]] - with TestClient(server.app) as _: + with server.test_client() as _: # The things should be connected as the server is now running for thing, other in zip(things, reversed(things), strict=True): assert thing.other_thing is other @@ -398,7 +397,7 @@ def test_connections_none_default(connections, error): if error is None: server = lt.ThingServer.from_things(things) - with TestClient(server.app): + with server.test_client(): thing_one = server.things["thing_one"] assert isinstance(thing_one, ThingN) assert thing_one.other_thing is thing_one @@ -412,7 +411,7 @@ def test_optional_and_empty(): """Check that an optional or mapping connection can be None/empty.""" server = lt.ThingServer.from_things({"thing_one": ThingOne, "thing_two": ThingTwo}) - with TestClient(server.app): + with server.test_client(): thing_one = server.things["thing_one"] assert isinstance(thing_one, ThingOne) assert thing_one.optional_thing is None @@ -443,7 +442,7 @@ def test_mapping_and_multiple(): thing_slots={"optional_thing": "thing_3"}, ) server = lt.ThingServer.from_things(things) - with TestClient(server.app): + with server.test_client(): thing_one = server.things["thing_one"] assert isinstance(thing_one, ThingOne) assert thing_one.optional_thing is not None diff --git a/tests/test_websocket.py b/tests/test_websocket.py index d66e397c..7446a079 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -1,4 +1,3 @@ -from fastapi.testclient import TestClient import pytest import labthings_fastapi as lt from labthings_fastapi.exceptions import ( @@ -62,7 +61,7 @@ def server(): @pytest.fixture def client(server): """Yield a TestClient connected to the server.""" - with TestClient(server.app) as client: + with server.test_client() as client: yield client From 90752a93a244fb03f0049c2b228905837821ccbd Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 29 Apr 2026 16:33:06 +0100 Subject: [PATCH 7/9] Use `ThingServer.serve()` in test suite This: * replaces the call to `uvicorn.run` with a call to `ThingServer.serve` in `serve_from_cli`. * removes the `dry_run` argument in favour of using a mock for `uvicorn.run` * always either returns a server or fails to return in `serve_from_cli` * tests the server's debug flag directly in the debug-related tests. --- src/labthings_fastapi/server/cli.py | 35 +++++++++-------------------- tests/test_logs.py | 19 ++++++++++------ tests/test_server_cli.py | 9 +++++--- 3 files changed, 28 insertions(+), 35 deletions(-) diff --git a/src/labthings_fastapi/server/cli.py b/src/labthings_fastapi/server/cli.py index 245ae645..70856ba3 100644 --- a/src/labthings_fastapi/server/cli.py +++ b/src/labthings_fastapi/server/cli.py @@ -20,7 +20,7 @@ from argparse import ArgumentParser, Namespace import sys -from typing import Literal, Optional, overload +from typing import Optional from pydantic import ValidationError import uvicorn @@ -113,25 +113,13 @@ def config_from_args(args: Namespace) -> ThingServerConfig: raise RuntimeError("No configuration (or empty configuration) provided") -@overload -def serve_from_cli( - argv: Optional[list[str]], dry_run: Literal[True] -) -> ThingServer: ... - - -@overload -def serve_from_cli(argv: Optional[list[str]], dry_run: Literal[False]) -> None: ... - - -def serve_from_cli( - argv: Optional[list[str]] = None, dry_run: bool = False -) -> ThingServer | None: +def serve_from_cli(argv: Optional[list[str]] = None) -> ThingServer: r"""Start the server from the command line. This function will parse command line arguments, load configuration, set up a server, and start it. It calls `.parse_args`, - `.config_from_args` and `~lt.ThingServer.from_config` to get a server, then - starts `uvicorn` to serve on the specified host and port. + `.config_from_args` and `~lt.ThingServer` to get a server, then + serves on the specified host and port using `uvicorn`\ . If the ``fallback`` argument is specified, errors that stop the LabThings server from starting will be handled by starting a simple @@ -143,12 +131,10 @@ def serve_from_cli( :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 - has been created. This tests set-up code and verifies all of the - Things specified can be correctly loaded and instantiated, but - does not start `uvicorn`\ . - :return: the `~lt.ThingServer` instance created, if ``dry_run`` is ``True``. + :return: the `~lt.ThingServer` instance created. This is mostly useful + for test code that mocks `uvicorn.run` to allow inspection of the + server. :raise BaseException: if the server cannot start, and the ``fallback`` option is not specified. @@ -159,9 +145,8 @@ def serve_from_cli( config, server = None, None config = config_from_args(args) server = ThingServer(config, debug=True if args.debug else False) - if dry_run: - return server - uvicorn.run(server.app, host=args.host, port=args.port, ws="websockets-sansio") + server.serve(host=args.host, port=args.port) + return server except BaseException as e: if args.fallback: print(f"Error: {e}") @@ -175,10 +160,10 @@ def serve_from_cli( ) uvicorn.run(app, host=args.host, port=args.port, ws="websockets-sansio") + sys.exit(3) # If we served the fallback, be clear that it wasn't a success. else: if isinstance(e, (ValidationError, ThingImportFailure)): 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 diff --git a/tests/test_logs.py b/tests/test_logs.py index d6a697a2..2464cd39 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -176,11 +176,12 @@ def test_configure_thing_logger(): assert dest[0].msg == "Test" -def test_cli_debug_flag(): +def test_cli_debug_flag(mocker): """ Test that using the --debug flag sets the logger level to DEBUG, and that not using it leaves the logger level at INFO. """ + mocker.patch("uvicorn.run") # Reset logger level to INFO reset_thing_logger() @@ -188,26 +189,28 @@ def test_cli_debug_flag(): logs.configure_thing_logger() # Run without --debug - # We use dry_run=True to avoid starting uvicorn # We need a dummy config dummy_json = '{"things": {}}' - serve_from_cli(["--json", dummy_json], dry_run=True) + server = serve_from_cli(["--json", dummy_json]) assert logs.THING_LOGGER.level == logging.INFO + assert server.debug is False # Run with --debug - serve_from_cli(["--json", dummy_json, "--debug"], dry_run=True) + server = serve_from_cli(["--json", dummy_json, "--debug"]) assert logs.THING_LOGGER.level == logging.DEBUG + assert server.debug is True # Reset back to INFO reset_thing_logger() -def test_cli_debug_flag_with_thing(caplog): +def test_cli_debug_flag_with_thing(caplog, mocker): """ Test that using the --debug flag allows capturing debug logs during __init__. """ + mocker.patch("uvicorn.run") # Reset logger level to INFO reset_thing_logger() @@ -220,14 +223,16 @@ def test_cli_debug_flag_with_thing(caplog): # Run without --debug and capture logs with caplog.at_level(logging.DEBUG, logger="labthings_fastapi.things"): - serve_from_cli(["--json", config_json], dry_run=True) + server = serve_from_cli(["--json", config_json]) + assert server.debug is False # There are no logs assert len(caplog.messages) == 0 # Run with --debug and capture logs with caplog.at_level(logging.DEBUG, logger="labthings_fastapi.things"): - serve_from_cli(["--json", config_json, "--debug"], dry_run=True) + server = serve_from_cli(["--json", config_json, "--debug"]) + assert server.debug is True # Check that the debug message was captured assert "Debug message during __init__" in caplog.text diff --git a/tests/test_server_cli.py b/tests/test_server_cli.py index 9022339b..9d560b70 100644 --- a/tests/test_server_cli.py +++ b/tests/test_server_cli.py @@ -112,16 +112,19 @@ def test_serve_from_cli_with_config_file(): @pytest.mark.slow -def test_serve_with_no_config_without_multiprocessing(): +def test_serve_with_no_config_without_multiprocessing(mocker): + """Test that no configuration options result in an error.""" with raises(RuntimeError): - serve_from_cli([], dry_run=True) + serve_from_cli([]) @pytest.mark.slow def test_serve_with_no_config(): """Check an empty config fails, using multiprocessing. + This is important, because if it passes it means our tests above - are not actually testing anything. + are not actually testing anything - perhaps the errors are being + swallowed by `check_serve_from_cli`. """ with raises(RuntimeError): check_serve_from_cli([]) From 2e6d5ce53fe695c37c90fd1f94eeb9969a267709 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 29 Apr 2026 16:38:40 +0100 Subject: [PATCH 8/9] Test error/warning cases for server creation. --- tests/test_server.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_server.py b/tests/test_server.py index 44dd5e00..b2abd183 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -198,8 +198,11 @@ def check_server(server: lt.ThingServer, debug: bool = False): assert server._api_prefix == "/api/v3" assert server.debug == debug + # The type hint doesn't match a dict, but it works anyway. check_server(lt.ThingServer(config_dict)) + # Supplying a model is the "right" way to do it check_server(lt.ThingServer(config_model)) + # The old usage should use `from_things` check_server( lt.ThingServer.from_things(config_dict["things"], api_prefix="/api/v3") ) @@ -207,3 +210,15 @@ def check_server(server: lt.ThingServer, debug: bool = False): lt.ThingServer.from_things(config_model.thing_configs, api_prefix="/api/v3") ) check_server(lt.ThingServer(config_model, debug=True), debug=True) + # ThingServer.from_config is retired in favour of the constructor + with pytest.warns(DeprecationWarning, match="redundant"): + check_server(lt.ThingServer.from_config(config_model)) + # `things` can still be passed as kwargs, but it's deprecated + with pytest.warns(DeprecationWarning, match="keyword arguments"): + check_server(lt.ThingServer(**config_dict)) + # Supplying config and **kwargs is an error + with pytest.raises(ValueError, match="no extra keyword arguments"): + lt.ThingServer(config_model, settings_folder="./foo") + # Invalid configuration raises a TypeError, with upgrade message + with pytest.raises(TypeError, match="from_things"): + lt.ThingServer(config_dict["things"]) From 46b9ae57ae42ed982083eef9071af5255f1f7321 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 29 Apr 2026 16:57:50 +0100 Subject: [PATCH 9/9] Update public API docs --- docs/source/public_api.rst | 49 +++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/docs/source/public_api.rst b/docs/source/public_api.rst index d66c311b..c76f01cf 100644 --- a/docs/source/public_api.rst +++ b/docs/source/public_api.rst @@ -229,29 +229,44 @@ This page summarises the parts of the LabThings API that should be most frequent :return: When used as intended, the result is an `.EndpointDescriptor`. -.. py:class:: ThingServer(things: config_model.ThingsConfig, settings_folder: Optional[str] = None, application_config: Optional[collections.abc.Mapping[str, Any]] = None, debug: bool = False) +.. py:class:: ThingServer(config: ThingServerConfig, debug: bool = False) The `ThingServer` sets up a `fastapi.FastAPI` application and uses it to expose the capabilities of `Thing` instances over HTTP. - Full documentation of how the class works is available at `labthings_fastpi.server.ThingServer`\ . Most of the attributes of `ThingServer` should not be accessed directly by `Thing` subclasses - instead they should use the `ThingServerInterface` for a cleaner way to access the server. - - :param things: A mapping of Thing names to `~lt.Thing` subclasses, or - `ThingConfig` objects specifying the subclass, its initialisation - arguments, and any connections to other `~lt.Thing`\ s. - :param settings_folder: the location on disk where `~lt.Thing` - settings will be saved. - :param application_config: A mapping containing custom configuration for the - application. This is not processed by LabThings. Each `~lt.Thing` can access - application. This is not processed by LabThings. Each `~lt.Thing` can access - this via the Thing-Server interface. - :param debug: If ``True``, set the log level for `~lt.Thing` instances to - DEBUG. - + Full documentation of how the class works is available at `labthings_fastpi.server.ThingServer`\ . + Most of the attributes of `ThingServer` should not be accessed directly by `Thing` subclasses - instead they should use the `ThingServerInterface` for a cleaner way to access the server. - .. automethod:: labthings_fastapi.server.ThingServer.from_config - :no-index: + :param config: a `ThingServerConfig` instance (or compatible dictionary) setting the server's configuration. + :param debug: sets the log level for `~lt.Thing` instances to DEBUG if it is set to `True`\ . + :param \**kwargs: for backwards compatibility, keyword arguments are used to create the server configuration if `config` is missing. This raises a `DeprecationWarning`\ . + + .. py:method:: from_config(config: ThingServerConfig, debug: bool = False) -> ThingServer + + This method of creating a `ThingServer` is deprecated, as it is equivalent to the constructor. + + :return: a `ThingServer` with the supplied configuration. + + .. py:classmethod:: from_things(things: Mapping[str: ThingConfig | type[Thing] | str]) -> ThingServer + + This class method allows a server to be created by passing in a mapping of Thing names to Thing configurations. + Thing configurations may be either a `Thing` subclass, or an import string (e.g. ``my.module:MyClass``), or a `ThingConfig` instance (or compatible dictionary). + + .. code-block:: python + server = lt.ThingServer.from_things( + { + "my_thing": MyThingSubclass, + "their_thing": "their.module:TheirThing", + "configured_thing": { + "cls": MyThingSubclass, + "kwargs": {"a": 10}, + }, + } + ) + + :param things: a mapping of Thing names to Thing configurations. + :param \**kwargs: additional keyword arguments are passed to `ThingServerConfig`\ . .. py:class:: ThingServerInterface(server: ThingServer, name: str)