Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2e13f2c
Refactor field-like typing logic into base_descriptor.
rwb27 Sep 12, 2025
78a9720
First implementation of ThingConnection.
rwb27 Sep 12, 2025
5e333f6
Completed implementation of thing_connection
rwb27 Sep 15, 2025
405ac09
Test code for thing_connection
rwb27 Sep 15, 2025
e688dd7
Improve handling of field-typed descriptors.
rwb27 Sep 15, 2025
28b2709
Docstring fixes
rwb27 Sep 15, 2025
f747fc6
Revert to using `typing.get_type_hints` but evaluate lazily.
rwb27 Sep 24, 2025
9816338
Support multiple things in a connection
rwb27 Sep 24, 2025
8cb258c
Move more error checking into `ThingConnection`
rwb27 Sep 24, 2025
24998bf
Got tests passing
rwb27 Sep 24, 2025
869bd73
Tidy up some imports
rwb27 Sep 24, 2025
31350b4
Testing and improvements
rwb27 Sep 25, 2025
739372c
Removed unnecessary override.
rwb27 Sep 25, 2025
4464624
Typing fixes.
rwb27 Sep 25, 2025
7a266e7
Update tests to take account of different type evaluation.
rwb27 Sep 25, 2025
dbcbe72
Check ReferenceError is raised if a Thing gets deleted.
rwb27 Sep 25, 2025
2260d64
Add comment to #type: ignore
rwb27 Sep 25, 2025
3451344
Explicitly don't support forward references in type subscripts.
rwb27 Oct 6, 2025
0e6908c
Add full testing for FieldTypedBaseDescriptor.
rwb27 Oct 6, 2025
79c08fb
Mark dependencies as deprecated.
rwb27 Oct 6, 2025
997f1b6
Fix broken test
rwb27 Oct 7, 2025
a5d61d2
Fix type ignore comments
rwb27 Oct 7, 2025
b0c12c6
Fix docstring
rwb27 Oct 7, 2025
e9a0e29
Pass Thing Connection config through `add_thing`.
rwb27 Oct 7, 2025
602ccf1
Allow thing connections to be specified in config.
rwb27 Oct 7, 2025
be6fc96
Spelling fix
rwb27 Oct 7, 2025
04499c1
Fix a typo in test code.
rwb27 Oct 7, 2025
cc3cb27
Add a documentation page on thing connections.
rwb27 Oct 7, 2025
369faaa
Fix tests for Python < 3.12
rwb27 Oct 7, 2025
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
8 changes: 8 additions & 0 deletions docs/source/dependencies/dependencies.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@
Dependencies
============

.. warning::

The use of dependencies is now deprecated. See :ref:`thing_connections` and `.ThingServerInterface` for a more intuitive way to access that functionality.

LabThings makes use of the powerful "dependency injection" mechanism in FastAPI. You can see the `FastAPI documentation`_ for more information. In brief, FastAPI dependencies are annotated types that instruct FastAPI to supply certain function arguments automatically. This removes the need to set up resources at the start of a function, and ensures everything the function needs is declared and typed clearly. The most common use for dependencies in LabThings is where an action needs to make use of another `.Thing` on the same `.ThingServer`.

Inter-Thing dependencies
------------------------

.. warning::

These dependencies are deprecated - see :ref:`thing_connections` instead.

Simple actions depend only on their input parameters and the `.Thing` on which they are defined. However, it's quite common to need something else, for example accessing another `.Thing` instance on the same LabThings server. There are two important principles to bear in mind here:

* Other `.Thing` instances should be accessed using a `.DirectThingClient` subclass if possible. This creates a wrapper object that should work like a `.ThingClient`, meaning your code should work either on the server or in a client script. This makes the code much easier to debug.
Expand Down
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Documentation for LabThings-FastAPI
tutorial/index.rst
examples.rst
actions.rst
thing_connections.rst
dependencies/dependencies.rst
blobs.rst
concurrency.rst
Expand Down
89 changes: 89 additions & 0 deletions docs/source/thing_connections.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
.. thing_connections:

Thing Connections
=================

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 `.thing_connection`\ . 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 available, it will raise an exception. The
advantage of making connections after initialisation is that we don't need to
worry about the order in which `.Thing`\ s are created.

The following example shows the use of a Thing Connection:

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

@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()


server = lt.ThingServer()
server.add_thing("thing_a", ThingA)
server.add_thing("thing_b", ThingB)


In this example, ``ThingB.thing_a`` is the simplest form of Thing Connection: it
is type hinted as a `.Thing` subclass, and by default the server will look for the
instance of that class and supply it when the server starts. If there is no
matching `.Thing` or if more than one instance is present, the server will fail
to start with a `.ThingConnectionError`\ .

It is also possible to use an optional type hint (``ThingA | None``), which
means there will be no error if a matching `.Thing` instance is not found, and
the connection will evaluate to `None`\ . Finally, a `.thing_connection` may be
type hinted as ``Mapping[str, ThingA]`` which permits zero or more instances to
be connected. The mapping keys are the names of the things.

