diff --git a/docs/source/actions.rst b/docs/source/actions.rst
index 1294a7c8..68e1d1b0 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
+background, with the option of cancelling them.
Raising exceptions
------------------
diff --git a/docs/source/dependencies/dependencies.rst b/docs/source/dependencies/dependencies.rst
index 8f58d4e7..4e4823b2 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/dependencies/example.py b/docs/source/dependencies/example.py
index 315611e2..e2c383a3 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/index.rst b/docs/source/index.rst
index 8d313abd..f43b163f 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -7,11 +7,11 @@ Documentation for LabThings-FastAPI
quickstart/quickstart.rst
wot_core_concepts.rst
- lt_core_concepts.rst
+ structure.rst
tutorial/index.rst
examples.rst
actions.rst
- thing_connections.rst
+ thing_slots.rst
dependencies/dependencies.rst
blobs.rst
concurrency.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 08ef280d..00000000
--- 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/quickstart/counter.py b/docs/source/quickstart/counter.py
index 8e4b566d..43fcffee 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/structure.rst b/docs/source/structure.rst
new file mode 100644
index 00000000..02514a10
--- /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 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
diff --git a/docs/source/thing_connections.rst b/docs/source/thing_slots.rst
similarity index 64%
rename from docs/source/thing_connections.rst
rename to docs/source/thing_slots.rst
index 06df1263..a31fd84b 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:
@@ -42,34 +42,37 @@ 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
+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:
-`.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
+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_slots`` property
that sets up the connections. To repeat the example above with a configuration
file:
@@ -79,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/docs/source/tutorial/writing_a_thing.rst b/docs/source/tutorial/writing_a_thing.rst
index defe6516..f6aa16bf 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/__init__.py b/src/labthings_fastapi/__init__.py
index e9892686..c84f3693 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 (
@@ -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,
@@ -52,13 +53,15 @@
"DataProperty",
"DataSetting",
"thing_action",
- "thing_connection",
+ "thing_slot",
"fastapi_endpoint",
"deps",
"outputs",
"blob",
"ThingServer",
"cli",
+ "ThingConfig",
+ "ThingServerConfig",
"ThingClient",
"cancellable_sleep",
"raise_if_cancelled",
diff --git a/src/labthings_fastapi/exceptions.py b/src/labthings_fastapi/exceptions.py
index 4895f85d..fc99dba9 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/logs.py b/src/labthings_fastapi/logs.py
index ebcfd7d8..cf27e765 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/outputs/mjpeg_stream.py b/src/labthings_fastapi/outputs/mjpeg_stream.py
index 2aaf05bd..2c142269 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.
diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py
index d91f62ae..bcf2d90c 100644
--- a/src/labthings_fastapi/server/__init__.py
+++ b/src/labthings_fastapi/server/__init__.py
@@ -7,7 +7,8 @@
"""
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
@@ -15,22 +16,23 @@
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
-from ..thing_connections import ThingConnection
+from ..thing_slots import ThingSlot
from ..utilities import class_attributes
-from ..utilities.object_reference_to_object import (
- object_reference_to_object,
-)
from ..actions import ActionManager
from ..logs import configure_thing_logger
from ..thing import Thing
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 (
+ ThingsConfig,
+ ThingServerConfig,
+ normalise_things_config as normalise_things_config,
+)
# `_thing_servers` is used as a global from `ThingServer.__init__`
from ..outputs.blob import BlobDataManager
@@ -62,8 +64,12 @@ class ThingServer:
an `anyio.from_thread.BlockingPortal`.
"""
- def __init__(self, settings_folder: Optional[str] = None) -> None:
- """Initialise a LabThings server.
+ def __init__(
+ self,
+ things: ThingsConfig,
+ settings_folder: Optional[str] = None,
+ ) -> None:
+ r"""Initialise a LabThings server.
Setting up the `.ThingServer` involves creating the underlying
`fastapi.FastAPI` app, setting its lifespan function (used to
@@ -73,30 +79,43 @@ def __init__(self, settings_folder: Optional[str] = None) -> None:
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._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.
+ # 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:
+ r"""Create a ThingServer from a configuration model.
- app: FastAPI
- action_manager: ActionManager
- blob_data_manager: BlobDataManager
+ This is equivalent to ``ThingServer(**dict(config))``\ .
+
+ :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:
+ 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,83 +173,10 @@ def thing_by_class(self, cls: type[ThingInstance]) -> ThingInstance:
f"There are {len(instances)} Things of class {cls}, expected 1."
)
- def add_thing(
- self,
- name: str,
- thing_subclass: type[ThingSubclass],
- args: Sequence[Any] | None = None,
- kwargs: Mapping[str, Any] | None = None,
- thing_connections: Mapping[str, str | Iterable[str] | None] | None = None,
- ) -> ThingSubclass:
- 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``\ .
- :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.
-
- :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 self._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]
- self._things[name] = thing
- if thing_connections is not None:
- self.thing_connections[name] = thing_connections
- thing.attach_to_server(
- server=self,
- )
- return thing
-
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.
@@ -240,28 +186,69 @@ 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 _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 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,
+ )
+ return things
+
def _connect_things(self) -> None:
- """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.thing_connections.get(thing_name, {})
+ 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)
+ 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.
@@ -287,10 +274,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 +285,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 +327,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 2e636d28..5af8c01e 100644
--- a/src/labthings_fastapi/server/cli.py
+++ b/src/labthings_fastapi/server/cli.py
@@ -19,15 +19,15 @@
"""
from argparse import ArgumentParser, Namespace
+import sys
from typing import Optional
-import json
-from ..utilities.object_reference_to_object import (
- object_reference_to_object,
-)
+from pydantic import ValidationError
import uvicorn
-from . import ThingServer, server_from_config
+from . import ThingServer
+from . import fallback
+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
@@ -127,6 +124,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
@@ -143,20 +142,23 @@ 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)
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
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
diff --git a/src/labthings_fastapi/server/config_model.py b/src/labthings_fastapi/server/config_model.py
new file mode 100644
index 00000000..38e4ca8f
--- /dev/null
+++ b/src/labthings_fastapi/server/config_model.py
@@ -0,0 +1,140 @@
+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 configuration
+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, AliasChoices, field_validator
+from typing import Any, Annotated, TypeAlias
+from collections.abc import Mapping, Sequence, Iterable
+
+
+# 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(
+ validation_alias=AliasChoices("cls", "class"),
+ description="The Thing subclass to add to the server.",
+ )
+
+ args: Sequence[Any] = Field(
+ default_factory=list,
+ description="Positional arguments to pass to the constructor of `cls`.",
+ )
+
+ kwargs: Mapping[str, Any] = Field(
+ default_factory=dict,
+ description="Keyword arguments to pass to the constructor of `cls`.",
+ )
+
+ thing_slots: 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[
+ str,
+ Field(min_length=1, pattern=r"^([a-zA-Z0-9\-_]+)$"),
+]
+
+
+ThingsConfig: TypeAlias = Mapping[ThingName, ThingConfig | ImportString]
+
+
+class ThingServerConfig(BaseModel):
+ r"""The configuration parameters for a `.ThingServer`\ ."""
+
+ 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.
+
+ :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)
+
+ @property
+ def thing_configs(self) -> Mapping[ThingName, ThingConfig]:
+ r"""A copy of the ``things`` field where every value is a ``.ThingConfig``\ .
+
+ 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
diff --git a/src/labthings_fastapi/server/fallback.py b/src/labthings_fastapi/server/fallback.py
index 388a9f96..399656ed 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
diff --git a/src/labthings_fastapi/thing_server_interface.py b/src/labthings_fastapi/thing_server_interface.py
index c11116f6..3f20eec4 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/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 94319809..79a5e9cd 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/src/labthings_fastapi/utilities/object_reference_to_object.py b/src/labthings_fastapi/utilities/object_reference_to_object.py
deleted file mode 100644
index 72ddceb3..00000000
--- 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
diff --git a/tests/old_dependency_tests/test_action_cancel.py b/tests/old_dependency_tests/test_action_cancel.py
index cf79c411..43c6af49 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 2c521d3f..3bcd158b 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 47d264ef..a0d49e51 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 381ee392..6cc2aa91 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 e66c7cba..ab1b0c8e 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 d9be8a7b..2713fd8a 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 9804ae70..39749eaa 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 5da65ba5..507806b7 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 3227087d..c2c7b909 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 32fdb94b..ae955320 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 6360704e..9348d43c 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 257ccda8..bc0619a7 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 798bfd1d..c2566774 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_logs.py b/tests/test_logs.py
index c09abd03..af24e6f3 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.
"""
diff --git a/tests/test_mjpeg_stream.py b/tests/test_mjpeg_stream.py
index a780d10c..0effde36 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 a50a8b4a..fd6da3aa 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 bf12abb3..fcf85a30 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 0ec3a84b..2a391091 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 00000000..5ce29b8e
--- /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_slots == {}
+
+ 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 b138dcd0..aea1520e 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 375c33e5..988a0648 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 05b71ea9..39a77b17 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, ThingNotConnectedError
+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)
@@ -355,19 +353,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", {})
- )
- thing_two = server.add_thing(
- "thing_two", cls_2, thing_connections=connections.get("thing_two", {})
+ server = lt.ThingServer(
+ things={
+ "thing_one": lt.ThingConfig(
+ cls=cls_1, thing_slots=connections.get("thing_one", {})
+ ),
+ "thing_two": lt.ThingConfig(
+ cls=cls_2, thing_slots=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 +371,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 +393,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_slots=connections.get("thing_one", {})
+ ),
+ "thing_two": lt.ThingConfig(
+ cls=ThingN, thing_slots=connections.get("thing_two", {})
+ ),
+ "thing_three": lt.ThingConfig(
+ cls=ThingThree, thing_slots=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(ThingSlotError, 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 +434,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(ThingSlotError, 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_slots={"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 6be75547..e737506d 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 82914d01..f8435dea 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
@@ -166,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)
diff --git a/tests/test_websocket.py b/tests/test_websocket.py
index 00d7947a..41e5ee38 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