labthings_fastapi.thing_slots

Facilitate connections between Things.

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

Thing connections are set up after all the Thing instances are initialised. This means you should not rely on them during initialisation: if you attempt to access a connection before it is provided, it will raise an exception. The advantage of making connections after initialisation is that circular connections 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_slot rather than the descriptor directly. This allows them to be typed and documented on the class, i.e.

import labthings_fastapi as lt


class ThingA(lt.Thing):
    "A class that doesn't do much."

    @lt.action
    def say_hello(self) -> str:
        "A canonical example function."
        return "Hello world."


class ThingB(lt.Thing):
    "A class that relies on ThingA."

    thing_a: ThingA = lt.thing_slot()

    @lt.action
    def say_hello(self) -> str:
        "I'm too lazy to say hello, ThingA does it for me."
        return self.thing_a.say_hello()

Attributes

ThingSubclass

ConnectedThings

Classes

ThingSlot

Descriptor that instructs the server to supply other Things.

Functions

thing_slot(→ Any)

Declare a connection to another Thing in the same server.

Module Contents

labthings_fastapi.thing_slots.ThingSubclass
labthings_fastapi.thing_slots.ConnectedThings
class labthings_fastapi.thing_slots.ThingSlot(*, default: str | None | collections.abc.Iterable[str] | types.EllipsisType = ...)

Bases: Generic[ConnectedThings], labthings_fastapi.base_descriptor.FieldTypedBaseDescriptor[labthings_fastapi.thing.Thing, ConnectedThings]

Descriptor that instructs the server to supply other Things.

A ThingSlot provides either one or several Thing instances as a property of a Thing. This allows Things to communicate with each other within the server, including accessing attributes that are not exposed over HTTP.

While it is possible to dynamically retrieve a Thing from the ThingServer this is not recommended: using Thing Connections ensures all the Thing instances are available before the server starts, reducing the likelihood of run-time crashes.

The usual way of creating these connections is the function thing_slot. This class and its subclasses are not usually instantiated directly.

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:

class OtherExample(lt.Thing):
    pass


class Example(lt.Thing):
    # This will always evaluate to an `OtherExample`
    other_thing: OtherExample = lt.thing_slot("other_thing")

    # This may evaluate to an `OtherExample` or `None`
    optional: OtherExample | None = lt.thing_slot("other_thing")

    # This evaluates to a mapping of `str` to `~lt.Thing` instances
    things: Mapping[str, OtherExample] = lt.thing_slot(["thing_a"])

Declare a ThingSlot.

Parameters:

default

The name of the Thing(s) that will be connected by default.

If the type is optional (e.g. ThingSubclass | None) a default value of None will result in the connection evaluating to None unless it has been configured by the server.

If the type is not optional, a default value of None will result in an error, unless the server has set another value in its configuration.

If the type is a mapping of str to Thing the default should be of type Iterable[str] (and could be an empty list).

_default = Ellipsis
_things: weakref.WeakKeyDictionary[labthings_fastapi.thing.Thing, weakref.ReferenceType[labthings_fastapi.thing.Thing] | weakref.WeakValueDictionary[str, labthings_fastapi.thing.Thing] | None]
property thing_type: tuple[type, Ellipsis]

The Thing subclass(es) returned by this connection.

A tuple is returned to allow for optional thing connections that are typed as the union of two Thing types. It will work with isinstance.

property is_mapping: bool

Whether we return a mapping of strings to Things, or a single Thing.

property is_optional: bool

Whether None or an empty mapping is an allowed value.

property default: str | collections.abc.Iterable[str] | None | types.EllipsisType

The name of the Thing that will be connected by default, if any.

_pick_things(things: Mapping[str, Thing], target: str | collections.abc.Iterable[str] | None | types.EllipsisType) Sequence[Thing]

Pick the Things we should connect to from a list.

This function is used internally by ThingSlot.connect to choose the Things we return when the ThingSlot is accessed.

Parameters:
  • things – the available Thing instances on the server.

  • target – the name(s) we should connect to, or None to set the connection to None (if it is optional). A special value is which will pick the Thing instannce(s) matching this connection’s type hint.

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.

  • TypeError – if target is not one of the allowed types.

KeyError will also be raised if names specified in target do not exist in things.

Returns:

a list of Thing instances to supply in response to __get__.

connect(host: labthings_fastapi.thing.Thing, things: Mapping[str, Thing], target: str | collections.abc.Iterable[str] | None | types.EllipsisType = ...) None

Find the Thing(s) we should supply when accessed.

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:

  • If target is specified, we look for the specified Thing(s). None means we should return None - that’s only allowed if the type hint permits it.

  • If target is not specified or is ... we use the default value set when the connection was defined.

  • If the default value was ... and no target was specified, we will attempt to find the Thing by type. Most of the time, this is the desired behaviour.

If the type of this connection is a Mapping, target should be a sequence of names. This sequence may be empty.

None is treated as equivalent to the empty list, and a list with one name in it is treated as equivalent to a single name.

If the type hint of this connection does not permit None, and either None is specified, or no target is given and the default is set as None, then an error will be raised. None will only be returned at runtime if it is permitted by the type hint.

Parameters:
  • host – the Thing on which the connection is defined.

  • things – the available Thing instances on the server.

  • 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 ThingSlot was defined.

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.

instance_get(obj: labthings_fastapi.thing.Thing) ConnectedThings

Supply the connected Thing(s).

Parameters:

obj – The Thing on which the connection is defined.

Returns:

the Thing instance(s) connected.

Raises:

Typing notes:

This must be annotated as ConnectedThings which is the type variable corresponding to the type of this connection. The type determined at runtime will be within the upper bound of ConnectedThings but it would be possible for ConnectedThings to be more specific.

In general, types determined at runtime may conflict with generic types, and at least for this class the important thing is that types determined at runtime match the attribute annotations, which is tested in unit tests.

The return statements here consequently have their types ignored.

labthings_fastapi.thing_slots.thing_slot(default: str | collections.abc.Iterable[str] | None | types.EllipsisType = ...) Any

Declare a connection to another Thing in the same server.

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.

In keeping with property and setting, the type of the attribute should be the type of the connected Thing. For example:

import labthings_fastapi as lt


class ThingA(lt.Thing): ...


class ThingB(lt.Thing):
    "A class that relies on ThingA."

    thing_a: ThingA = lt.thing_slot()

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

The type hint of a Thing Connection should be one of the following:

  • A Thing subclass. An instance of this subclass will be returned when

    the attribute is accessed.

  • An optional Thing subclass (e.g. MyThing | None). This will either

    return a MyThing instance or None.

  • A mapping of str to Thing (e.g. Mapping[str, MyThing]). This will

    return a mapping of Thing names to Thing instances. The mapping may be empty.

Example:

import labthings_fastapi as lt


class ThingA(lt.Thing):
    "An example Thing."


class ThingB(lt.Thing):
    "An example Thing with connections."

    thing_a: ThingA = lt.thing_slot()
    maybe_thing_a: ThingA | None = lt.thing_slot()
    all_things_a: Mapping[str, ThingA] = lt.thing_slot()

    @lt.action
    def show_connections(self) -> str:
        "Tell someone about our connections."
        self.thing_a  # should always evaluate to a ThingA instance
        self.maybe_thing_a  # will be a ThingA instance or None
        self.all_things_a  # will a mapping of names to ThingA instances
        return f"{self.thing_a=}, {self.maybe_thing_a=}, {self.all_things_a=}"

The example above is very contrived, but shows how to apply the different types.

If no default value is supplied, and no value is configured for the connection, the server will attempt to find a Thing that matches the specified type when the server is started. If no matching Thing instances are found, the descriptor will return None or an empty mapping. If that is not allowed by the type hint, the server will fail to start with an error.

The default value may be a string specifying a Thing name, or a sequence of strings (for connections that return mappings). In those cases, the relevant Thing will be returned from the server. If a name is given that either doesn’t correspond to a Thing on the server, or is a Thing that doesn’t match the type of this connection, the server will fail to start with an error.

The default may also be None which is appropriate when the type is optional or a mapping. If the type is a Thing subclass, a default value of None forces the connection to be specified in configuration.

Parameters:

default – The name(s) of the Thing(s) that will be connected by default. 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.

Returns:

A ThingSlot descriptor.

Typing notes:

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