labthings_fastapi.thing_slots ============================= .. py:module:: labthings_fastapi.thing_slots .. autoapi-nested-parse:: 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 `~lt.Thing` to declare that it depends on another `~lt.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 `~lt.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. .. code-block:: python 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 ---------- .. autoapisummary:: labthings_fastapi.thing_slots.ThingSubclass labthings_fastapi.thing_slots.ConnectedThings Classes ------- .. autoapisummary:: labthings_fastapi.thing_slots.ThingSlot Functions --------- .. autoapisummary:: labthings_fastapi.thing_slots.thing_slot Module Contents --------------- .. py:data:: ThingSubclass .. py:data:: ConnectedThings .. py:class:: ThingSlot(*, default: str | None | collections.abc.Iterable[str] | types.EllipsisType = ...) Bases: :py:obj:`Generic`\ [\ :py:obj:`ConnectedThings`\ ], :py:obj:`labthings_fastapi.base_descriptor.FieldTypedBaseDescriptor`\ [\ :py:obj:`labthings_fastapi.thing.Thing`\ , :py:obj:`ConnectedThings`\ ] Descriptor that instructs the server to supply other Things. A `.ThingSlot` provides either one or several `~lt.Thing` instances as a property of a `~lt.Thing`\ . This allows `~lt.Thing`\ s 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 `~lt.Thing` from the `~lt.ThingServer` this is not recommended: using Thing Connections ensures all the `~lt.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 `~lt.Thing` subclass, a mapping of strings to `~lt.Thing` or subclass instances, or an optional `~lt.Thing` instance: .. code-block:: python 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. :param 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 `~lt.Thing` the default should be of type `Iterable[str]` (and could be an empty list). .. py:attribute:: _default :value: Ellipsis .. py:attribute:: _things :type: weakref.WeakKeyDictionary[labthings_fastapi.thing.Thing, weakref.ReferenceType[labthings_fastapi.thing.Thing] | weakref.WeakValueDictionary[str, labthings_fastapi.thing.Thing] | None] .. py:property:: thing_type :type: tuple[type, Ellipsis] The `~lt.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`\ . .. py:property:: is_mapping :type: bool Whether we return a mapping of strings to Things, or a single Thing. .. py:property:: is_optional :type: bool Whether ``None`` or an empty mapping is an allowed value. .. py:property:: default :type: str | collections.abc.Iterable[str] | None | types.EllipsisType The name of the Thing that will be connected by default, if any. .. py:method:: _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. :param things: the available `~lt.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). A special value is `...` which will pick the `~lt.Thing` instannce(s) matching this connection's type hint. :raises ThingSlotError: if the supplied `~lt.Thing` is of the wrong type, if a sequence is supplied when a single `~lt.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. `KeyError` will also be raised if names specified in ``target`` do not exist in ``things``\ . :return: a list of `~lt.Thing` instances to supply in response to ``__get__``\ . .. py:method:: connect(host: labthings_fastapi.thing.Thing, things: Mapping[str, Thing], target: str | collections.abc.Iterable[str] | None | types.EllipsisType = ...) -> None Find the `~lt.Thing`\ (s) we should supply when accessed. This method sets up a ThingSlot on ``host_thing`` by finding the `~lt.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 `~lt.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 `~lt.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. :param host: the `~lt.Thing` on which the connection is defined. :param things: the available `~lt.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 `.ThingSlot` was defined. :raises ThingSlotError: if the supplied `~lt.Thing` is of the wrong type, if a sequence is supplied when a single `~lt.Thing` is required, or if `None` is supplied and the connection is not optional. .. py:method:: instance_get(obj: labthings_fastapi.thing.Thing) -> ConnectedThings Supply the connected `~lt.Thing`\ (s). :param obj: The `~lt.Thing` on which the connection is defined. :return: the `~lt.Thing` instance(s) connected. :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). 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. .. py:function:: thing_slot(default: str | collections.abc.Iterable[str] | None | types.EllipsisType = ...) -> Any Declare a connection to another `~lt.Thing` in the same server. ``lt.thing_slot`` marks a class attribute as a connection to another `~lt.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 `~lt.property` and `~lt.setting`, the type of the attribute should be the type of the connected `~lt.Thing`\ . For example: .. code-block:: python 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 `~lt.Thing` subclass. An instance of this subclass will be returned when the attribute is accessed. * An optional `~lt.Thing` subclass (e.g. ``MyThing | None``). This will either return a ``MyThing`` instance or ``None``\ . * A mapping of `str` to `~lt.Thing` (e.g. ``Mapping[str, MyThing]``). This will return a mapping of `~lt.Thing` names to `~lt.Thing` instances. The mapping may be empty. Example: .. code-block:: python 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 `~lt.Thing` that matches the specified type when the server is started. If no matching `~lt.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 `~lt.Thing` name, or a sequence of strings (for connections that return mappings). In those cases, the relevant `~lt.Thing` will be returned from the server. If a name is given that either doesn't correspond to a `~lt.Thing` on the server, or is a `~lt.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 `~lt.Thing` subclass, a default value of ``None`` forces the connection to be specified in configuration. :param 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 `~lt.Thing` instance (or instances). A default value of `None` is allowed if the connection is type hinted as optional. :return: 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`\ .