Configuring Thing Connections
-----------------------------

A Thing Connection may be given a default value. If this is a string, the server
will look up the `.Thing` by name. If the default is `None` the connection will
evaluate to `None` unless explicitly configured.

Connections may also be configured when `.Thing`\ s are added to the server:
`.ThingServer.add_thing` takes an argument that allows connections to be made
by name (or set to `None`). Similarly, if you set up your server using a config
file, each entry in the ``things`` list may have a ``thing_connections`` property
that sets up the connections. To repeat the example above with a configuration
file:

.. code-block:: JSON

"things": {
"thing_a": "example:ThingA",
"thing_b": {
"class": "example:ThingB",
"thing_connections": {
"thing_a": "thing_a"
}
}
}

More detail can be found in the description of `.thing_connection` or the
:mod:`.thing_connections` module documentation.
2 changes: 2 additions & 0 deletions src/labthings_fastapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"""

from .thing import Thing
from .thing_connections import thing_connection
from .thing_server_interface import ThingServerInterface
from .properties import property, setting, DataProperty, DataSetting
from .decorators import (
Expand All @@ -46,6 +47,7 @@
"DataProperty",
"DataSetting",
"thing_action",
"thing_connection",
"fastapi_endpoint",
"deps",
"outputs",
Expand Down
176 changes: 175 additions & 1 deletion src/labthings_fastapi/base_descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@

from __future__ import annotations
import ast
import builtins
import inspect
from itertools import pairwise
import textwrap
from typing import overload, Generic, Mapping, TypeVar, TYPE_CHECKING
from types import MappingProxyType
from weakref import WeakKeyDictionary
import typing
from weakref import WeakKeyDictionary, ref, ReferenceType
from typing_extensions import Self

from .utilities.introspection import get_docstring, get_summary
from .exceptions import MissingTypeError, InconsistentTypeError

if TYPE_CHECKING:
from .thing import Thing
Expand Down Expand Up @@ -169,6 +172,7 @@ class Example:

def __init__(self) -> None:
"""Initialise a BaseDescriptor."""
super().__init__()
self._name: str | None = None
self._title: str | None = None
self._description: str | None = None
Expand Down Expand Up @@ -353,6 +357,176 @@ def instance_get(self, obj: Thing) -> Value:
)


class FieldTypedBaseDescriptor(Generic[Value], BaseDescriptor[Value]):
"""A BaseDescriptor that determines its type like a dataclass field."""

def __init__(self) -> None:
"""Initialise the FieldTypedBaseDescriptor.

Very little happens at initialisation time: most of the type determination
happens in ``__set_name__`` and ``value_type`` so that type hints can
be lazily evaluated.
"""
super().__init__()
self._type: type | None = None # the type of the descriptor's value.
# It may be set during __set_name__ if a type is available, or the
# first time `self.value_type` is accessed.
self._unevaluated_type_hint: str | None = None # Set in `__set_name__`
# Type hints are not un-stringized in `__set_name__` but we remember them
# for later evaluation in `value_type`.
self._owner: ReferenceType[type] | None = None # For forward-reference types
# When we evaluate the type hints in `value_type` we need a reference to
# the object on which they are defined, to provide the context for the
# evaluation.

def __set_name__(self, owner: type[Thing], name: str) -> None:
r"""Take note of the name and type.

This function is where we determine the type of the property. It may
be specified in two ways: either by subscripting the descriptor
or by annotating the attribute. This example is for ``DataProperty``
as this class is not intended to be used directly.

.. code-block:: python

class MyThing(Thing):
subscripted_property = DataProperty[int](0)
annotated_property: int = DataProperty(0)

The second form often works better with autocompletion, though it
is usually called via a function to avoid type checking errors.

Neither form allows us to access the type during ``__init__``, which
is why we find the type here. If there is a problem, exceptions raised
will appear to come from the class definition, so it's important to
include the name of the attribute.

See :ref:`descriptors` for links to the Python docs about when
this function is called.

For subscripted types (i.e. the first form above), we use
`typing.get_args` to retrieve the value type. This will be evaluated
immediately, resolving any forward references.

We use `typing.get_type_hints` to resolve type hints on the owning
class. This takes care of a lot of subtleties like un-stringifying
forward references. In order to support forward references, we only
check for the existence of a type hint during ``__set_name__`` and
will evaluate it fully during ``value_type``\ .

:param owner: the `.Thing` subclass to which we are being attached.
:param name: the name to which we have been assigned.

