Skip to content
Draft
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
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 @@ -43,7 +43,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 @@ -105,9 +105,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.

Expand Down Expand Up @@ -162,6 +162,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
Expand Down Expand Up @@ -398,9 +399,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)

Expand Down Expand Up @@ -436,6 +435,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
Expand All @@ -446,8 +447,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:
Expand Down
103 changes: 99 additions & 4 deletions src/labthings_fastapi/thing_slots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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, ...]:
Expand Down Expand Up @@ -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]",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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)
31 changes: 16 additions & 15 deletions src/labthings_fastapi/utilities/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading