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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/source/public_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.


Expand Down
35 changes: 35 additions & 0 deletions docs/source/thing_slots.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 11 additions & 0 deletions src/labthings_fastapi/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 13 additions & 7 deletions src/labthings_fastapi/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -104,9 +104,9 @@

: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.

Expand Down Expand Up @@ -161,6 +161,7 @@
# 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
Expand Down Expand Up @@ -248,7 +249,7 @@
async def global_lock_exception_handler(
_request: Request, exc: GlobalLockBusyError
) -> JSONResponse:
return JSONResponse(

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

View workflow job for this annotation

GitHub Actions / coverage

252 line is not covered with tests
status_code=409,
content={"detail": repr(exc)},
)
Expand All @@ -257,11 +258,11 @@
async def serialisation_error_handler(
request: Request, exc: PydanticSerializationError
) -> JSONResponse:
LOGGER.error(

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

View workflow job for this annotation

GitHub Actions / coverage

261 line is not covered with tests
f"Couldn't serialise response to {request.url} because of error: \n"
f"{exc}"
)
return JSONResponse(status_code=500, content={"detail": str(exc)})

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

View workflow job for this annotation

GitHub Actions / coverage

265 line is not covered with tests

@property
def debug(self) -> bool:
Expand Down Expand Up @@ -325,7 +326,7 @@

:return: all instances of ``cls`` that have been added to this server.
"""
return [t for t in self.things.values() if isinstance(t, cls)]

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

View workflow job for this annotation

GitHub Actions / coverage

329 line is not covered with tests

def thing_by_class(self, cls: type[ThingInstance]) -> ThingInstance:
"""Return the instance of ``cls`` attached to this server.
Expand All @@ -339,10 +340,10 @@

:raise RuntimeError: if there is not exactly one matching Thing.
"""
instances = self.things_by_class(cls)
if len(instances) == 1:
return instances[0]
raise RuntimeError(

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

View workflow job for this annotation

GitHub Actions / coverage

343-346 lines are not covered with tests
f"There are {len(instances)} Things of class {cls}, expected 1."
)

Expand Down Expand Up @@ -405,9 +406,7 @@
"""
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)

Expand Down Expand Up @@ -443,6 +442,8 @@
``__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
Expand All @@ -453,8 +454,13 @@
# 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(

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

View workflow job for this annotation

GitHub Actions / coverage

458 line is not covered with tests
"`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:
Expand Down
114 changes: 109 additions & 5 deletions src/labthings_fastapi/thing_slots.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@
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
Expand Down Expand Up @@ -102,7 +107,10 @@
"""

def __init__(
self, *, default: str | None | Iterable[str] | EllipsisType = ...
self,
*,
default: str | None | Iterable[str] | EllipsisType = ...,
deferred: bool = True,
) -> None:
"""Declare a ThingSlot.

Expand All @@ -118,12 +126,24 @@

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 deferred: Whether the connected Things should be started before
the Thing on which the slot is defined.

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, 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._deferred = deferred

@property
def thing_type(self) -> tuple[type, ...]:
Expand Down Expand Up @@ -165,6 +185,11 @@
"""The name of the Thing that will be connected by default, if any."""
return self._default

@property
def deferred(self) -> bool:
"""Whether the connected Things must be started before this one."""
return self._deferred

def _pick_things(
self,
things: "Mapping[str, Thing]",
Expand Down Expand Up @@ -338,8 +363,76 @@
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()

Check warning on line 374 in src/labthings_fastapi/thing_slots.py

View workflow job for this annotation

GitHub Actions / coverage

374 line is not covered with tests
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[str] = set()
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.
# 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 = ...,
deferred: bool = True,
) -> 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
Expand Down Expand Up @@ -367,7 +460,7 @@
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.
Expand Down Expand Up @@ -427,6 +520,17 @@
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 Things should be started before
the Thing on which the slot is defined.

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, i.e. it's safe to use the slot during
``__enter__``
:return: A `.ThingSlot` descriptor.

Typing notes:
Expand All @@ -441,4 +545,4 @@
and it is done by established libraries such as `pydantic`\ .

"""
return ThingSlot(default=default)
return ThingSlot(default=default, deferred=deferred)
43 changes: 27 additions & 16 deletions src/labthings_fastapi/utilities/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -26,40 +26,51 @@

__all__ = [
"RootModelWrapper",
"attributes",
"class_attributes",
"model_to_dict",
]


def class_attributes(obj: Any) -> Iterable[tuple[str, Any]]:
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] | 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.
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 filter_type is None or isinstance(value, filter_type):
yield name, value


WrappedT = TypeVar("WrappedT")
Expand Down Expand Up @@ -109,7 +120,7 @@
try: # This needs to be a `try` as basic types are not classes
if issubclass(model, BaseModel):
if constraints:
raise UnsupportedConstraintError(

Check warning on line 123 in src/labthings_fastapi/utilities/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

123 line is not covered with tests
"Constraints may only be applied to plain types, not Models."
)
return model
Expand All @@ -130,7 +141,7 @@
except RuntimeError as e:
if "Unable to apply constraint" in str(e):
raise UnsupportedConstraintError(str(e)) from e
raise e

Check warning on line 144 in src/labthings_fastapi/utilities/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

144 line is not covered with tests

@classmethod
def unwrap(cls, value: BaseModel | None) -> Any:
Expand All @@ -144,7 +155,7 @@
return None
if isinstance(value, cls):
return value.root
return value

Check warning on line 158 in src/labthings_fastapi/utilities/__init__.py

View workflow job for this annotation

GitHub Actions / coverage

158 line is not covered with tests


def refer_to_user_code(
Expand Down
Loading
Loading