From 6fed61c5930c64a28b266e61b44d409f33f6fbde Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 5 Aug 2026 20:07:47 +0100 Subject: [PATCH 1/8] Add a filter by type option to `class_attributes` Also, `attributes` is never used. I've merged it with `class_attributes` to reduce pointless duplication. --- src/labthings_fastapi/utilities/__init__.py | 31 +++++++++++---------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/labthings_fastapi/utilities/__init__.py b/src/labthings_fastapi/utilities/__init__.py index cc68c197..a37ec2da 100644 --- a/src/labthings_fastapi/utilities/__init__.py +++ b/src/labthings_fastapi/utilities/__init__.py @@ -26,40 +26,41 @@ __all__ = [ "RootModelWrapper", - "attributes", "class_attributes", "model_to_dict", ] -def class_attributes(obj: Any) -> Iterable[tuple[str, Any]]: +AttrT = TypeVar("AttrT") + + +def class_attributes( + obj: Any, filter_type: type[AttrT] = object +) -> Iterable[tuple[str, AttrT]]: """List all the attributes of an object's class. This function gets all class attributes, including inherited ones. It is used to obtain the various descriptors used to represent properties and actions. It calls `.attributes` on ``obj.__class__``. + If a ``filter_type`` argument is supplied, only attributes that match the + supplied type will be returned. The default (`object`) matches all + attributes. + + Attributes starting with a double underscore will be ignored. + :param obj: The instance, usually a `~lt.Thing` instance. + :param filter_type: if specified, only return attributes of this type. :yield: tuples of ``(name, value)`` giving each attribute of the class. """ cls = obj.__class__ - yield from attributes(cls) - - -def attributes(cls: Any) -> Iterable[tuple[str, Any]]: - """List all the attributes of an object not starting with `__`. - - :param cls: The object whose attributes we are listing. This may be - a class, because classes are objects too. - - :yield: tuples of ``(name, value)`` giving each attribute and its - value. - """ for name in dir(cls): if name.startswith("__"): continue - yield name, getattr(cls, name) + value = getattr(cls, name) + if isinstance(value, filter_type): + yield name, value WrappedT = TypeVar("WrappedT") From 6febd7c282ca474bd9f1307d163cbf2177cc4a49 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 5 Aug 2026 21:04:20 +0100 Subject: [PATCH 2/8] Allow thing slots to determine start-up order This commit adds a `start_first` attribute to `lt.thing_slot` and a simple algorithm that determines the order in which things must be started in order to fulfil those constraints. It's not guaranteed to work, and will give a helpful error if that happens. --- src/labthings_fastapi/exceptions.py | 11 +++ src/labthings_fastapi/server/__init__.py | 20 +++-- src/labthings_fastapi/thing_slots.py | 103 ++++++++++++++++++++++- tests/test_thing_slots.py | 78 ++++++++++++++++- 4 files changed, 198 insertions(+), 14 deletions(-) diff --git a/src/labthings_fastapi/exceptions.py b/src/labthings_fastapi/exceptions.py index 4482a597..d87381a3 100644 --- a/src/labthings_fastapi/exceptions.py +++ b/src/labthings_fastapi/exceptions.py @@ -214,6 +214,17 @@ class ThingSlotError(RuntimeError): """ +class ThingSlotCircularDependencyError(RuntimeError): + """There was no order in which the Things could be correctly started. + + This error is raised when Things have incompatible requirements about + start-up order. `~lt.thing_slot` allows Things to specify that they + should be started up only once the connected Things have been started. + If there's a cycle (e.g. A must start after B, but B must start after A), + then LabThings will fail to start with this error. + """ + + class InvocationCancelledError(BaseException): """An invocation was cancelled by the user. diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index 208a4765..c0929855 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -42,7 +42,7 @@ from labthings_fastapi.thing import Thing from labthings_fastapi.thing_description._model import ThingDescription from labthings_fastapi.thing_server_interface import ThingServerInterface -from labthings_fastapi.thing_slots import ThingSlot +from labthings_fastapi.thing_slots import ThingSlot, _determine_startup_order from labthings_fastapi.utilities import class_attributes __all__ = ["ThingServer"] @@ -104,9 +104,9 @@ def __init__( :param config: a `~lt.ThingServerConfig` object that configures the server, or something that may be converted to one. - :param debug: ff ``True``, set the log level for `~lt.Thing` instances to + :param debug: if ``True``, set the log level for `~lt.Thing` instances to DEBUG. - :param \**kwargs: ff keyword arguments are supplied, they will be passed + :param \**kwargs: if keyword arguments are supplied, they will be passed to the constructor of `~lt.ThingServerConfig`\ . This is not allowed if `config` is a `~lt.ThingServerConfig` object. @@ -161,6 +161,7 @@ def __init__( # The function calls below create and set up the Things. self._things = self._create_things() self._connect_things() + self._startup_order = _determine_startup_order(self.things) self._attach_things_to_server() @classmethod @@ -405,9 +406,7 @@ def _connect_things(self) -> None: """ for thing_name, thing in self.things.items(): config = self._config.thing_configs[thing_name].thing_slots - for attr_name, attr in class_attributes(thing): - if not isinstance(attr, ThingSlot): - continue + for attr_name, attr in class_attributes(thing, ThingSlot): target = config.get(attr_name, ...) attr.connect(thing, self.things, target) @@ -443,6 +442,8 @@ async def lifespan(self, app: FastAPI) -> AsyncGenerator[None, None]: ``__enter__`` on each Thing. The error is also saved to ``self.startup_failure`` for post mortem, as otherwise uvicorn will swallow it and replace it with SystemExit(3) and no traceback. + :raises RuntimeError: if the startup order doesn't match the Things that are + attached to the server. This should never happen. """ async with BlockingPortal() as portal: # We create a blocking portal to allow threaded code to call async code @@ -453,8 +454,13 @@ async def lifespan(self, app: FastAPI) -> AsyncGenerator[None, None]: # synchronous __enter__ and __exit__ methods if they exist, to initialise # and shut down the hardware. NB we must make sure the blocking portal # is present when this happens, in case we are dealing with threads. + if set(self.things.keys()) != set(self._startup_order): + raise RuntimeError( + "`self._startup_order` does not match `self.things`." + ) async with AsyncExitStack() as stack: - for thing in self.things.values(): + for name in self._startup_order: + thing = self.things[name] try: await stack.enter_async_context(thing) except BaseException as e: diff --git a/src/labthings_fastapi/thing_slots.py b/src/labthings_fastapi/thing_slots.py index 718ac467..3d160458 100644 --- a/src/labthings_fastapi/thing_slots.py +++ b/src/labthings_fastapi/thing_slots.py @@ -47,7 +47,12 @@ def say_hello(self) -> str: from weakref import ReferenceType, WeakKeyDictionary, WeakValueDictionary, ref from labthings_fastapi.base_descriptor import FieldTypedBaseDescriptor -from labthings_fastapi.exceptions import ThingNotConnectedError, ThingSlotError +from labthings_fastapi.exceptions import ( + ThingNotConnectedError, + ThingSlotCircularDependencyError, + ThingSlotError, +) +from labthings_fastapi.utilities import class_attributes if TYPE_CHECKING: from labthings_fastapi.thing import Thing @@ -102,7 +107,10 @@ class Example(lt.Thing): """ def __init__( - self, *, default: str | None | Iterable[str] | EllipsisType = ... + self, + *, + default: str | None | Iterable[str] | EllipsisType = ..., + start_first: bool = False, ) -> None: """Declare a ThingSlot. @@ -118,12 +126,21 @@ def __init__( If the type is a mapping of `str` to `~lt.Thing` the default should be of type `Iterable[str]` (and could be an empty list). + :param start_first: Whether the connected Things should be started before + the Thing on which the slot is defined. + + When this is `False` (the default), an error will be raised if the slot + is accessed during ``__enter__`` and there's no constraint on the order + in which things will be started. If it is set to `True` then LabThings + will ensure the connected Thing(s) have ``__enter__`` called before it + is called on this Thing. """ super().__init__() self._default = default self._things: WeakKeyDictionary[ "Thing", ReferenceType["Thing"] | WeakValueDictionary[str, "Thing"] | None ] = WeakKeyDictionary() + self._start_first = start_first @property def thing_type(self) -> tuple[type, ...]: @@ -165,6 +182,11 @@ def default(self) -> str | Iterable[str] | None | EllipsisType: """The name of the Thing that will be connected by default, if any.""" return self._default + @property + def start_first(self) -> bool: + """Whether the connected Things must be started before this one.""" + return self._start_first + def _pick_things( self, things: "Mapping[str, Thing]", @@ -338,8 +360,73 @@ def instance_get(self, obj: "Thing") -> ConnectedThings: return val # type: ignore[return-value] # See docstring for an explanation of the type ignore directives. + def _connected_thing_names(self, obj: "Thing") -> set[str]: + """Return the names of the Thing(s) connected to this slot. + + :param obj: the Thing instance we're considering. + :return: a set of Thing names that are connected. + """ + val = self.instance_get(obj) + if val is None: + return set() + if isinstance(val, Mapping): + return set(val.keys()) + else: + return {val.name} -def thing_slot(default: str | Iterable[str] | None | EllipsisType = ...) -> Any: + +def _determine_startup_order(things: "Mapping[str, Thing]") -> tuple[str, ...]: + r"""Determine the order in which Things should be started. + + Thing Slots may specify that connected Things must be started before the + thing on which the slot is defined. This function resolves those + dependencies and sets the order in which the Things should start. + + "start" here refers to calling ``__enter__``\ . + + :param things: a mapping of names to Things. + :return: an ordered list of Thing names. + :raises ThingSlotCircularDependencyError: if there is no order of starting + the Things that will satisfy all the constraints. + """ + dependencies: dict[str, set[str]] = {} + for name, thing in things.items(): + deps = set() + for _, slot in class_attributes(thing, ThingSlot): + if slot.start_first: + deps = deps.union(slot._connected_thing_names(thing)) + dependencies[name] = deps + + # We add Things to the list iteratively, when they are able to be added. + # Things with no dependencies will be added first, gradually working through + # until everything's done. + # If we reach an iteration where we can't add anything, we have a deadlock + # and we must raise an exception. + order: list[str] = [] + remaining = set(things.keys()) + while remaining: + # If a Thing's dependencies are a subset of the things that are already + # started, we can now add it to the order. + things_to_add = {n for n in remaining if dependencies[n].issubset(order)} + if things_to_add: + order += list(things_to_add) + remaining = remaining.difference(things_to_add) + else: + msg = ( + f"There is no order in which the Things may be started.\n" + f"We could start {order}, but the remaining Things have cyclic " + f"dependencies: {remaining}.\n\n" + ) + for name in remaining: + msg += f"'{name}' must be started after {dependencies[name]}.\n" + raise ThingSlotCircularDependencyError(msg) + return tuple(order) + + +def thing_slot( + default: str | Iterable[str] | None | EllipsisType = ..., + start_first: bool = False, +) -> Any: r"""Declare a connection to another `~lt.Thing` in the same server. ``lt.thing_slot`` marks a class attribute as a connection to another @@ -427,6 +514,14 @@ def show_connections(self) -> str: If the default is omitted or set to ``...`` the server will attempt to find a matching `~lt.Thing` instance (or instances). A default value of `None` is allowed if the connection is type hinted as optional. + :param start_first: Whether the connected Things should be started before + the Thing on which the slot is defined. + + When this is `False` (the default), an error will be raised if the slot + is accessed during ``__enter__`` and there's no constraint on the order + in which things will be started. If it is set to `True` then LabThings + will ensure the connected Thing(s) have ``__enter__`` called before it + is called on this Thing. :return: A `.ThingSlot` descriptor. Typing notes: @@ -441,4 +536,4 @@ def show_connections(self) -> str: and it is done by established libraries such as `pydantic`\ . """ - return ThingSlot(default=default) + return ThingSlot(default=default, start_first=start_first) diff --git a/tests/test_thing_slots.py b/tests/test_thing_slots.py index d386a1d3..5dcd1b77 100644 --- a/tests/test_thing_slots.py +++ b/tests/test_thing_slots.py @@ -6,7 +6,15 @@ import pytest import labthings_fastapi as lt -from labthings_fastapi.exceptions import ThingSlotError +from labthings_fastapi.exceptions import ( + ThingSlotCircularDependencyError, + ThingSlotError, +) +from labthings_fastapi.testing import ( + MockThingServerInterface, +) +from labthings_fastapi.thing_slots import ThingSlot, _determine_startup_order +from labthings_fastapi.utilities import class_attributes class ThingOne(lt.Thing): @@ -41,6 +49,26 @@ class ThingThatMustBeConfigured(lt.Thing): other_thing: lt.Thing = lt.thing_slot(None) +class ThingWithDependency(lt.Thing): + r"""A Thing that relies on a connected Thing during ``__enter__``\ .""" + + other_thing: "ThingThree | ThingWithCircularDependency" = lt.thing_slot( + start_first=True + ) + + +class ThingWithoutDependency(lt.Thing): + """A Thing with a slot that has start_first=False (default).""" + + other_thing: "ThingThree | ThingWithCircularDependency" = lt.thing_slot() + + +class ThingWithCircularDependency(lt.Thing): + """A Thing that will cause a dependency cycle.""" + + other_thing: ThingWithDependency = lt.thing_slot(start_first=True) + + class Dummy: """A dummy thing-like class.""" @@ -161,11 +189,11 @@ def picked_names(things, target): # If there are other Things, they should be filtered by type. for names1 in [[], ["thing1_a"], ["thing1_a", "thing1_b"]]: for names2 in [[], ["thing2_a"], ["thing2_a", "thing2_b"]]: - mixed_things = { + things = { **dummy_things(names1, Dummy1), **dummy_things(names2, Dummy2), } - assert picked_names(mixed_things, ...) == set(names1) + assert picked_names(things, ...) == set(names1) # If a string is specified, it works when it exists and it's the right type. for target in ["thing1_a", "thing1_b"]: @@ -449,3 +477,47 @@ def test_mapping_and_multiple(): assert thing_one.optional_thing is not None assert thing_one.optional_thing.name == "thing_3" assert set(thing_one.n_things.keys()) == {f"thing_{i + 3}" for i in range(3)} + + +def connected_things(classes: dict[str, type[lt.Thing]]) -> dict[str, lt.Thing]: + """Instantiate and connect a list of Things.""" + things = {} + for k, v in classes.items(): + tsi = MockThingServerInterface(name=k, class_name=v.__name__) + things[k] = v(thing_server_interface=tsi) + for thing in things.values(): + for _, attr in class_attributes(thing, ThingSlot): + attr.connect(thing, things, ...) + return things + + +@pytest.mark.parametrize( + ("thing_classes", "orders"), + [ + # If there are no constraints, the order doesn't matter + ({"a": ThingThree, "b": ThingThree}, {("a", "b"), ("b", "a")}), + ({"b": ThingThree, "a": ThingThree}, {("a", "b"), ("b", "a")}), + # A thing_slot that doesn't declare `start_first` can have any order + ({"a": ThingWithoutDependency, "b": ThingThree}, {("a", "b"), ("b", "a")}), + # If start_first==True, the list order can take only one value + ({"a": ThingWithDependency, "b": ThingThree}, {("b", "a")}), + # Try with swapped names, just in case something is sorting alphabetically + ({"b": ThingWithDependency, "a": ThingThree}, {("a", "b")}), + ], +) +def test_determine_startup_order(thing_classes, orders): + """Check the logic to figure out the order in which Things should be started.""" + things = connected_things(thing_classes) + assert _determine_startup_order(things) in orders + + +def test_circular_startup_dependency(): + """Check the error for a circular dependency from slots with start_first==True""" + things = connected_things( + { + "a": ThingWithDependency, + "b": ThingWithCircularDependency, + } + ) + with pytest.raises(ThingSlotCircularDependencyError): + _determine_startup_order(things) From a6307137ec636f057d4729d46810ab3b28c2668a Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 6 Aug 2026 09:48:37 +0100 Subject: [PATCH 3/8] Add a test for startup ordering. This should check that Things get ordered correctly. --- tests/test_thing_slots.py | 72 +++++++++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/tests/test_thing_slots.py b/tests/test_thing_slots.py index 5dcd1b77..6e251a7b 100644 --- a/tests/test_thing_slots.py +++ b/tests/test_thing_slots.py @@ -40,7 +40,13 @@ class ThingN(lt.Thing): class ThingThree(lt.Thing): """A Thing that has no other attributes.""" - pass + started = False + + def __enter__(self): + self.started = True + + def __exit__(self, *args, **kwargs): + pass class ThingThatMustBeConfigured(lt.Thing): @@ -49,21 +55,39 @@ class ThingThatMustBeConfigured(lt.Thing): other_thing: lt.Thing = lt.thing_slot(None) -class ThingWithDependency(lt.Thing): +class ThingThatTracksStartup(lt.Thing): + """A Thing that sets a flag when it starts.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.started = False + + def __enter__(self): + self.started = True + + def __exit__(self, *args, **kwargs): + pass + + +class ThingWithDependency(ThingThatTracksStartup): r"""A Thing that relies on a connected Thing during ``__enter__``\ .""" other_thing: "ThingThree | ThingWithCircularDependency" = lt.thing_slot( start_first=True ) + def __enter__(self): + assert self.other_thing.started + super().__enter__() -class ThingWithoutDependency(lt.Thing): + +class ThingWithoutDependency(ThingThatTracksStartup): """A Thing with a slot that has start_first=False (default).""" other_thing: "ThingThree | ThingWithCircularDependency" = lt.thing_slot() -class ThingWithCircularDependency(lt.Thing): +class ThingWithCircularDependency(ThingThatTracksStartup): """A Thing that will cause a dependency cycle.""" other_thing: ThingWithDependency = lt.thing_slot(start_first=True) @@ -491,26 +515,38 @@ def connected_things(classes: dict[str, type[lt.Thing]]) -> dict[str, lt.Thing]: return things -@pytest.mark.parametrize( - ("thing_classes", "orders"), - [ - # If there are no constraints, the order doesn't matter - ({"a": ThingThree, "b": ThingThree}, {("a", "b"), ("b", "a")}), - ({"b": ThingThree, "a": ThingThree}, {("a", "b"), ("b", "a")}), - # A thing_slot that doesn't declare `start_first` can have any order - ({"a": ThingWithoutDependency, "b": ThingThree}, {("a", "b"), ("b", "a")}), - # If start_first==True, the list order can take only one value - ({"a": ThingWithDependency, "b": ThingThree}, {("b", "a")}), - # Try with swapped names, just in case something is sorting alphabetically - ({"b": ThingWithDependency, "a": ThingThree}, {("a", "b")}), - ], -) +THING_CLASSES_AND_ORDERS = [ + # If there are no constraints, the order doesn't matter + ({"a": ThingThree, "b": ThingThree}, {("a", "b"), ("b", "a")}), + ({"b": ThingThree, "a": ThingThree}, {("a", "b"), ("b", "a")}), + # A thing_slot that doesn't declare `start_first` can have any order + ({"a": ThingWithoutDependency, "b": ThingThree}, {("a", "b"), ("b", "a")}), + # If start_first==True, the list order can take only one value + ({"a": ThingWithDependency, "b": ThingThree}, {("b", "a")}), + # Try with swapped names, just in case something is sorting alphabetically + ({"b": ThingWithDependency, "a": ThingThree}, {("a", "b")}), +] + + +@pytest.mark.parametrize(("thing_classes", "orders"), THING_CLASSES_AND_ORDERS) def test_determine_startup_order(thing_classes, orders): """Check the logic to figure out the order in which Things should be started.""" things = connected_things(thing_classes) assert _determine_startup_order(things) in orders +@pytest.mark.parametrize(("thing_classes", "orders"), THING_CLASSES_AND_ORDERS) +def test_determine_startup_order_server(thing_classes, orders): + """Check the logic to figure out the order in which Things should be started.""" + server = lt.ThingServer.from_things(thing_classes) + assert server._startup_order in orders + with server.test_client(): + # There's an assertion in the relevant Thing's `__enter__` method, and + # it sets `started=True` + assert server.things["a"].started is True + assert server.things["b"].started is True + + def test_circular_startup_dependency(): """Check the error for a circular dependency from slots with start_first==True""" things = connected_things( From b88fbc582d61d02e3b7146217043d7ef6293448f Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Nov 2026 23:23:29 +0000 Subject: [PATCH 4/8] Rename start_first to deferred --- src/labthings_fastapi/thing_slots.py | 42 ++++++++++++++++------------ tests/test_thing_slots.py | 16 +++++++++-- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/src/labthings_fastapi/thing_slots.py b/src/labthings_fastapi/thing_slots.py index 3d160458..6c0168b3 100644 --- a/src/labthings_fastapi/thing_slots.py +++ b/src/labthings_fastapi/thing_slots.py @@ -110,7 +110,7 @@ def __init__( self, *, default: str | None | Iterable[str] | EllipsisType = ..., - start_first: bool = False, + deferred: bool = True, ) -> None: """Declare a ThingSlot. @@ -126,21 +126,24 @@ def __init__( If the type is a mapping of `str` to `~lt.Thing` the default should be of type `Iterable[str]` (and could be an empty list). - :param start_first: Whether the connected Things should be started before + :param deferred: Whether the connected Things should be started before the Thing on which the slot is defined. - When this is `False` (the default), an error will be raised if the slot - is accessed during ``__enter__`` and there's no constraint on the order - in which things will be started. If it is set to `True` then LabThings + When this is `True` (the default), there's no constraint on the order + in which things will be started: it's assumed the slot will not be + accessed until all Things have started. + + If `deferred` is set to `False` then LabThings will ensure the connected Thing(s) have ``__enter__`` called before it - is called on this Thing. + is called on this Thing, i.e. it's safe to use the slot during + ``__enter__`` """ super().__init__() self._default = default self._things: WeakKeyDictionary[ "Thing", ReferenceType["Thing"] | WeakValueDictionary[str, "Thing"] | None ] = WeakKeyDictionary() - self._start_first = start_first + self._deferred = deferred @property def thing_type(self) -> tuple[type, ...]: @@ -183,9 +186,9 @@ def default(self) -> str | Iterable[str] | None | EllipsisType: return self._default @property - def start_first(self) -> bool: + def deferred(self) -> bool: """Whether the connected Things must be started before this one.""" - return self._start_first + return self._deferred def _pick_things( self, @@ -393,7 +396,7 @@ def _determine_startup_order(things: "Mapping[str, Thing]") -> tuple[str, ...]: for name, thing in things.items(): deps = set() for _, slot in class_attributes(thing, ThingSlot): - if slot.start_first: + if not slot.deferred: deps = deps.union(slot._connected_thing_names(thing)) dependencies[name] = deps @@ -425,7 +428,7 @@ def _determine_startup_order(things: "Mapping[str, Thing]") -> tuple[str, ...]: def thing_slot( default: str | Iterable[str] | None | EllipsisType = ..., - start_first: bool = False, + deferred: bool = True, ) -> Any: r"""Declare a connection to another `~lt.Thing` in the same server. @@ -454,7 +457,7 @@ class ThingB(lt.Thing): 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). - The type hint of a Thing Connection should be one of the following: + The type hint of a Thing Slot should be one of the following: * A `~lt.Thing` subclass. An instance of this subclass will be returned when the attribute is accessed. @@ -514,14 +517,17 @@ def show_connections(self) -> str: If the default is omitted or set to ``...`` the server will attempt to find a matching `~lt.Thing` instance (or instances). A default value of `None` is allowed if the connection is type hinted as optional. - :param start_first: Whether the connected Things should be started before + :param deferred: Whether the connected Things should be started before the Thing on which the slot is defined. - When this is `False` (the default), an error will be raised if the slot - is accessed during ``__enter__`` and there's no constraint on the order - in which things will be started. If it is set to `True` then LabThings + When this is `True` (the default), there's no constraint on the order + in which things will be started: it's assumed the slot will not be + accessed until all Things have started. + + If `deferred` is set to `False` then LabThings will ensure the connected Thing(s) have ``__enter__`` called before it - is called on this Thing. + is called on this Thing, i.e. it's safe to use the slot during + ``__enter__`` :return: A `.ThingSlot` descriptor. Typing notes: @@ -536,4 +542,4 @@ def show_connections(self) -> str: and it is done by established libraries such as `pydantic`\ . """ - return ThingSlot(default=default, start_first=start_first) + return ThingSlot(default=default, deferred=deferred) diff --git a/tests/test_thing_slots.py b/tests/test_thing_slots.py index 6e251a7b..e48c3e88 100644 --- a/tests/test_thing_slots.py +++ b/tests/test_thing_slots.py @@ -73,7 +73,7 @@ class ThingWithDependency(ThingThatTracksStartup): r"""A Thing that relies on a connected Thing during ``__enter__``\ .""" other_thing: "ThingThree | ThingWithCircularDependency" = lt.thing_slot( - start_first=True + deferred=False ) def __enter__(self): @@ -82,7 +82,7 @@ def __enter__(self): class ThingWithoutDependency(ThingThatTracksStartup): - """A Thing with a slot that has start_first=False (default).""" + """A Thing with a slot that has deferred=True (default).""" other_thing: "ThingThree | ThingWithCircularDependency" = lt.thing_slot() @@ -90,7 +90,7 @@ class ThingWithoutDependency(ThingThatTracksStartup): class ThingWithCircularDependency(ThingThatTracksStartup): """A Thing that will cause a dependency cycle.""" - other_thing: ThingWithDependency = lt.thing_slot(start_first=True) + other_thing: ThingWithDependency = lt.thing_slot(deferred=False) class Dummy: @@ -503,6 +503,16 @@ def test_mapping_and_multiple(): assert set(thing_one.n_things.keys()) == {f"thing_{i + 3}" for i in range(3)} +def test_deferred_default(): + """Check that the default value of `deferred` is True.""" + slot = lt.thing_slot() + assert slot.deferred + slot = ThingSlot() + assert slot.deferred + # The Thing definition below doesn't specify `deferred` + assert ThingWithoutDependency.other_thing.deferred is True + + def connected_things(classes: dict[str, type[lt.Thing]]) -> dict[str, lt.Thing]: """Instantiate and connect a list of Things.""" things = {} From 89784dd217ffd53e2873e514a464e939c678a4d1 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Nov 2026 23:23:46 +0000 Subject: [PATCH 5/8] Update docs with example and new argument --- docs/source/public_api.rst | 6 +++++- docs/source/thing_slots.rst | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/source/public_api.rst b/docs/source/public_api.rst index 9f276fe0..7a25d6ac 100644 --- a/docs/source/public_api.rst +++ b/docs/source/public_api.rst @@ -164,7 +164,7 @@ This page summarises the parts of the LabThings API that should be most frequent of `.ActionDescriptor`. -.. py:function:: thing_slot(default: str | collections.abc.Iterable[str] | None | types.EllipsisType = ...) -> Any +.. py:function:: thing_slot(default: str | collections.abc.Iterable[str] | None | types.EllipsisType = ..., deferred: bool = True) -> Any Declare a connection to another `~lt.Thing` in the same server. @@ -193,6 +193,10 @@ This page summarises the parts of the LabThings API that should be most frequent If the default is omitted or set to ``...`` the server will attempt to find a matching `~lt.Thing` instance (or instances). A default value of `None` is allowed if the connection is type hinted as optional. + :param deferred: Whether the connected Thing may be started after the host Thing. By default + this is `True` so the start up order is not specified. Setting ``deferred=False`` instructs + LabThings to make sure the connected Thing is available during start-up, i.e. its + ``__enter__`` method will be called first. :return: A `.ThingSlot` descriptor. diff --git a/docs/source/thing_slots.rst b/docs/source/thing_slots.rst index 13a057be..5e92c3b3 100644 --- a/docs/source/thing_slots.rst +++ b/docs/source/thing_slots.rst @@ -84,5 +84,40 @@ file: } } + +Influencing start-up order +-------------------------- + +Sometimes it's useful to require that other Things start before this one. For example, if you share a serial port between several functions, you might want to use it via a `~lt.thing_slot` during start-up of a different Thing. This is possible if you specify `deferred=False` when creating the slot: + +.. code-block:: python + + import labthings_fastapi as lt + + + class SerialPort(lt.Thing): + """A port for communicating serially.""" + + def query(self, msg: str) -> str: + ... + + class SerialLight(lt.Thing): + """A light that communicates over serial""" + + _ser: SerialPort = lt.thing_slot(deferred=False) + + def __enter__(self): + """Start up the Thing.""" + # LabThings will ensure self._ser has already had + # __enter__ called, so it's running. + self._version = self._ser.query("version?") + + +Without specifying ``deferred=False``, there's no guarantee which Thing will start up first. If the `SerialPort` starts after the ``SerialLight``, most likely the attempt to communicate during ``SerialLight.__enter__`` would fail. Specifying `deferred=False` instructs LabThings to ensure that the Thing connected to the slot starts up *before* the Thing on which the slot is defined. + + +More detail +----------- + More detail can be found in the description of `~lt.thing_slot` or the :mod:`.thing_slots` module documentation. From 0cdfe3ad6d707667848c2938ac7eec8e8b4244f4 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Nov 2026 23:30:19 +0000 Subject: [PATCH 6/8] Ignore self-dependencies Non-deferred Thing Slots now ignore dependencies on the host Thing. This is most likely to happen with a Thing Slot typed as `lt.Thing` which matches all Things on a server. I've added a test for this. --- src/labthings_fastapi/thing_slots.py | 3 +++ tests/test_thing_slots.py | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/labthings_fastapi/thing_slots.py b/src/labthings_fastapi/thing_slots.py index 6c0168b3..20e6c7ec 100644 --- a/src/labthings_fastapi/thing_slots.py +++ b/src/labthings_fastapi/thing_slots.py @@ -398,6 +398,9 @@ def _determine_startup_order(things: "Mapping[str, Thing]") -> tuple[str, ...]: for _, slot in class_attributes(thing, ThingSlot): if not slot.deferred: deps = deps.union(slot._connected_thing_names(thing)) + # Having a Thing depend on itself makes no sense, so we ignore + # self-dependencies. + deps.discard(name) dependencies[name] = deps # We add Things to the list iteratively, when they are able to be added. diff --git a/tests/test_thing_slots.py b/tests/test_thing_slots.py index e48c3e88..2e1632d1 100644 --- a/tests/test_thing_slots.py +++ b/tests/test_thing_slots.py @@ -93,6 +93,16 @@ class ThingWithCircularDependency(ThingThatTracksStartup): other_thing: ThingWithDependency = lt.thing_slot(deferred=False) +class ThingThatDependsOnEverything(ThingThatTracksStartup): + """A Thing that depends on all other Things. + + It's not a great idea to do this in practice, because only one + Thing can demand to be started last... + """ + + all_other_things: Mapping[str, lt.Thing] = lt.thing_slot(deferred=False) + + class Dummy: """A dummy thing-like class.""" @@ -567,3 +577,18 @@ def test_circular_startup_dependency(): ) with pytest.raises(ThingSlotCircularDependencyError): _determine_startup_order(things) + + +def test_all_startup_dependency(): + """Check the error for a dependency on all Things with start_first==True""" + things = connected_things( + { + "a": ThingThatDependsOnEverything, + "b": ThingThree, + "c": ThingThree, + } + ) + # Self-dependencies are ignored, so this should work + order = _determine_startup_order(things) + # The ThingThatDependsOnEverthing should start last + assert order[-1] == "a" From 0616997278466962c57f36c0438fa0e7123e1865 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 3 Nov 2026 00:14:08 +0000 Subject: [PATCH 7/8] Typing fixes This gets mypy passing again (some failures appeared when I rebased due to a mypy upgrade). --- src/labthings_fastapi/thing_slots.py | 2 +- src/labthings_fastapi/utilities/__init__.py | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/labthings_fastapi/thing_slots.py b/src/labthings_fastapi/thing_slots.py index 20e6c7ec..b27c45c9 100644 --- a/src/labthings_fastapi/thing_slots.py +++ b/src/labthings_fastapi/thing_slots.py @@ -394,7 +394,7 @@ def _determine_startup_order(things: "Mapping[str, Thing]") -> tuple[str, ...]: """ dependencies: dict[str, set[str]] = {} for name, thing in things.items(): - deps = set() + deps: set[str] = set() for _, slot in class_attributes(thing, ThingSlot): if not slot.deferred: deps = deps.union(slot._connected_thing_names(thing)) diff --git a/src/labthings_fastapi/utilities/__init__.py b/src/labthings_fastapi/utilities/__init__.py index a37ec2da..ebe44ab4 100644 --- a/src/labthings_fastapi/utilities/__init__.py +++ b/src/labthings_fastapi/utilities/__init__.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Mapping from types import FunctionType -from typing import Any, Dict, Generic, Iterable, Optional, TypeVar +from typing import Any, Dict, Generic, Iterable, Optional, TypeVar, overload from fastapi import Response from pydantic import ( @@ -34,9 +34,19 @@ AttrT = TypeVar("AttrT") +@overload +def class_attributes(obj: Any) -> Iterable[tuple[str, Any]]: ... + + +@overload +def class_attributes( + obj: Any, filter_type: type[AttrT] +) -> Iterable[tuple[str, AttrT]]: ... + + def class_attributes( - obj: Any, filter_type: type[AttrT] = object -) -> Iterable[tuple[str, AttrT]]: + obj: Any, filter_type: type[AttrT] | None = None +) -> Iterable[tuple[str, AttrT]] | Iterable[tuple[str, Any]]: """List all the attributes of an object's class. This function gets all class attributes, including inherited ones. @@ -59,7 +69,7 @@ def class_attributes( if name.startswith("__"): continue value = getattr(cls, name) - if isinstance(value, filter_type): + if filter_type is None or isinstance(value, filter_type): yield name, value From 720654899dea591cb6f379b4a4669ca59b0afc4e Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 5 Nov 2026 23:15:30 +0000 Subject: [PATCH 8/8] Fully cover the diff This adds tests that cover: * the startup order not matching the server's `things`. * every case of `_connected_thing_names`, including no thing connected. --- tests/test_server.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_thing_slots.py | 20 ++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/tests/test_server.py b/tests/test_server.py index dc23ce39..1ced6183 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -5,6 +5,7 @@ """ import pytest +from anyio.from_thread import BlockingPortal from starlette.routing import Route import labthings_fastapi as lt @@ -232,3 +233,38 @@ def check_server(server: lt.ThingServer, debug: bool = False): # Invalid configuration raises a TypeError, with upgrade message with pytest.raises(TypeError, match="from_things"): lt.ThingServer(config_dict["things"]) + + +@pytest.fixture +def server_with_mock_thing(mocker): + """A ThingServer with a mocked Thing. + + The server is set up with no things, then we add the thing + in manually. + """ + dummy_thing = mocker.AsyncMock(spec=lt.Thing) + server = lt.ThingServer.from_things({}) + server._things = {"thing": dummy_thing} + server._startup_order = ("thing",) + print(set(server._things.keys())) + print(set(server._startup_order)) + return server + + +async def test_thingserver_lifespan(mocker, server_with_mock_thing): + """Check the lifespan function.""" + server = server_with_mock_thing + thing = server._things["thing"] + async with server.lifespan(mocker.Mock()): + assert isinstance(server.blocking_portal, BlockingPortal) + assert thing.__aenter__.call_count == 1 + assert thing.__aenter__.call_count == 1 + + +async def test_startup_order_mismatch(mocker, server_with_mock_thing): + """Check the lifespan function errors if _startup_order is wrong.""" + server = server_with_mock_thing + server._startup_order = tuple[str]() + with pytest.RaisesGroup(RuntimeError): + async with server.lifespan(mocker.Mock()): + pass diff --git a/tests/test_thing_slots.py b/tests/test_thing_slots.py index 2e1632d1..13c21746 100644 --- a/tests/test_thing_slots.py +++ b/tests/test_thing_slots.py @@ -12,6 +12,7 @@ ) from labthings_fastapi.testing import ( MockThingServerInterface, + mock_thing_instance, ) from labthings_fastapi.thing_slots import ThingSlot, _determine_startup_order from labthings_fastapi.utilities import class_attributes @@ -366,6 +367,25 @@ def test_referenceerror(): _ = obj.single_no_default +def test_connected_thing_names(mocker): + """Check `_connected_thing_names` works for all cases.""" + host = mock_thing_instance(lt.Thing) + other = mock_thing_instance(lt.Thing) + slot = mocker.Mock() + # Case 1: the slot is empty (and has been connected) + slot.instance_get.return_value = None + assert ThingSlot._connected_thing_names(slot, host) == set() + # Case 2: the slot is an empty mapping + slot.instance_get.return_value = {} + assert ThingSlot._connected_thing_names(slot, host) == set() + # Case 3: the slot is connected to a single Thing + slot.instance_get.return_value = other + assert ThingSlot._connected_thing_names(slot, host) == {other.name} + # Case 3: a mapping slot is connected to a single Thing + slot.instance_get.return_value = {other.name: other} + assert ThingSlot._connected_thing_names(slot, host) == {other.name} + + # The tests below use real Things and a real ThingServer to do more # realistic tests. These are not as exhaustive as the tests above, # but I think there's no harm in taking both approaches.