:raises InconsistentTypeError: if the type is specified twice and
the two types are not identical.
:raises MissingTypeError: if no type hints have been given.
"""
# Call BaseDescriptor so we remember the name
super().__set_name__(owner, name)

# Check for type subscripts
if hasattr(self, "__orig_class__"):
# We have been instantiated with a subscript, e.g. BaseProperty[int].
#
# __orig_class__ is set on generic classes when they are instantiated
# with a subscripted type. It is not available during __init__, which
# is why we check for it here.
self._type = typing.get_args(self.__orig_class__)[0]
if isinstance(self._type, typing.ForwardRef):
raise MissingTypeError(
f"{owner}.{name} is a subscripted descriptor, where the "
f"subscript is a forward reference ({self._type}). Forward "
"references are not supported as subscripts."
)

# Check for annotations on the parent class
field_annotation = inspect.get_annotations(owner).get(name, None)
if field_annotation is not None:
# We have been assigned to an annotated class attribute, e.g.
# myprop: int = BaseProperty(0)
if self._type is not None and self._type != field_annotation:
# As a rule, if _type is already set, we don't expect any
# annotation on the attribute, so this error should not
# be a frequent occurrence.
raise InconsistentTypeError(
f"Property {name} on {owner} has conflicting types.\n\n"
f"The field annotation of {field_annotation} conflicts "
f"with the inferred type of {self._type}."
)
self._unevaluated_type_hint = field_annotation
self._owner = ref(owner)

# Ensure a type is specified.
# If we've not set _type by now, we are not going to set it, and the
# descriptor will not work properly. It's beest to raise an error now.
# Note that we need to specify the attribute name, as the exception
# will appear to come from the end of the class definition, and not
# from the descriptor definition.
if self._type is None and self._unevaluated_type_hint is None:
raise MissingTypeError(
f"No type hint was found for attribute {name} on {owner}."
)

@builtins.property
def value_type(self) -> type[Value]:
"""The type of this descriptor's value.

This is only available after ``__set_name__`` has been called, which happens
at the end of the class definition. If it is called too early, a
`.DescriptorNotAddedToClassError` will be raised.

Accessing this property will attempt to resolve forward references,
i.e. type annotations that are strings. If there is an error resolving
the forward reference, a `.MissingTypeError` will be raised.

:return: the type of the descriptor's value.
:raises MissingTypeError: if the type is None, not resolvable, or not specified.
"""
self.assert_set_name_called()
if self._type is None and self._unevaluated_type_hint is not None:
# We have a forward reference, so we need to resolve it.
if self._owner is None:
raise MissingTypeError(
f"Can't resolve forward reference for type of {self.name} because "
"the class on which it was defined wasn't saved. This is a "
"LabThings bug - please report it."
)
owner = self._owner()
if owner is None:
raise MissingTypeError(
f"Can't resolve forward reference for type of {self.name} because "
"the class on which it was defined has been garbage collected."
)
try:
# Resolving a forward reference has quirks, and rather than tie us
# to undocumented implementation details of `typing` we just use
# `typing.get_type_hints`.
# This isn't efficient (it resolves everything, rather than just
# the one annotation we need, and it traverses the MRO when we know
# the class we're defined on) but it is part of the public API,
# and therefore much less likely to break.
#
# Note that we already checked there was an annotation in
# __set_name__.
hints = typing.get_type_hints(owner, include_extras=True)
self._type = hints[self.name]
except Exception as e:
raise MissingTypeError(
f"Can't resolve forward reference for type of {self.name}."
) from e
if self._type is None:
# We should never reach this line: if `__set_name__` was called, we'd
# have raised an exception there if _type was None. If `__set_name__`
# has not been called, `self.assert_set_name_called()` would have failed.
# This block is required for `mypy` to know that self._type is not None.
raise MissingTypeError(
f"No type hint was found for property {self.name}. This may indicate "
"a bug in LabThings, as the error should have been caught before now."
)

return self._type


# get_class_attribute_docstrings is a relatively expensive function that
# will be called potentially quite a few times on the same class. It will
# return the same result each time (because it depends only on the source
Expand Down
40 changes: 40 additions & 0 deletions src/labthings_fastapi/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,43 @@ class PropertyNotObservableError(RuntimeError):
observable: functional properties (using a getter/setter) may not be
observed.
"""


class InconsistentTypeError(TypeError):
"""Different type hints have been given for a descriptor.

Some descriptors in LabThings, particularly `.DataProperty` and `.ThingConnection`
may have their type specified in different ways. If multiple type hints are
provided, they must match. See `.property` for more details.
"""


class MissingTypeError(TypeError):
"""No type hints have been given for a descriptor that requires a type.

Every property and thing connection should have a type hint,
There are different ways of providing these type hints.
This error indicates that no type hint was found.

See documentation for `.property` and `.thing_connection` for more details.
"""


class ThingNotConnectedError(RuntimeError):
"""ThingConnections have not yet been set up.

This error is raised if a ThingConnection is accessed before the `.Thing` has
been supplied by the LabThings server. This usually happens because either
the `.Thing` is being used without a server (in which case the attribute
should be mocked), or because it has been accessed before ``__enter__``
has been called.
"""


class ThingConnectionError(RuntimeError):
"""A ThingConnection could not be set up.

This error is raised if the LabThings server is unable to set up a
ThingConnection, for example because the named Thing does not exist,
or is of the wrong type, or is not specified and there is no default.
"""
Loading