From 7685550eb9288ad90ce1ca48581d5ec6301cd03e Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 28 Jan 2026 23:25:17 +0000 Subject: [PATCH 01/11] Implement BaseDescriptorInfo This adds a new class that provides access to the useful methods of a BaseDescriptor, without needing to retrieve the BaseDescriptor object directly. This should tidy up code in a few places, where we want to refer to the affordances directly, not just their values. --- src/labthings_fastapi/base_descriptor.py | 165 ++++++++++++++++++++++- src/labthings_fastapi/exceptions.py | 13 ++ 2 files changed, 177 insertions(+), 1 deletion(-) diff --git a/src/labthings_fastapi/base_descriptor.py b/src/labthings_fastapi/base_descriptor.py index f229c1d2..2d8d4621 100644 --- a/src/labthings_fastapi/base_descriptor.py +++ b/src/labthings_fastapi/base_descriptor.py @@ -18,7 +18,7 @@ from typing_extensions import Self from .utilities.introspection import get_docstring, get_summary -from .exceptions import MissingTypeError, InconsistentTypeError +from .exceptions import MissingTypeError, InconsistentTypeError, NotBoundToInstanceError if TYPE_CHECKING: from .thing import Thing @@ -32,6 +32,9 @@ Descriptor = TypeVar("Descriptor", bound="BaseDescriptor") """The type of a descriptor that's referred to by a `BaseDescriptorInfo` object.""" +DescriptorInfoT = TypeVar("DescriptorInfoT", bound="BaseDescriptorInfo") +"""The type of `.BaseDescriptorInfo` returned by a descriptor""" + class DescriptorNotAddedToClassError(RuntimeError): """Descriptor has not yet been added to a class. @@ -144,6 +147,122 @@ def _set_prop4(self, val): """ +class BaseDescriptorInfo(Generic[Descriptor, Owner, Value]): + r"""A class that describes a `BaseDescriptor`\ . + + This class is used internally by LabThings to describe :ref:`properties`\ , + :ref:`actions`\ , and other attributes of a `.Thing`\ . It's not usually + encountered directly by someone using LabThings, except as a base class for + `.Action`\ , `.Property` and others. + + LabThings uses descriptors to represent the :ref:`affordances` of a `.Thing`\ . + However, passing descriptors around isn't very elegant for two reasons: + + * Holding references to Descriptor objects can confuse static type checkers. + * Descriptors are attached to a *class* but do not know which *object* they + are defined on. + + This class allows the attributes of a descriptor to be accessed, and holds + a reference to the underlying descriptor and its owning class. It may + optionally hold a reference to a `.Thing` instance, in which case it is + said to be "bound". This means there's no need to separately pass the `.Thing` + along with the descriptor, which should help keep things simple in several + places in the code. + """ + + @overload + def __init__(self, descriptor: Descriptor, obj: Owner) -> None: ... + + @overload + def __init__(self, descriptor: Descriptor, obj: None, cls: type[Owner]) -> None: ... + + def __init__( + self, descriptor: Descriptor, obj: Owner | None, cls: type[Owner] | None = None + ) -> None: + r"""Initialise a BaseDescriptorInfo object. + + This sets up a BaseDescriptorInfo object, describing ``descriptor`` and + optionally bound to ``obj``\ . + + :param descriptor: The descriptor that this object will describe. + :param obj: The object to which this `.BaseDescriptorInfo` is bound. If + it is `None` (default), the object will be unbound and will refer to + the descriptor as attached to the class. This may mean that some + methods are unavailable. + """ + self._descriptor_ref = ref(descriptor) + if cls is None: + if obj is None: + raise ValueError("Either `obj` or `cls` must be supplied.") + cls = obj.__class__ + self._descriptor_cls = cls + self._bound_to_obj = obj + + @property + def owning_class(self) -> type[Owner]: + """Retrieve the class the descriptor is attached to.""" + return self._descriptor_cls + + @property + def owning_object(self) -> Owner | None: + """Retrieve the object on which the descriptor is being accessed.""" + return self._bound_to_obj + + @property + def is_bound(self) -> bool: + """Whether this `BaseDescriptorInfo` object is bound to an instance.""" + return self._bound_to_obj is not None + + def owning_object_or_error(self) -> Owner: + """Return the `.Thing` instance to which we are bound, or raise an error. + + This is mostly a convenience function that saves type-checking boilerplate. + + :raises NotBoundToInstanceError: if the descriptor is not bound. + """ + obj = self._bound_to_obj + if obj is None: + raise NotBoundToInstanceError("Can't return the object, as we are unbound.") + return obj + + def get_descriptor(self) -> Descriptor: + """Retrieve the descriptor object. + + :return: The descriptor object + """ + descriptor = self._descriptor_ref() + if descriptor is None: + msg = "A descriptor was deleted too early. This may be a LabThings Bug." + raise RuntimeError(msg) + return descriptor + + @property + def name(self) -> str: + """The name of the descriptor. + + This should be the same as the name of the attribute in Python. + """ + return self.get_descriptor().name + + @property + def title(self) -> str: + """The title of the descriptor.""" + return self.get_descriptor().title + + @property + def description(self) -> str | None: + """A description (usually the docstring) of the descriptor.""" + return self.get_descriptor().description + + def get(self) -> Value: + """Get the value of the descriptor.""" + if not self.is_bound: + msg = "We can't get the value when called on a class." + raise NotBoundToInstanceError(msg) + descriptor = self.get_descriptor() + return descriptor.__get__(self.owning_object_or_error()) + + class BaseDescriptor(Generic[Owner, Value]): r"""A base class for descriptors in LabThings-FastAPI. @@ -219,6 +338,7 @@ def __set_name__(self, owner: type[Owner], name: str) -> None: self._set_name_called = True self._name = name self._owner_name = owner.__qualname__ + self._owner_ref = ref(owner) # Check for docstrings on the owning class, and retrieve the one for # this attribute (identified by `name`). @@ -362,6 +482,49 @@ def instance_get(self, obj: Owner) -> Value: "See BaseDescriptor.__instance_get__ for details." ) + def _descriptor_info( + self, info_class: type[DescriptorInfoT], owner: Owner | None = None + ) -> DescriptorInfoT: + """Return a `BaseDescriptorInfo` object for this descriptor. + + The return value of this function is an object that may be passed around + without confusing type checkers, but still allows access to all of its + functionality. Essentially, it just misses out ``__get__`` so that it + is no longer a Descriptor. + + If ``owner`` is supplied, the returned object is bound to a particular + object, and if not it is unbound, i.e. knows only about the class. + + :param info_class: the `.BaseDescriptorInfo` subclass to return. + :param owner: The `.Thing` instance to which the return value is bound. + :return: An object that may be used to refer to this descriptor. + """ + if owner: + return info_class(self, owner) + else: + self.assert_set_name_called() + owning_class = self._owner_ref() + if owning_class is None: + raise RuntimeError("Class was unexpetedly deleted") + return info_class(self, None, owning_class) + + def descriptor_info( + self, owner: Owner | None = None + ) -> BaseDescriptorInfo[Self, Owner, Value]: + """Return a `BaseDescriptorInfo` object for this descriptor. + + This generates an object that refers to the descriptor, optionally + bound to a particular object. It's intended to make it easier to pass + around references to particular affordances, without needing to retrieve + and store Descriptor objects directly (which gets confusing). + If ``owner`` is supplied, the returned object is bound to a particular + object, and if not it is unbound, i.e. knows only about the class. + + :param owner: The `.Thing` instance to which the return value is bound. + :return: An object that may be used to refer to this descriptor. + """ + return self._descriptor_info(BaseDescriptorInfo, owner) + class FieldTypedBaseDescriptor(Generic[Owner, Value], BaseDescriptor[Owner, Value]): """A BaseDescriptor that determines its type like a dataclass field.""" diff --git a/src/labthings_fastapi/exceptions.py b/src/labthings_fastapi/exceptions.py index 0cbcf419..24957305 100644 --- a/src/labthings_fastapi/exceptions.py +++ b/src/labthings_fastapi/exceptions.py @@ -189,3 +189,16 @@ class ServerActionError(RuntimeError): class ClientPropertyError(RuntimeError): """Setting or getting a property via a ThingClient failed.""" + + +class NotBoundToInstanceError(RuntimeError): + """A `.BaseDescriptorInfo` is not bound to an object. + + Some methods and properties of `.BaseDescriptorInfo` objects require them + to be bound to a `.Thing` instance. If these methods are called on a + `.BaseDescriptorInfo` object that is unbound, this exception is raised. + + This exception should only be seen when `.BaseDescriptorInfo` objects are + generated from a `.Thing` class. Usually, they should be accessed via a + `.Thing` instance, in which case they will be bound. + """ From ac8a5f4e9d519129bfb0db333ce0609fd32e558e Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Sat, 31 Jan 2026 09:24:51 +0000 Subject: [PATCH 02/11] Add a BaseDescriptorInfo class This commit creates a new class, `BaseDescriptorInfo`. The intention is that using `BaseDescriptorInfo` will be more convenient than passing around descriptors. It may also be bound to an object, which should be significantly more convenient when both a Thing instance and a descriptor need to be referenced. An important side-effect that I'll note here is that `BaseDescriptor` is now a *Data Descriptor* as it implements a `__set__` method. This is arguably the way it should always have been, and simply means that `BaseDescriptor` instances won't get overwritten by the instance dictionary. Making `BaseDescriptor` instances read-only data descriptors by default means I can get rid of a dummy `__set__` method from `ThingSlot`. --- src/labthings_fastapi/base_descriptor.py | 55 ++++++++++++++++++++++ src/labthings_fastapi/thing_slots.py | 10 ---- tests/test_base_descriptor.py | 59 +++++++++++++++++++++++- 3 files changed, 113 insertions(+), 11 deletions(-) diff --git a/src/labthings_fastapi/base_descriptor.py b/src/labthings_fastapi/base_descriptor.py index 2d8d4621..6e58fd14 100644 --- a/src/labthings_fastapi/base_descriptor.py +++ b/src/labthings_fastapi/base_descriptor.py @@ -262,6 +262,14 @@ def get(self) -> Value: descriptor = self.get_descriptor() return descriptor.__get__(self.owning_object_or_error()) + def set(self, value: Value) -> None: + """Set the value of the descriptor.""" + if not self.is_bound: + msg = "We can't set the value when called on a class." + raise NotBoundToInstanceError(msg) + descriptor = self.get_descriptor() + descriptor.__set__(self.owning_object_or_error(), value) + class BaseDescriptor(Generic[Owner, Value]): r"""A base class for descriptors in LabThings-FastAPI. @@ -482,6 +490,17 @@ def instance_get(self, obj: Owner) -> Value: "See BaseDescriptor.__instance_get__ for details." ) + def __set__(self, obj: Owner, value: Value) -> None: + """Mark the `BaseDescriptor` as a data descriptor. + + Even for read-only descriptors, it's important to define a ``__set__`` method. + The presence of this method prevents Python overwriting the descriptor when + a value is assigned. This base implementation returns an `AttributeError` to + signal that the descriptor is read-only. Overriding it with a method that + does not raise an exception will allow the descriptor to be written to. + """ + raise AttributeError("This attribute is read-only.") + def _descriptor_info( self, info_class: type[DescriptorInfoT], owner: Owner | None = None ) -> DescriptorInfoT: @@ -526,6 +545,25 @@ def descriptor_info( return self._descriptor_info(BaseDescriptorInfo, owner) +FTDescriptorT = TypeVar("FTDescriptorT", bound="FieldTypedBaseDescriptor") + + +class FieldTypedBaseDescriptorInfo( + BaseDescriptorInfo[FTDescriptorT, Owner, Value], + Generic[FTDescriptorT, Owner, Value], +): + r"""A description of a `.FieldTypedBaseDescriptor`\ . + + This adds `value_type` to `.BaseDescriptorInfo` so we can fully describe a + `.FieldTypedBaseDescriptor`\ . + """ + + @property + def value_type(self) -> type: + """The type of the descriptor's value.""" + return self.get_descriptor().value_type + + class FieldTypedBaseDescriptor(Generic[Owner, Value], BaseDescriptor[Owner, Value]): """A BaseDescriptor that determines its type like a dataclass field.""" @@ -695,6 +733,23 @@ def value_type(self) -> type[Value]: return self._type + def descriptor_info( + self, owner: Owner | None = None + ) -> FieldTypedBaseDescriptorInfo[Self, Owner, Value]: + """Return a `BaseDescriptorInfo` object for this descriptor. + + This generates an object that refers to the descriptor, optionally + bound to a particular object. It's intended to make it easier to pass + around references to particular affordances, without needing to retrieve + and store Descriptor objects directly (which gets confusing). + If ``owner`` is supplied, the returned object is bound to a particular + object, and if not it is unbound, i.e. knows only about the class. + + :param owner: The `.Thing` instance to which the return value is bound. + :return: An object that may be used to refer to this descriptor. + """ + return self._descriptor_info(FieldTypedBaseDescriptorInfo, owner) + # get_class_attribute_docstrings is a relatively expensive function that # will be called potentially quite a few times on the same class. It will diff --git a/src/labthings_fastapi/thing_slots.py b/src/labthings_fastapi/thing_slots.py index 5c5ea745..4a11d0cb 100644 --- a/src/labthings_fastapi/thing_slots.py +++ b/src/labthings_fastapi/thing_slots.py @@ -164,16 +164,6 @@ def default(self) -> str | Iterable[str] | None | EllipsisType: """The name of the Thing that will be connected by default, if any.""" return self._default - def __set__(self, obj: "Thing", value: ThingSubclass) -> None: - """Raise an error as this is a read-only descriptor. - - :param obj: the `.Thing` on which the descriptor is defined. - :param value: the value being assigned. - - :raises AttributeError: this descriptor is not writeable. - """ - raise AttributeError("This descriptor is read-only.") - def _pick_things( self, things: "Mapping[str, Thing]", diff --git a/tests/test_base_descriptor.py b/tests/test_base_descriptor.py index ed69985e..47071bf7 100644 --- a/tests/test_base_descriptor.py +++ b/tests/test_base_descriptor.py @@ -8,7 +8,11 @@ get_class_attribute_docstrings, ) from .utilities import raises_or_is_caused_by -from labthings_fastapi.exceptions import MissingTypeError, InconsistentTypeError +from labthings_fastapi.exceptions import ( + MissingTypeError, + InconsistentTypeError, + NotBoundToInstanceError, +) import labthings_fastapi as lt @@ -450,3 +454,56 @@ def test_stringified_vs_unstringified_mismatch(): class Example5: field: "int" = FieldTypedBaseDescriptor[lt.Thing, int]() + + +def test_descriptorinfo(): + """Test that the DescriptorInfo object works as expected.""" + + class Example6: + intfield: int = FieldTypedBaseDescriptor() + """The descriptor's title. + + A description from a multiline docstring. + """ + + strprop = BaseDescriptor["Example6", str]() + + intfield_descriptor = Example6.intfield + assert isinstance(intfield_descriptor, FieldTypedBaseDescriptor) + + # First, make an unbound info object + intfield_info = intfield_descriptor.descriptor_info() + assert intfield_info.is_bound is False + assert intfield_info.name == "intfield" + assert intfield_info.title == "The descriptor's title." + assert intfield_info.description == "A description from a multiline docstring." + with pytest.raises(NotBoundToInstanceError): + intfield_info.get() + + # Next, check the bound version + example6 = Example6() + intfield_info = intfield_descriptor.descriptor_info(example6) + assert intfield_info.is_bound is True + assert intfield_info.name == "intfield" + assert intfield_info.title == "The descriptor's title." + assert intfield_info.description == "A description from a multiline docstring." + with pytest.raises(NotImplementedError): + # As we're now calling on a bound info object, we should just get the + # exception from `BaseDescriptor.instance_get()`, not the unbound error. + intfield_info.get() + with pytest.raises(AttributeError, match="read-only"): + # As we're now calling on a bound info object, we should just get the + # exception from `BaseDescriptor.__set__(value)`, not the unbound error. + intfield_info.set(10) + assert intfield_info.value_type is int + + # Check strprop, which is missing most of the documentation properties and + # should not have a value_type. + strprop_descriptor = Example6.strprop + assert isinstance(strprop_descriptor, BaseDescriptor) + strprop_info = strprop_descriptor.descriptor_info() + assert strprop_info.name == "strprop" + assert strprop_info.title.lower() == "strprop" + assert strprop_info.description is None + with pytest.raises(AttributeError): + _ = strprop_info.value_type From 74431071ab2589ee5e27e9bfa484f4af106c0c5a Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Sun, 1 Feb 2026 00:15:02 +0100 Subject: [PATCH 03/11] DescriptorInfoCollection This introduces the `DescriptorInfoCollection` class, and a descriptor to return it. The `DescriptorInfoCollection` is a mapping that returns `DescriptorInfo` objects. This makes it convenient to obtain both bound and unbound `DescriptorInfo` objects. --- src/labthings_fastapi/base_descriptor.py | 255 +++++++++++++++++++---- tests/test_base_descriptor.py | 138 +++++++++++- 2 files changed, 345 insertions(+), 48 deletions(-) diff --git a/src/labthings_fastapi/base_descriptor.py b/src/labthings_fastapi/base_descriptor.py index 6e58fd14..3f6c8a94 100644 --- a/src/labthings_fastapi/base_descriptor.py +++ b/src/labthings_fastapi/base_descriptor.py @@ -1,13 +1,32 @@ -"""A base class for descriptors in LabThings. +r"""A base class for descriptors in LabThings. :ref:`descriptors` are used to describe :ref:`wot_affordances` in LabThings-FastAPI. There is some behaviour common to most of these, and `.BaseDescriptor` centralises the code that implements it. + +`.BaseDescriptor` provides consistent handling of name, title, and description, as +well as implementing the convention that descriptors return themselves when accessed +as class attributes. It also provides `.BaseDescriptor.descriptor_info` to return +an object that may be used to refer to the descriptor (see later). + +`.FieldTypedBaseDescriptor` is a subclass of `.BaseDescriptor` that adds "field typing", +i.e. the ability to determine the type of the descriptor's value from a type annotation +on the class attribute. This is particularly important for :ref:`properties`\ . + +`.BaseDescriptorInfo` is a class that describes a descriptor, optionally bound to an +instance. This allows us to pass around references to descriptors without confusing +type checkers, and without needing to separately pass the instance along with the +descriptor. + +`.DescriptorInfoCollection` is a mapping of descriptor names to `.BaseDescriptorInfo` +objects, and may be used to retrieve all descriptors of a particular type on a +`.Thing`\ . """ from __future__ import annotations import ast import builtins +from collections.abc import Iterator import inspect from itertools import pairwise import textwrap @@ -32,9 +51,15 @@ Descriptor = TypeVar("Descriptor", bound="BaseDescriptor") """The type of a descriptor that's referred to by a `BaseDescriptorInfo` object.""" +FTDescriptorT = TypeVar("FTDescriptorT", bound="FieldTypedBaseDescriptor") +"""The type of a field typed descriptor.""" + DescriptorInfoT = TypeVar("DescriptorInfoT", bound="BaseDescriptorInfo") """The type of `.BaseDescriptorInfo` returned by a descriptor""" +OptionallyBoundInfoT = TypeVar("OptionallyBoundInfoT", bound="OptionallyBoundInfo") +"""The type of `OptionallyBoundInfo` returned by a descriptor.""" + class DescriptorNotAddedToClassError(RuntimeError): """Descriptor has not yet been added to a class. @@ -147,7 +172,73 @@ def _set_prop4(self, val): """ -class BaseDescriptorInfo(Generic[Descriptor, Owner, Value]): +class OptionallyBoundInfo(Generic[Owner]): + """A class that may be bound to an owning object or to a class.""" + + def __init__(self, obj: Owner | None, cls: type[Owner] | None = None) -> None: + r"""Initialise an `OptionallyBoundInfo` object. + + This initialises the object, optionally binding it to `obj` if it is + not `None`\ . + + :param obj: The object to which this info object is bound. If + it is `None` (default), the object will be unbound and will refer to + the descriptor as attached to the class. This may mean that some + methods are unavailable. + + :param cls: The class to which this info object refers. May be omitted + if `obj` is supplied. + + :raises ValueError: if neither `obj` nor `cls` is supplied. + :raises TypeError: if `obj` and `cls` are both supplied, but `obj` is not + an instance of `cls`. Note that `cls` does not have to be equal to + ``obj.__class__``\ , it just has to pass `isinstance`\ . + """ + if cls is None: + if obj is None: + raise ValueError("Either `obj` or `cls` must be supplied.") + cls = obj.__class__ + if obj and not isinstance(obj, cls): + raise TypeError(f"{obj} is not an instance of {cls}.") + self._descriptor_cls = cls + self._bound_to_obj = obj + + @property + def owning_class(self) -> type[Owner]: + """Retrieve the class this info object is describing.""" + return self._descriptor_cls + + @property + def owning_object(self) -> Owner | None: + """Retrieve the object to which this info object is bound, if present.""" + return self._bound_to_obj + + @property + def is_bound(self) -> bool: + """Whether this info object is bound to an instance. + + If this property is `False` then this object refers only to a class. If it + is `True` then we are describing a particular instance. + """ + return self._bound_to_obj is not None + + def owning_object_or_error(self) -> Owner: + """Return the `.Thing` instance to which we are bound, or raise an error. + + This is mostly a convenience function that saves type-checking boilerplate. + + :raises NotBoundToInstanceError: if this object is not bound. + """ + obj = self._bound_to_obj + if obj is None: + raise NotBoundToInstanceError("Can't return the object, as we are unbound.") + return obj + + +class BaseDescriptorInfo( + OptionallyBoundInfo[Owner], + Generic[Descriptor, Owner, Value], +): r"""A class that describes a `BaseDescriptor`\ . This class is used internally by LabThings to describe :ref:`properties`\ , @@ -170,16 +261,10 @@ class BaseDescriptorInfo(Generic[Descriptor, Owner, Value]): places in the code. """ - @overload - def __init__(self, descriptor: Descriptor, obj: Owner) -> None: ... - - @overload - def __init__(self, descriptor: Descriptor, obj: None, cls: type[Owner]) -> None: ... - def __init__( self, descriptor: Descriptor, obj: Owner | None, cls: type[Owner] | None = None ) -> None: - r"""Initialise a BaseDescriptorInfo object. + r"""Initialise an `OptionallyBoundInfo` object. This sets up a BaseDescriptorInfo object, describing ``descriptor`` and optionally bound to ``obj``\ . @@ -190,6 +275,7 @@ def __init__( the descriptor as attached to the class. This may mean that some methods are unavailable. """ + super().__init__(obj, cls) self._descriptor_ref = ref(descriptor) if cls is None: if obj is None: @@ -198,33 +284,6 @@ def __init__( self._descriptor_cls = cls self._bound_to_obj = obj - @property - def owning_class(self) -> type[Owner]: - """Retrieve the class the descriptor is attached to.""" - return self._descriptor_cls - - @property - def owning_object(self) -> Owner | None: - """Retrieve the object on which the descriptor is being accessed.""" - return self._bound_to_obj - - @property - def is_bound(self) -> bool: - """Whether this `BaseDescriptorInfo` object is bound to an instance.""" - return self._bound_to_obj is not None - - def owning_object_or_error(self) -> Owner: - """Return the `.Thing` instance to which we are bound, or raise an error. - - This is mostly a convenience function that saves type-checking boilerplate. - - :raises NotBoundToInstanceError: if the descriptor is not bound. - """ - obj = self._bound_to_obj - if obj is None: - raise NotBoundToInstanceError("Can't return the object, as we are unbound.") - return obj - def get_descriptor(self) -> Descriptor: """Retrieve the descriptor object. @@ -502,7 +561,7 @@ def __set__(self, obj: Owner, value: Value) -> None: raise AttributeError("This attribute is read-only.") def _descriptor_info( - self, info_class: type[DescriptorInfoT], owner: Owner | None = None + self, info_class: type[DescriptorInfoT], obj: Owner | None = None ) -> DescriptorInfoT: """Return a `BaseDescriptorInfo` object for this descriptor. @@ -515,11 +574,11 @@ def _descriptor_info( object, and if not it is unbound, i.e. knows only about the class. :param info_class: the `.BaseDescriptorInfo` subclass to return. - :param owner: The `.Thing` instance to which the return value is bound. + :param obj: The `.Thing` instance to which the return value is bound. :return: An object that may be used to refer to this descriptor. """ - if owner: - return info_class(self, owner) + if obj: + return info_class(self, obj) else: self.assert_set_name_called() owning_class = self._owner_ref() @@ -545,9 +604,6 @@ def descriptor_info( return self._descriptor_info(BaseDescriptorInfo, owner) -FTDescriptorT = TypeVar("FTDescriptorT", bound="FieldTypedBaseDescriptor") - - class FieldTypedBaseDescriptorInfo( BaseDescriptorInfo[FTDescriptorT, Owner, Value], Generic[FTDescriptorT, Owner, Value], @@ -751,6 +807,119 @@ def descriptor_info( return self._descriptor_info(FieldTypedBaseDescriptorInfo, owner) +class DescriptorInfoCollection( + Mapping[str, DescriptorInfoT], + OptionallyBoundInfo[Owner], + Generic[Owner, DescriptorInfoT], +): + """Easy access to DescriptorInfo objects of a particular type. + + This class works as a Mapping, so you can retrieve individual + `.DescriptorInfo` objects by name, or iterate over the names of + the descriptors. + + It may be initialised with an object, in which case the contained + `.DescriptorInfo` objects will be bound to that object. If initialised + without an object, the contained `.DescriptorInfo` objects will be + unbound, i.e. referring only to the class. + + This class is subclassed by each of the LabThings descriptors + (Properties, Actions, etc.) and generated by a corresponding + `.OptionallyBoundDescriptor` on `.Thing` for convenience. + """ + + def __init__( + self, + obj: Owner | None, + cls: type[Owner] | None = None, + ) -> None: + r"""Initialise the DescriptorInfoCollection. + + This initialises the object, optionally binding it to `obj` if it is + not `None`\ . + + :param obj: The object to which this info object is bound. If + it is `None` (default), the object will be unbound and will refer to + the descriptor as attached to the class. This may mean that some + methods are unavailable. + + :param cls: The class to which this info object refers. May be omitted + if `obj` is supplied. + """ + super().__init__(obj, cls) + + _descriptorinfo_class: type[DescriptorInfoT] + """The class of DescriptorInfo objects contained in this collection. + + This class attribute must be set in subclasses. + """ + + @property + def descriptorinfo_class(self) -> type[DescriptorInfoT]: + """The class of DescriptorInfo objects contained in this collection.""" + return self._descriptorinfo_class + + def __getitem__(self, key: str) -> DescriptorInfoT: + """Retrieve a DescriptorInfo object given the name of the descriptor. + + :param key: The name of the descriptor whose info object is required. + :return: The DescriptorInfo object for the named descriptor. + """ + return getattr(self.owning_class, key).descriptor_info(self.owning_object) + + def __iter__(self) -> Iterator[str]: + """Iterate over the names of the descriptors of the specified type. + + :return: An iterator over the names of the descriptors. + """ + for name, member in inspect.getmembers(self.owning_class): + if isinstance(member, BaseDescriptor): + if isinstance(member.descriptor_info(), self._descriptorinfo_class): + yield name + + def __len__(self) -> int: + """Return the number of descriptors of the specified type. + + :return: The number of descriptors of the specified type. + """ + return sum(1 for _ in self.__iter__()) + + +class OptionallyBoundDescriptor(Generic[Owner, OptionallyBoundInfoT]): + """A descriptor that will return an OptionallyBoundInfo object. + + This descriptor will return an instance of a particular class, initialised + with either the object, or its class, depending on how it is accessed. + + This is useful for returning collections of `.BaseDescriptorInfo` objects + from a `.Thing` subclass. + """ + + def __init__(self, cls: type[OptionallyBoundInfoT]) -> None: + """Initialise the descriptor. + + :param cls: The class of `.OptionallyBoundInfo` objects that this descriptor + will return. + """ + super().__init__() + self._cls = cls + + def __get__( + self, + obj: Owner | None, + cls: type[Owner] | None = None, + ) -> OptionallyBoundInfoT: + """Return an OptionallyBoundInfo object. + + :param obj: The object to which the info is bound, or `None` + if unbound. + :param type: The class on which the info is defined. + + :return: An `OptionallyBoundInfo` object. + """ + return self._cls(obj, cls) + + # 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 diff --git a/tests/test_base_descriptor.py b/tests/test_base_descriptor.py index 47071bf7..b35923a1 100644 --- a/tests/test_base_descriptor.py +++ b/tests/test_base_descriptor.py @@ -2,11 +2,17 @@ import pytest from labthings_fastapi.base_descriptor import ( BaseDescriptor, + BaseDescriptorInfo, + DescriptorInfoCollection, FieldTypedBaseDescriptor, DescriptorNotAddedToClassError, DescriptorAddedToClassTwiceError, + FieldTypedBaseDescriptorInfo, + OptionallyBoundDescriptor, + OptionallyBoundInfo, get_class_attribute_docstrings, ) +from labthings_fastapi.testing import create_thing_without_server from .utilities import raises_or_is_caused_by from labthings_fastapi.exceptions import ( MissingTypeError, @@ -456,19 +462,52 @@ class Example5: field: "int" = FieldTypedBaseDescriptor[lt.Thing, int]() +def test_optionally_bound_info(): + """Test the OptionallyBoundInfo base class.""" + + class Example6(lt.Thing): + pass + + class Example6a(lt.Thing): + pass + + example6 = create_thing_without_server(Example6) + + bound_info = OptionallyBoundInfo(example6) + assert bound_info.owning_object is example6 + assert bound_info.owning_object_or_error() is example6 + assert bound_info.owning_class is Example6 + assert bound_info.is_bound is True + + unbound_info = OptionallyBoundInfo(None, Example6) + assert unbound_info.owning_object is None + with pytest.raises(NotBoundToInstanceError): + unbound_info.owning_object_or_error() + assert unbound_info.owning_class is Example6 + assert unbound_info.is_bound is False + + # Check that we can't create it with a bad class + with pytest.raises(TypeError): + _ = OptionallyBoundInfo(example6, Example6a) + + # Check that we can't create it with no class or object + with pytest.raises(ValueError): + _ = OptionallyBoundInfo(None, None) # type: ignore + + def test_descriptorinfo(): """Test that the DescriptorInfo object works as expected.""" - class Example6: + class Example7: intfield: int = FieldTypedBaseDescriptor() """The descriptor's title. A description from a multiline docstring. """ - strprop = BaseDescriptor["Example6", str]() + strprop = BaseDescriptor["Example7", str]() - intfield_descriptor = Example6.intfield + intfield_descriptor = Example7.intfield assert isinstance(intfield_descriptor, FieldTypedBaseDescriptor) # First, make an unbound info object @@ -481,7 +520,7 @@ class Example6: intfield_info.get() # Next, check the bound version - example6 = Example6() + example6 = Example7() intfield_info = intfield_descriptor.descriptor_info(example6) assert intfield_info.is_bound is True assert intfield_info.name == "intfield" @@ -499,7 +538,7 @@ class Example6: # Check strprop, which is missing most of the documentation properties and # should not have a value_type. - strprop_descriptor = Example6.strprop + strprop_descriptor = Example7.strprop assert isinstance(strprop_descriptor, BaseDescriptor) strprop_info = strprop_descriptor.descriptor_info() assert strprop_info.name == "strprop" @@ -507,3 +546,92 @@ class Example6: assert strprop_info.description is None with pytest.raises(AttributeError): _ = strprop_info.value_type + + +def test_descriptorinfocollection(): + """Test the DescriptorInfoCollection class. + + This test checks that: + * We can get a collection of all descriptors on a Thing subclass. + * The collection contains the right names (is filtered by type). + * The individual DescriptorInfo objects in the collection have the + right properties. + * The `OptionallyBoundDescriptor` returns a collection on either the + class or the instance, bound or unbound as appropriate. + """ + + class BaseDescriptorInfoCollection( + DescriptorInfoCollection[lt.Thing, BaseDescriptorInfo] + ): + """A collection of BaseDescriptorInfo objects.""" + + _descriptorinfo_class = BaseDescriptorInfo + + class FieldTypedBaseDescriptorInfoCollection( + DescriptorInfoCollection[lt.Thing, FieldTypedBaseDescriptorInfo] + ): + """A collection of FieldTypedBaseDescriptorInfo objects.""" + + _descriptorinfo_class = FieldTypedBaseDescriptorInfo + + class Example8(lt.Thing): + intfield: int = FieldTypedBaseDescriptor() + """An integer field.""" + + strprop = BaseDescriptor["Example8", str]() + """A string property.""" + + another_intfield: int = FieldTypedBaseDescriptor() + """Another integer field.""" + + base_descriptors = OptionallyBoundDescriptor(BaseDescriptorInfoCollection) + """A mapping of all base descriptors.""" + + field_typed_descriptors = OptionallyBoundDescriptor( + FieldTypedBaseDescriptorInfoCollection + ) + """A mapping of all field-typed descriptors.""" + + # The property should return a mapping of names to descriptor info objects + collection = Example8.base_descriptors + assert isinstance(collection, DescriptorInfoCollection) + + names = list(collection) + assert set(names) == {"intfield", "strprop", "another_intfield"} + assert len(collection) == 3 + assert collection.is_bound is False + + intfield_info = collection["intfield"] + assert intfield_info.name == "intfield" + assert intfield_info.title == "An integer field." + assert intfield_info.value_type is int + assert intfield_info.is_bound is False + + strprop_info = collection["strprop"] + assert strprop_info.name == "strprop" + assert strprop_info.title == "A string property." + with pytest.raises(AttributeError): + _ = strprop_info.value_type + assert strprop_info.is_bound is False + + # A more specific descriptor info type should narrow the collection + field_typed_collection = Example8.field_typed_descriptors + assert isinstance(field_typed_collection, DescriptorInfoCollection) + names = list(field_typed_collection) + assert set(names) == {"intfield", "another_intfield"} + assert len(field_typed_collection) == 2 + + intfield_info = field_typed_collection["intfield"] + assert intfield_info.name == "intfield" + assert intfield_info.title == "An integer field." + assert intfield_info.value_type is int + + example8 = create_thing_without_server(Example8) + bound_collection = example8.base_descriptors + assert bound_collection.is_bound is True + bound_names = list(bound_collection) + assert set(bound_names) == {"intfield", "strprop", "another_intfield"} + assert len(bound_collection) == 3 + + bound_intfield_info = bound_collection["intfield"] + assert bound_intfield_info.is_bound is True From 1839eccd49fa19ccbdda3055109017bbdb38fd60 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Sun, 1 Feb 2026 22:30:24 +0100 Subject: [PATCH 04/11] Add `PropertyInfo` and `SettingInfo` This includes tests for `PropertyInfo` and a fix to handle access to missing fields correctly. `.Thing.properties[]` now returns a `PropertyInfo` object allowing access to the property's metadata. `.Thing.settings` does the same for settings, and also builds a model for loading/saving the settings. It does not, yet, load/save them, and needs test code. --- src/labthings_fastapi/base_descriptor.py | 21 ++++--- src/labthings_fastapi/properties.py | 79 +++++++++++++++++++++++- src/labthings_fastapi/thing.py | 29 ++++++++- tests/test_base_descriptor.py | 13 ++-- tests/test_properties.py | 47 +++++++++++--- 5 files changed, 165 insertions(+), 24 deletions(-) diff --git a/src/labthings_fastapi/base_descriptor.py b/src/labthings_fastapi/base_descriptor.py index 3f6c8a94..08797296 100644 --- a/src/labthings_fastapi/base_descriptor.py +++ b/src/labthings_fastapi/base_descriptor.py @@ -33,7 +33,7 @@ from typing import overload, Generic, Mapping, TypeVar, TYPE_CHECKING from types import MappingProxyType import typing -from weakref import WeakKeyDictionary, ref, ReferenceType +from weakref import WeakKeyDictionary, ref from typing_extensions import Self from .utilities.introspection import get_docstring, get_summary @@ -637,10 +637,6 @@ def __init__(self) -> None: 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[Owner], name: str) -> None: r"""Take note of the name and type. @@ -717,7 +713,6 @@ class MyThing(Thing): 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 @@ -748,13 +743,14 @@ def value_type(self) -> type[Value]: 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: + if self._owner_ref 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() + # `self._owner_ref` is set in `BaseDescriptor.__set_name__`. + owner = self._owner_ref() if owner is None: raise MissingTypeError( f"Can't resolve forward reference for type of {self.name} because " @@ -865,7 +861,14 @@ def __getitem__(self, key: str) -> DescriptorInfoT: :param key: The name of the descriptor whose info object is required. :return: The DescriptorInfo object for the named descriptor. """ - return getattr(self.owning_class, key).descriptor_info(self.owning_object) + attr = getattr(self.owning_class, key, None) + if isinstance(attr, BaseDescriptor): + info = attr.descriptor_info(self.owning_object) + if isinstance(info, self.descriptorinfo_class): + return info + # Attributes that are missing or of the wrong type are not present in + # the mapping, so they raise KeyError. + raise KeyError(key) def __iter__(self) -> Iterator[str]: """Iterate over the names of the descriptors of the specified type. diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index dd4f64dc..da8c64e1 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -62,7 +62,7 @@ class attribute. Documentation is in strings immediately following the from weakref import WeakSet from fastapi import Body, FastAPI -from pydantic import BaseModel, RootModel +from pydantic import BaseModel, RootModel, create_model from .thing_description import type_to_dataschema from .thing_description._model import ( @@ -73,7 +73,11 @@ class attribute. Documentation is in strings immediately following the ) from .utilities import labthings_data, wrap_plain_types_in_rootmodel from .utilities.introspection import return_type -from .base_descriptor import FieldTypedBaseDescriptor +from .base_descriptor import ( + DescriptorInfoCollection, + FieldTypedBaseDescriptor, + FieldTypedBaseDescriptorInfo, +) from .exceptions import ( NotConnectedToServerError, ReadOnlyPropertyError, @@ -137,6 +141,9 @@ class MissingDefaultError(ValueError): Owner = TypeVar("Owner", bound="Thing") """The `.Thing` instance on which a property is bound.""" +BasePropertyT = TypeVar("BasePropertyT", bound="BaseProperty") +"""An instance of (a subclass of) BaseProperty.""" + def default_factory_from_arguments( default: Value | EllipsisType = ..., @@ -486,6 +493,12 @@ def __set__(self, obj: Owner, value: Any) -> None: "__set__ must be overridden by property implementations." ) + def descriptor_info( + self, owner: Owner | None = None + ) -> PropertyInfo[Self, Owner, Value]: + """Return an object that allows access to this descriptor's metadata.""" + return PropertyInfo(self, owner, self._owner_ref()) + class DataProperty(BaseProperty[Owner, Value], Generic[Owner, Value]): """A Property descriptor that acts like a regular variable. @@ -797,6 +810,34 @@ def __set__(self, obj: Owner, value: Value) -> None: self.fset(obj, value) +class PropertyInfo( + FieldTypedBaseDescriptorInfo[BasePropertyT, Owner, Value], + Generic[BasePropertyT, Owner, Value], +): + """Access to the metadata of a Property. + + This class provides a way to access the metadata of a Property, without + needing to retrieve the Descriptor object directly. It may be bound to a + `.Thing` instance, or may be accessed from the class. + """ + + @builtins.property + def model(self) -> type[BaseModel]: + """A `pydantic.BaseModel` describing this property's value.""" + return self.get_descriptor().model + + +class PropertyCollection(DescriptorInfoCollection[Owner, PropertyInfo], Generic[Owner]): + """Access to metadata on all the properties of a `.Thing` instance or subclass. + + This object may be used as a mapping, to retrieve `.PropertyInfo` objects for + each Property of a `.Thing` by name. This allows easy access to metadata like + their description and model. + """ + + _descriptorinfo_class = PropertyInfo + + @overload # use as a decorator @setting def setting( getter: Callable[[Owner], Value], @@ -928,6 +969,10 @@ def set_without_emit(self, obj: Owner, value: Value) -> None: """ raise NotImplementedError("This method should be implemented in subclasses.") + def descriptor_info(self, owner: Owner | None = None) -> SettingInfo[Owner, Value]: + """Return an object that allows access to this descriptor's metadata.""" + return SettingInfo(self, owner, self._owner_ref()) + class DataSetting( DataProperty[Owner, Value], BaseSetting[Owner, Value], Generic[Owner, Value] @@ -1019,3 +1064,33 @@ def set_without_emit(self, obj: Owner, value: Value) -> None: # FunctionalProperty does not emit changed events, so no special # behaviour is needed. super().__set__(obj, value) + + +class SettingInfo( + PropertyInfo[BaseSetting[Owner, Value], Owner, Value], Generic[Owner, Value] +): + """Access to the metadata of a setting.""" + + +class SettingCollection(DescriptorInfoCollection[Owner, SettingInfo], Generic[Owner]): + """Access to metadata on all the properties of a `.Thing` instance or subclass. + + This object may be used as a mapping, to retrieve `.PropertyInfo` objects for + each Property of a `.Thing` by name. This allows easy access to metadata like + their description and model. + """ + + _descriptorinfo_class = SettingInfo + + @builtins.property + def model(self) -> type[BaseModel]: + """A `pydantic.BaseModel` representing all the settings. + + This `pydantic.BaseModel` is used to load and save the settings to a file. + """ + name = self.owning_object.name if self.owning_object else self.owning_class.name + fields = {key: (value.model, None) for key, value in self.items()} + return create_model( # type: ignore[call-overload] + f"{name}_settings_model", + **fields, + ) diff --git a/src/labthings_fastapi/thing.py b/src/labthings_fastapi/thing.py index 76981998..5cb2ecb8 100644 --- a/src/labthings_fastapi/thing.py +++ b/src/labthings_fastapi/thing.py @@ -20,8 +20,16 @@ from pydantic import BaseModel +from labthings_fastapi.base_descriptor import OptionallyBoundDescriptor + from .logs import THING_LOGGER -from .properties import BaseProperty, DataProperty, BaseSetting +from .properties import ( + BaseProperty, + DataProperty, + BaseSetting, + PropertyCollection, + SettingCollection, +) from .actions import ActionDescriptor from .thing_description._model import ThingDescription, NoSecurityScheme from .utilities import class_attributes @@ -263,6 +271,25 @@ def save_settings(self) -> None: with open(path, "w", encoding="utf-8") as file_obj: file_obj.write(setting_json) + properties: OptionallyBoundDescriptor[Self, PropertyCollection] = ( + OptionallyBoundDescriptor(PropertyCollection) + ) + r"""Access to metadata and functions of this `.Thing`\ 's properties. + + `.Thing.properties` is a mapping of names to `.PropertyInfo` objects, which + allows convenient access to the metadata related to its properties. Note that + this includes settings, as they are a subclass of properties. + """ + + settings: OptionallyBoundDescriptor[Self, SettingCollection] = ( + OptionallyBoundDescriptor(SettingCollection) + ) + r"""Access to settings-related metadata and functions. + + `.Thing.settings` is a mapping of names to `.SettingInfo` objects that allows + convenient access to metadata of the settings of this `.Thing`\ . + """ + _labthings_thing_state: Optional[dict] = None @property diff --git a/tests/test_base_descriptor.py b/tests/test_base_descriptor.py index b35923a1..2cba08a3 100644 --- a/tests/test_base_descriptor.py +++ b/tests/test_base_descriptor.py @@ -602,6 +602,7 @@ class Example8(lt.Thing): assert collection.is_bound is False intfield_info = collection["intfield"] + assert isinstance(intfield_info, FieldTypedBaseDescriptorInfo) assert intfield_info.name == "intfield" assert intfield_info.title == "An integer field." assert intfield_info.value_type is int @@ -611,7 +612,7 @@ class Example8(lt.Thing): assert strprop_info.name == "strprop" assert strprop_info.title == "A string property." with pytest.raises(AttributeError): - _ = strprop_info.value_type + _ = strprop_info.value_type # type: ignore assert strprop_info.is_bound is False # A more specific descriptor info type should narrow the collection @@ -621,10 +622,8 @@ class Example8(lt.Thing): assert set(names) == {"intfield", "another_intfield"} assert len(field_typed_collection) == 2 - intfield_info = field_typed_collection["intfield"] - assert intfield_info.name == "intfield" - assert intfield_info.title == "An integer field." - assert intfield_info.value_type is int + assert field_typed_collection["intfield"] is intfield_info + assert field_typed_collection["another_intfield"] is collection["another_intfield"] example8 = create_thing_without_server(Example8) bound_collection = example8.base_descriptors @@ -635,3 +634,7 @@ class Example8(lt.Thing): bound_intfield_info = bound_collection["intfield"] assert bound_intfield_info.is_bound is True + + assert "spurious_name" not in collection + assert "spurious_name" not in bound_collection + assert "spurious_name" not in field_typed_collection diff --git a/tests/test_properties.py b/tests/test_properties.py index 4bf27bfd..0892d6a5 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -10,10 +10,12 @@ import labthings_fastapi as lt from labthings_fastapi.exceptions import ( + NotBoundToInstanceError, ServerNotRunningError, UnsupportedConstraintError, ) -from labthings_fastapi.properties import BaseProperty +from labthings_fastapi.properties import BaseProperty, PropertyInfo +from labthings_fastapi.testing import create_thing_without_server from .temp_client import poll_task @@ -26,10 +28,10 @@ def __init__(self, **kwargs): self._constrained_functional_str_setting = "ddd" boolprop: bool = lt.property(default=False) - "A boolean property" + "A boolean property." stringprop: str = lt.property(default="foo") - "A string property" + "A string property." _undoc = None @@ -64,18 +66,18 @@ def toggle_boolprop_from_thread(self): t.join() constrained_int: int = lt.property(default=5, ge=0, le=10, multiple_of=2) - "An integer property with constraints" + "An integer property with constraints." constrained_float: float = lt.property(default=5, gt=0, lt=10, allow_inf_nan=False) - "A float property with constraints" + "A float property with constraints." constrained_str: str = lt.property( default="hello", min_length=3, max_length=10, pattern="^[a-z]+$" ) - "A string property with constraints" + "A string property with constraints." constrained_int_setting: int = lt.setting(default=5, ge=0, le=10, multiple_of=2) - "An integer setting with constraints" + "An integer setting with constraints." @lt.property def constrained_functional_int(self) -> int: @@ -453,3 +455,34 @@ class AnotherBadConstraintThing(lt.Thing): # Some inappropriate constraints (e.g. multiple_of on str) are passed through # as metadata if used on the wrong type. We don't currently raise errors # for these. + + +def test_propertyinfo(): + """Check the PropertyInfo class is generated correctly.""" + + # Create a PropertyInfo object and check it matches the property + info = PropertyTestThing.properties["stringprop"] + assert isinstance(info, PropertyInfo) + assert info.name == "stringprop" + assert info.description == "A string property." + assert info.value_type is str + assert issubclass(info.model, RootModel) + assert info.model.model_fields["root"].annotation is str + assert info.is_bound is False + with pytest.raises(NotBoundToInstanceError): + info.get() + + # Try the same thing for an instance + thing = create_thing_without_server(PropertyTestThing) + binfo = thing.properties["stringprop"] + assert isinstance(binfo, PropertyInfo) + assert binfo.name == "stringprop" + assert binfo.description == "A string property." + assert binfo.value_type is str + assert issubclass(binfo.model, RootModel) + assert binfo.model.model_fields["root"].annotation is str + assert binfo.is_bound is True + assert binfo.get() == "foo" + + assert "not a property" not in PropertyTestThing.properties + assert "not a property" not in thing.properties From 6ade216a0d6859cda5eed476df7f59bff0ec1a09 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Feb 2026 01:01:05 +0100 Subject: [PATCH 05/11] Tighten typing of value_type and improve errors This now means that a test for `isinstance(obj, self.value_type)` works as expected. I've added the descriptor name to a couple of error messages, for clarity, and improved docstrings to satisfy flake8. --- src/labthings_fastapi/base_descriptor.py | 46 +++++++++++++++++++----- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/src/labthings_fastapi/base_descriptor.py b/src/labthings_fastapi/base_descriptor.py index 08797296..6ac5a008 100644 --- a/src/labthings_fastapi/base_descriptor.py +++ b/src/labthings_fastapi/base_descriptor.py @@ -227,6 +227,7 @@ def owning_object_or_error(self) -> Owner: This is mostly a convenience function that saves type-checking boilerplate. + :return: the owning object. :raises NotBoundToInstanceError: if this object is not bound. """ obj = self._bound_to_obj @@ -274,6 +275,10 @@ def __init__( it is `None` (default), the object will be unbound and will refer to the descriptor as attached to the class. This may mean that some methods are unavailable. + :param cls: The class to which we are bound. Only required if ``obj`` is + `None`\ . + + :raises ValueError: if both ``obj`` and ``cls`` are `None`\ . """ super().__init__(obj, cls) self._descriptor_ref = ref(descriptor) @@ -288,6 +293,8 @@ def get_descriptor(self) -> Descriptor: """Retrieve the descriptor object. :return: The descriptor object + :raises RuntimeError: if the descriptor was garbage collected. This should + never happen. """ descriptor = self._descriptor_ref() if descriptor is None: @@ -314,17 +321,32 @@ def description(self) -> str | None: return self.get_descriptor().description def get(self) -> Value: - """Get the value of the descriptor.""" + """Get the value of the descriptor. + + This method only works on a bound info object, it will raise an error + if called via a class rather than a `.Thing` instance. + + :return: the value of the descriptor. + :raises NotBoundToInstanceError: if called on an unbound object. + """ if not self.is_bound: - msg = "We can't get the value when called on a class." + msg = f"We can't get the value of {self.name} when called on a class." raise NotBoundToInstanceError(msg) descriptor = self.get_descriptor() return descriptor.__get__(self.owning_object_or_error()) def set(self, value: Value) -> None: - """Set the value of the descriptor.""" + """Set the value of the descriptor. + + This method may only be called if the DescriptorInfo object is bound to a + `.Thing` instance. It will raise an error if called on a class. + + :param value: the new value. + + :raises NotBoundToInstanceError: if called on an unbound info object. + """ if not self.is_bound: - msg = "We can't set the value when called on a class." + msg = f"We can't set the value of {self.name} when called on a class." raise NotBoundToInstanceError(msg) descriptor = self.get_descriptor() descriptor.__set__(self.owning_object_or_error(), value) @@ -557,6 +579,10 @@ def __set__(self, obj: Owner, value: Value) -> None: a value is assigned. This base implementation returns an `AttributeError` to signal that the descriptor is read-only. Overriding it with a method that does not raise an exception will allow the descriptor to be written to. + + :param obj: The object on which to set the value. + :param value: The value to set the descriptor to. + :raises AttributeError: always, as this is read-only by default. """ raise AttributeError("This attribute is read-only.") @@ -576,6 +602,8 @@ def _descriptor_info( :param info_class: the `.BaseDescriptorInfo` subclass to return. :param obj: The `.Thing` instance to which the return value is bound. :return: An object that may be used to refer to this descriptor. + :raises RuntimeError: if garbage collection occurs unexpectedly. This + should not happen and would indicate a LabThings bug. """ if obj: return info_class(self, obj) @@ -615,7 +643,7 @@ class FieldTypedBaseDescriptorInfo( """ @property - def value_type(self) -> type: + def value_type(self) -> type[Value]: """The type of the descriptor's value.""" return self.get_descriptor().value_type @@ -846,7 +874,7 @@ def __init__( _descriptorinfo_class: type[DescriptorInfoT] """The class of DescriptorInfo objects contained in this collection. - + This class attribute must be set in subclasses. """ @@ -860,6 +888,8 @@ def __getitem__(self, key: str) -> DescriptorInfoT: :param key: The name of the descriptor whose info object is required. :return: The DescriptorInfo object for the named descriptor. + :raises KeyError: if the key does not refer to a descriptor of the right + type. """ attr = getattr(self.owning_class, key, None) if isinstance(attr, BaseDescriptor): @@ -873,7 +903,7 @@ def __getitem__(self, key: str) -> DescriptorInfoT: def __iter__(self) -> Iterator[str]: """Iterate over the names of the descriptors of the specified type. - :return: An iterator over the names of the descriptors. + :yield: The names of the descriptors. """ for name, member in inspect.getmembers(self.owning_class): if isinstance(member, BaseDescriptor): @@ -916,7 +946,7 @@ def __get__( :param obj: The object to which the info is bound, or `None` if unbound. - :param type: The class on which the info is defined. + :param cls: The class on which the info is defined. :return: An `OptionallyBoundInfo` object. """ From c454c1a9fde188a3e19505a8091d6171fb8f3be9 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Feb 2026 01:03:44 +0100 Subject: [PATCH 06/11] Use settings.model to load/save settings This commit makes use of the new `Thing.settings` to generate a model for the settings file, and load/save the settings using a model. This has two advantages: * Settings that are typed as a model are now correctly loaded as a model. * Settings with constraints are validated when the settings file is loaded. The first point should get rid of some unnecessary code downstream. The second point is related to a change in behaviour: broken or invalid settings files, including those that have extra keys in them, now cause an error and will stop the server from loading. --- src/labthings_fastapi/properties.py | 105 +++++++++++++++++++++++++--- src/labthings_fastapi/thing.py | 94 ++++++++----------------- tests/test_settings.py | 71 +++++++++---------- 3 files changed, 162 insertions(+), 108 deletions(-) diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index da8c64e1..2e4f5f55 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -62,7 +62,7 @@ class attribute. Documentation is in strings immediately following the from weakref import WeakSet from fastapi import Body, FastAPI -from pydantic import BaseModel, RootModel, create_model +from pydantic import BaseModel, ConfigDict, RootModel, create_model from .thing_description import type_to_dataschema from .thing_description._model import ( @@ -496,7 +496,12 @@ def __set__(self, obj: Owner, value: Any) -> None: def descriptor_info( self, owner: Owner | None = None ) -> PropertyInfo[Self, Owner, Value]: - """Return an object that allows access to this descriptor's metadata.""" + r"""Return an object that allows access to this descriptor's metadata. + + :param owner: An instance to bind the descriptor info to. If `None`\ , + the returned object will be unbound and will only refer to the class. + :return: A `PropertyInfo` instance describing this property. + """ return PropertyInfo(self, owner, self._owner_ref()) @@ -822,10 +827,65 @@ class PropertyInfo( """ @builtins.property - def model(self) -> type[BaseModel]: + def model(self) -> type[BaseModel]: # noqa: DOC201 """A `pydantic.BaseModel` describing this property's value.""" return self.get_descriptor().model + @builtins.property + def model_instance(self) -> BaseModel: # noqa: DOC201 + """An instance of ``self.model`` populated with the current value. + + :raises TypeError: if the return value can't be wrapped in a model. + """ + value = self.get() + if isinstance(value, BaseModel): + return value + else: + # If the return value isn't a model, we need to wrap it in a RootModel + # which we do using the model in self.model + cls = self.model + if not issubclass(cls, RootModel): + msg = ( + f"LabThings couldn't wrap the return value of {self.name} in " + f"a model. This either means your property has an incorrect " + f"type, or there is a bug in LabThings.\n\n" + f"Value: {value}\n" + f"Expected type: {self.value_type}\n" + f"Actual type: {type(value)}\n" + f"Model: {self.model}\n" + ) + raise TypeError(msg) + return cls(root=value) + + def model_to_value(self, value: BaseModel) -> Value: + r"""Convert a model to a value for this property. + + Even properties with plain types are sometimes converted to or from a + `pydantic.BaseModel` to allow conversion to/from JSON. This is a convenience + method that accepts a model (which should be an instance of ``self.model``\ ) + and unwraps it when necessary to get the plain Python value. + + :param value: A `.BaseModel` instance to convert. + :return: the value, with `.RootModel` unwrapped so it matches the descriptor's + type. + :raises TypeError: if the supplied value cannot be converted to the right type. + """ + if isinstance(value, self.value_type): + return value + elif isinstance(value, RootModel): + root = value.root + if isinstance(root, self.value_type): + return root + msg = f"Model {value} isn't {self.value_type} or a RootModel wrapping it." + raise TypeError(msg) + + def set_without_emit_from_model(self, value: BaseModel) -> None: + """Set the value from a model instance, unwrapping RootModels as needed. + + :param value: the model to extract the value from. + """ + self.set_without_emit(self.model_to_value(value)) + class PropertyCollection(DescriptorInfoCollection[Owner, PropertyInfo], Generic[Owner]): """Access to metadata on all the properties of a `.Thing` instance or subclass. @@ -970,7 +1030,12 @@ def set_without_emit(self, obj: Owner, value: Value) -> None: raise NotImplementedError("This method should be implemented in subclasses.") def descriptor_info(self, owner: Owner | None = None) -> SettingInfo[Owner, Value]: - """Return an object that allows access to this descriptor's metadata.""" + r"""Return an object that allows access to this descriptor's metadata. + + :param owner: An instance to bind the descriptor info to. If `None`\ , + the returned object will be unbound and will only refer to the class. + :return: A `SettingInfo` instance describing this setting. + """ return SettingInfo(self, owner, self._owner_ref()) @@ -1071,6 +1136,14 @@ class SettingInfo( ): """Access to the metadata of a setting.""" + def set_without_emit(self, value: Value) -> None: + """Set the value of the setting, but don't emit a notification. + + :param value: the new value for the setting. + """ + obj = self.owning_object_or_error() + self.get_descriptor().set_without_emit(obj, value) + class SettingCollection(DescriptorInfoCollection[Owner, SettingInfo], Generic[Owner]): """Access to metadata on all the properties of a `.Thing` instance or subclass. @@ -1083,14 +1156,30 @@ class SettingCollection(DescriptorInfoCollection[Owner, SettingInfo], Generic[Ow _descriptorinfo_class = SettingInfo @builtins.property - def model(self) -> type[BaseModel]: + def model(self) -> type[BaseModel]: # noqa: DOC201 """A `pydantic.BaseModel` representing all the settings. This `pydantic.BaseModel` is used to load and save the settings to a file. + Note that it uses the ``model`` of each setting, so every field in this model + will be either a `BaseModel` or a `RootModel` instance, unless it is missing. + + Wrapping plain types in a `RootModel` makes no difference to the JSON, but it + means that constraints will be applied and it makes it easier to distinguish + between missing fields and fields that are set to `None`. """ name = self.owning_object.name if self.owning_object else self.owning_class.name - fields = {key: (value.model, None) for key, value in self.items()} + fields = {key: (value.model | None, None) for key, value in self.items()} return create_model( # type: ignore[call-overload] - f"{name}_settings_model", - **fields, + f"{name}_settings_model", **fields, __config__=ConfigDict(extra="forbid") ) + + @builtins.property + def model_instance(self) -> BaseModel: # noqa: DOC201 + """An instance of ``self.model`` populated with the current setting values.""" + models = { + # Note that we need to populate it with models, not the bare types. + # This doesn't make a difference to the JSON. + name: setting.model_instance + for name, setting in self.items() + } + return self.model(**models) diff --git a/src/labthings_fastapi/thing.py b/src/labthings_fastapi/thing.py index 5cb2ecb8..c056dd36 100644 --- a/src/labthings_fastapi/thing.py +++ b/src/labthings_fastapi/thing.py @@ -11,14 +11,12 @@ from collections.abc import Mapping import logging import os -import json from json.decoder import JSONDecodeError from fastapi.encoders import jsonable_encoder from fastapi import Request, WebSocket from anyio.abc import ObjectSendStream from anyio.to_thread import run_sync -from pydantic import BaseModel from labthings_fastapi.base_descriptor import OptionallyBoundDescriptor @@ -26,7 +24,6 @@ from .properties import ( BaseProperty, DataProperty, - BaseSetting, PropertyCollection, SettingCollection, ) @@ -191,28 +188,6 @@ def thing_description(request: Request) -> ThingDescription: async def websocket(ws: WebSocket) -> None: await websocket_endpoint(self, ws) - # A private variable to hold the list of settings so it doesn't need to be - # iterated through each time it is read - _settings_store: Optional[dict[str, BaseSetting]] = None - - @property - def _settings(self) -> dict[str, BaseSetting]: - """A private property that returns a dict of all settings for this Thing. - - Each dict key is the name of the setting, the corresponding value is the - BaseSetting class (a descriptor). This can be used to directly get the - descriptor so that the value can be set without emitting signals, such - as on startup. - """ - if self._settings_store is not None: - return self._settings_store - - self._settings_store = {} - for name, attr in class_attributes(self): - if isinstance(attr, BaseSetting): - self._settings_store[name] = attr - return self._settings_store - def load_settings(self) -> None: """Load settings from json. @@ -228,27 +203,27 @@ def load_settings(self) -> None: """ setting_storage_path = self._thing_server_interface.settings_file_path thing_name = type(self).__name__ - if os.path.exists(setting_storage_path): - self._disable_saving_settings = True - try: - with open(setting_storage_path, "r", encoding="utf-8") as file_obj: - setting_dict = json.load(file_obj) - for key, value in setting_dict.items(): - if key in self._settings: - self._settings[key].set_without_emit(self, value) - else: - _LOGGER.warning( - ( - "Cannot set %s from persistent storage as %s " - "has no matching setting." - ), - key, - thing_name, - ) - except (FileNotFoundError, JSONDecodeError, PermissionError): - _LOGGER.warning("Error loading settings for %s", thing_name) - finally: - self._disable_saving_settings = False + if not os.path.exists(setting_storage_path): + # If the settings file doesn't exist, we have nothing to do - the settings + # are already initialised to their default values. + return + + # Stop recursion by not allowing settings to be saved as we're reading them. + self._disable_saving_settings = True + + try: + with open(setting_storage_path, "r", encoding="utf-8") as file_obj: + settings_model = self.settings.model.model_validate_json( + file_obj.read() + ) + for key, value in settings_model: + if value is None: + continue # `None` means the key was missing + self.settings[key].set_without_emit_from_model(value) + except (FileNotFoundError, JSONDecodeError, PermissionError): + _LOGGER.warning("Error loading settings for %s", thing_name) + finally: + self._disable_saving_settings = False def save_settings(self) -> None: """Save settings to JSON. @@ -258,34 +233,27 @@ def save_settings(self) -> None: """ if self._disable_saving_settings: return - if self._settings is not None: - setting_dict = {} - for name in self._settings.keys(): - value = getattr(self, name) - if isinstance(value, BaseModel): - value = value.model_dump() - setting_dict[name] = value - # Dumpy to string before writing so if this fails the file isn't overwritten - setting_json = json.dumps(setting_dict, indent=4) - path = self._thing_server_interface.settings_file_path - with open(path, "w", encoding="utf-8") as file_obj: - file_obj.write(setting_json) - - properties: OptionallyBoundDescriptor[Self, PropertyCollection] = ( + # We dump to a string first, to avoid corrupting the file if it fails + setting_json = self.settings.model_instance.model_dump_json(indent=4) + path = self._thing_server_interface.settings_file_path + with open(path, "w", encoding="utf-8") as file_obj: + file_obj.write(setting_json) + + properties: OptionallyBoundDescriptor["Thing", PropertyCollection] = ( OptionallyBoundDescriptor(PropertyCollection) ) r"""Access to metadata and functions of this `.Thing`\ 's properties. - + `.Thing.properties` is a mapping of names to `.PropertyInfo` objects, which allows convenient access to the metadata related to its properties. Note that this includes settings, as they are a subclass of properties. """ - settings: OptionallyBoundDescriptor[Self, SettingCollection] = ( + settings: OptionallyBoundDescriptor["Thing", SettingCollection] = ( OptionallyBoundDescriptor(SettingCollection) ) r"""Access to settings-related metadata and functions. - + `.Thing.settings` is a mapping of names to `.SettingInfo` objects that allows convenient access to metadata of the settings of this `.Thing`\ . """ diff --git a/tests/test_settings.py b/tests/test_settings.py index 19357748..359a1e8e 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -4,14 +4,26 @@ from typing import Any import pytest import os -import logging +from pydantic import BaseModel, ValidationError from fastapi.testclient import TestClient import labthings_fastapi as lt from labthings_fastapi.testing import create_thing_without_server +class MyModel(BaseModel): + """A basic Model subclass. + + This is used to test that we can safely load/save settings that are + `.BaseModel` instances. Prior to v0.0.14, these were loaded as dictionaries + but they should now be correctly reinflated to the right class. + """ + + a: int + b: str + + class ThingWithSettings(lt.Thing): """A test `.Thing` with some settings and actions.""" @@ -22,13 +34,16 @@ def __init__(self, **kwargs: Any) -> None: self._localonlysetting = "Local-only default." boolsetting: bool = lt.setting(default=False) - "A boolean setting" + "A boolean setting." stringsetting: str = lt.setting(default="foo") - "A string setting" + "A string setting." dictsetting: dict = lt.setting(default_factory=lambda: {"a": 1, "b": 2}) - "A dictionary setting" + "A dictionary setting." + + modelsetting: MyModel = lt.setting(default_factory=lambda: MyModel(a=0, b="string")) + "A setting that is a BaseModel." @lt.setting def floatsetting(self) -> float: @@ -98,6 +113,7 @@ def _settings_dict( floatsetting=1.0, stringsetting="foo", dictsetting=None, + modelsetting=None, localonlysetting="Local-only default.", localonly_boolsetting=False, ): @@ -107,11 +123,14 @@ def _settings_dict( """ if dictsetting is None: dictsetting = {"a": 1, "b": 2} + if modelsetting is None: + modelsetting = {"a": 0, "b": "string"} return { "boolsetting": boolsetting, "floatsetting": floatsetting, "stringsetting": stringsetting, "dictsetting": dictsetting, + "modelsetting": modelsetting, "localonlysetting": localonlysetting, "localonly_boolsetting": localonly_boolsetting, } @@ -134,13 +153,15 @@ def test_setting_available(): assert thing.floatsetting == 1.0 assert thing.localonlysetting == "Local-only default." assert thing.dictsetting == {"a": 1, "b": 2} + assert thing.modelsetting == MyModel(a=0, b="string") def test_functional_settings_save(tempdir): """Check updated settings are saved to disk ``floatsetting`` is a functional setting, we should also test - a `.DataSetting` for completeness.""" + a `.DataSetting` for completeness. + """ server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) setting_file = _get_setting_file(server, "thing") # No setting file created when first added @@ -151,12 +172,12 @@ def test_functional_settings_save(tempdir): # A 201 return code means the operation succeeded (i.e. # the property was written to) assert r.status_code == 201 - # We check the value with a GET request - r = client.get("/thing/floatsetting") - assert r.json() == 2.0 # After successfully writing to the setting, it should # have created a settings file. assert os.path.isfile(setting_file) + # We check the value with a GET request + r = client.get("/thing/floatsetting") + assert r.json() == 2.0 with open(setting_file, "r", encoding="utf-8") as file_obj: # Check settings on file match expected dictionary assert json.load(file_obj) == _settings_dict(floatsetting=2.0) @@ -253,21 +274,9 @@ def test_load_extra_settings(caplog, tempdir): with open(setting_file, "w", encoding="utf-8") as file_obj: file_obj.write(setting_json) - with caplog.at_level(logging.WARNING): + with pytest.raises(ValidationError, match="extra_forbidden"): # Create the server with the Thing added. - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) - assert len(caplog.records) == 1 - assert caplog.records[0].levelname == "WARNING" - assert caplog.records[0].name == "labthings_fastapi.thing" - - # Get the instance of the ThingWithSettings - thing = server.things["thing"] - assert isinstance(thing, ThingWithSettings) - - # Check other settings are loaded as expected - assert not thing.boolsetting - assert thing.stringsetting == "bar" - assert thing.floatsetting == 3.0 + _ = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) def test_try_loading_corrupt_settings(tempdir, caplog): @@ -286,19 +295,7 @@ def test_try_loading_corrupt_settings(tempdir, caplog): with open(setting_file, "w", encoding="utf-8") as file_obj: file_obj.write(setting_json) - # Recreate the server and check for warnings - with caplog.at_level(logging.WARNING): + # Recreate the server and check for the error + with pytest.raises(ValidationError, match="Invalid JSON"): # Add thing to server - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) - assert len(caplog.records) == 1 - assert caplog.records[0].levelname == "WARNING" - assert caplog.records[0].name == "labthings_fastapi.thing" - - # Get the instance of the ThingWithSettings - thing = server.things["thing"] - assert isinstance(thing, ThingWithSettings) - - # Check default settings are loaded - assert not thing.boolsetting - assert thing.stringsetting == "foo" - assert thing.floatsetting == 1.0 + _ = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) From e16b6722ea795fe4465e941c7fe054525c1b5621 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Feb 2026 01:20:41 +0100 Subject: [PATCH 07/11] Move set_without_emit_from_model. This was accidentally defined on PropertyInfo, when it should have been in SettingInfo. --- src/labthings_fastapi/properties.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index 2e4f5f55..c1b79589 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -879,13 +879,6 @@ def model_to_value(self, value: BaseModel) -> Value: msg = f"Model {value} isn't {self.value_type} or a RootModel wrapping it." raise TypeError(msg) - def set_without_emit_from_model(self, value: BaseModel) -> None: - """Set the value from a model instance, unwrapping RootModels as needed. - - :param value: the model to extract the value from. - """ - self.set_without_emit(self.model_to_value(value)) - class PropertyCollection(DescriptorInfoCollection[Owner, PropertyInfo], Generic[Owner]): """Access to metadata on all the properties of a `.Thing` instance or subclass. @@ -1144,6 +1137,13 @@ def set_without_emit(self, value: Value) -> None: obj = self.owning_object_or_error() self.get_descriptor().set_without_emit(obj, value) + def set_without_emit_from_model(self, value: BaseModel) -> None: + """Set the value from a model instance, unwrapping RootModels as needed. + + :param value: the model to extract the value from. + """ + self.set_without_emit(self.model_to_value(value)) + class SettingCollection(DescriptorInfoCollection[Owner, SettingInfo], Generic[Owner]): """Access to metadata on all the properties of a `.Thing` instance or subclass. From f01129d4a4ed48f47b639a94250ddf894c84c6ac Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Feb 2026 01:46:06 +0100 Subject: [PATCH 08/11] Add `.Thing.actions` This is added, largely for completeness so it's consistent between actions, properties and settings. --- src/labthings_fastapi/actions.py | 66 ++++++++++++++++++++++++++++++-- src/labthings_fastapi/thing.py | 11 +++++- tests/test_actions.py | 50 ++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 4 deletions(-) diff --git a/src/labthings_fastapi/actions.py b/src/labthings_fastapi/actions.py index 5a4b5f24..9e2e7277 100644 --- a/src/labthings_fastapi/actions.py +++ b/src/labthings_fastapi/actions.py @@ -39,9 +39,12 @@ from fastapi import FastAPI, HTTPException, Request, Body, BackgroundTasks from pydantic import BaseModel, create_model -from labthings_fastapi.middleware.url_for import URLFor - -from .base_descriptor import BaseDescriptor +from .middleware.url_for import URLFor +from .base_descriptor import ( + BaseDescriptor, + BaseDescriptorInfo, + DescriptorInfoCollection, +) from .logs import add_thing_log_destination from .utilities import model_to_dict, wrap_plain_types_in_rootmodel from .invocations import InvocationModel, InvocationStatus, LogRecordModel @@ -591,6 +594,54 @@ def delete_invocation(id: uuid.UUID) -> None: OwnerT = TypeVar("OwnerT", bound="Thing") +class ActionInfo( + BaseDescriptorInfo[ + "ActionDescriptor", OwnerT, Callable[ActionParams, ActionReturn] + ], + Generic[OwnerT, ActionParams, ActionReturn], +): + """Convenient access to the metadata of an action.""" + + @property + def response_timeout(self) -> float: + """The time to wait before replying to the HTTP request initiating an action.""" + return self.get_descriptor().response_timeout + + @property + def retention_time(self) -> float: + """How long to retain the action's output for, in seconds.""" + return self.get_descriptor().retention_time + + @property + def input_model(self) -> type[BaseModel]: + """A Pydantic model for the input parameters of an Action.""" + return self.get_descriptor().input_model + + @property + def output_model(self) -> type[BaseModel]: + """A Pydantic model for the output parameters of an Action.""" + return self.get_descriptor().output_model + + @property + def invocation_model(self) -> type[BaseModel]: + """A Pydantic model for an invocation of this action.""" + return self.get_descriptor().invocation_model + + @property + def func(self) -> Callable[Concatenate[OwnerT, ActionParams], ActionReturn]: + """The function that runs the action.""" + return self.get_descriptor().func + + +class ActionCollection( + DescriptorInfoCollection[OwnerT, ActionInfo], + Generic[OwnerT], +): + """Access to the metadata of each Action.""" + + _descriptorinfo_class = ActionInfo + + class ActionDescriptor( BaseDescriptor[OwnerT, Callable[ActionParams, ActionReturn]], Generic[ActionParams, ActionReturn, OwnerT], @@ -881,6 +932,15 @@ def action_affordance( output=type_to_dataschema(self.output_model, title=f"{self.name}_output"), ) + def descriptor_info(self, owner: OwnerT | None = None) -> ActionInfo: + """Return an `.ActionInfo` object describing this action. + + The returned object will either refer to the class, or be bound to a particular + instance. If it is bound, more properties will be available - e.g. we will be + able to get the bound function. + """ + return self._descriptor_info(ActionInfo, owner) + @overload def action( diff --git a/src/labthings_fastapi/thing.py b/src/labthings_fastapi/thing.py index c056dd36..86b1f6f5 100644 --- a/src/labthings_fastapi/thing.py +++ b/src/labthings_fastapi/thing.py @@ -27,7 +27,7 @@ PropertyCollection, SettingCollection, ) -from .actions import ActionDescriptor +from .actions import ActionCollection, ActionDescriptor from .thing_description._model import ThingDescription, NoSecurityScheme from .utilities import class_attributes from .thing_description import validation @@ -258,6 +258,15 @@ def save_settings(self) -> None: convenient access to metadata of the settings of this `.Thing`\ . """ + actions: OptionallyBoundDescriptor["Thing", ActionCollection] = ( + OptionallyBoundDescriptor(ActionCollection) + ) + r"""Access to metadata for the actions of this `.Thing`\ . + + `.Thing.actions` is a mapping of names to `.ActionInfo` objects that allows + convenient access to metadata of each action. + """ + _labthings_thing_state: Optional[dict] = None @property diff --git a/tests/test_actions.py b/tests/test_actions.py index d08c6e67..98f56095 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,14 +1,32 @@ import uuid from fastapi.testclient import TestClient +from pydantic import BaseModel import pytest import functools +from labthings_fastapi.actions import ActionInfo from labthings_fastapi.testing import create_thing_without_server from .temp_client import poll_task, get_link from labthings_fastapi.example_things import MyThing import labthings_fastapi as lt +class ActionMan(lt.Thing): + """A Thing with some actions""" + + _direction: str = "centred" + + @lt.action(response_timeout=0, retention_time=0) + def move_eyes(self, direction: str) -> None: + """Take one input and no outputs""" + self._direction = direction + + @lt.action + def say_hello(self) -> str: + """Return a string.""" + return "Hello World." + + @pytest.fixture def client(): """Yield a client connected to a ThingServer""" @@ -27,6 +45,38 @@ def run(payload=None): return run +def test_action_info(): + """Test the .actions descriptor works as expected.""" + actions = ActionMan.actions + assert len(actions) == 2 + assert set(actions) == {"move_eyes", "say_hello"} + assert actions.is_bound is False + + move_eyes = ActionMan.actions["move_eyes"] + assert isinstance(move_eyes, ActionInfo) + assert move_eyes.name == "move_eyes" + assert move_eyes.description == "Take one input and no outputs" + assert set(move_eyes.input_model.model_fields) == {"direction"} + assert set(move_eyes.output_model.model_fields) == {"root"} # rootmodel for None + assert issubclass(move_eyes.invocation_model, BaseModel) + assert move_eyes.response_timeout == 0 + assert move_eyes.retention_time == 0 + assert move_eyes.is_bound is False + assert callable(move_eyes.func) + + # Try again with a bound one + action_man = create_thing_without_server(ActionMan) + assert len(action_man.actions) == 2 + assert set(action_man.actions) == {"move_eyes", "say_hello"} + assert action_man.actions.is_bound is True + + move_eyes = action_man.actions["move_eyes"] + assert isinstance(move_eyes, ActionInfo) + assert move_eyes.name == "move_eyes" + assert move_eyes.description == "Take one input and no outputs" + assert move_eyes.is_bound is True + + def test_get_action_invocations(client): """Test that running "get" on an action returns a list of invocations.""" # When we start the action has no invocations From c7d0cb2fa371eefefdadd87c9520e6cc868b4cea Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 9 Feb 2026 22:14:06 +0000 Subject: [PATCH 09/11] Fix a spelling error --- src/labthings_fastapi/base_descriptor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/labthings_fastapi/base_descriptor.py b/src/labthings_fastapi/base_descriptor.py index 6ac5a008..c75e5c9b 100644 --- a/src/labthings_fastapi/base_descriptor.py +++ b/src/labthings_fastapi/base_descriptor.py @@ -611,7 +611,7 @@ def _descriptor_info( self.assert_set_name_called() owning_class = self._owner_ref() if owning_class is None: - raise RuntimeError("Class was unexpetedly deleted") + raise RuntimeError("Class was unexpectedly deleted") return info_class(self, None, owning_class) def descriptor_info( From 07883bf057ecc1d36b3883fc7eb3ae2385829291 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 9 Feb 2026 22:52:44 +0000 Subject: [PATCH 10/11] Fix test suite This fixes a few minor problems that caused tests to fail: 1. DescriptorInfo objects don't get cached, so we need to test for equality rather than identity (== vs is). 2. There were some bad default values for constrained settings. This caused an error when settings were written to disk. Possibly we should enable "validate_default" for these. 3. There was a bad name for a private property, used to test an error condition. As part of this I've implemented `__eq__` and `__repr__` on BaseDescriptorInfo. I think that is likely to prove useful in other contexts. I added a test for _repr_, __eq__ is tested in test_descriptorinfocollection(). --- src/labthings_fastapi/base_descriptor.py | 21 ++++++++++++++++++++- tests/test_base_descriptor.py | 15 ++++++++++++--- tests/test_properties.py | 4 ++-- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/labthings_fastapi/base_descriptor.py b/src/labthings_fastapi/base_descriptor.py index c75e5c9b..74cdc955 100644 --- a/src/labthings_fastapi/base_descriptor.py +++ b/src/labthings_fastapi/base_descriptor.py @@ -30,7 +30,7 @@ import inspect from itertools import pairwise import textwrap -from typing import overload, Generic, Mapping, TypeVar, TYPE_CHECKING +from typing import Any, overload, Generic, Mapping, TypeVar, TYPE_CHECKING from types import MappingProxyType import typing from weakref import WeakKeyDictionary, ref @@ -351,6 +351,25 @@ def set(self, value: Value) -> None: descriptor = self.get_descriptor() descriptor.__set__(self.owning_object_or_error(), value) + def __eq__(self, other: Any) -> bool: + """Determine if this object is equal to another one. + + :param other: the object we're comparing to. + :return: whether the two objects are equal. + """ + return ( + self.__class__ == other.__class__ + and self.name == other.name + and self.owning_class == other.owning_class + and self.owning_object == other.owning_object + ) + + def __repr__(self) -> str: + """Represent the DescriptorInfo object as a string.""" + descriptor = f"{self.owning_class.__name__}.{self.name}" + bound = f" bound to {self.owning_object}>" if self.is_bound else "" + return f"<{self.__class__.__name__} for {descriptor}{bound}>" + class BaseDescriptor(Generic[Owner, Value]): r"""A base class for descriptors in LabThings-FastAPI. diff --git a/tests/test_base_descriptor.py b/tests/test_base_descriptor.py index 2cba08a3..68830893 100644 --- a/tests/test_base_descriptor.py +++ b/tests/test_base_descriptor.py @@ -375,7 +375,7 @@ class Example3: # I don't see how this could happen in practice, _owner is always # set if we find a forward reference. # We force this error condition by manually setting _owner to None - Example3.field4._owner = None + Example3.field4._owner_ref = None with pytest.raises(MissingTypeError) as excinfo: _ = Example3.field4.value_type @@ -512,6 +512,7 @@ class Example7: # First, make an unbound info object intfield_info = intfield_descriptor.descriptor_info() + assert repr(intfield_info) == "" assert intfield_info.is_bound is False assert intfield_info.name == "intfield" assert intfield_info.title == "The descriptor's title." @@ -522,6 +523,9 @@ class Example7: # Next, check the bound version example6 = Example7() intfield_info = intfield_descriptor.descriptor_info(example6) + assert repr(intfield_info).startswith( + " Date: Tue, 10 Feb 2026 12:54:34 +0000 Subject: [PATCH 11/11] Fix docstrings --- src/labthings_fastapi/actions.py | 5 ++++- src/labthings_fastapi/base_descriptor.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/labthings_fastapi/actions.py b/src/labthings_fastapi/actions.py index 9e2e7277..793e5957 100644 --- a/src/labthings_fastapi/actions.py +++ b/src/labthings_fastapi/actions.py @@ -933,11 +933,14 @@ def action_affordance( ) def descriptor_info(self, owner: OwnerT | None = None) -> ActionInfo: - """Return an `.ActionInfo` object describing this action. + r"""Return an `.ActionInfo` object describing this action. The returned object will either refer to the class, or be bound to a particular instance. If it is bound, more properties will be available - e.g. we will be able to get the bound function. + + :param owner: The owning object, or `None` to return an unbound `.ActionInfo`\ . + :return: An `.ActionInfo` object describing this Action. """ return self._descriptor_info(ActionInfo, owner) diff --git a/src/labthings_fastapi/base_descriptor.py b/src/labthings_fastapi/base_descriptor.py index 74cdc955..1589e8cb 100644 --- a/src/labthings_fastapi/base_descriptor.py +++ b/src/labthings_fastapi/base_descriptor.py @@ -365,7 +365,10 @@ def __eq__(self, other: Any) -> bool: ) def __repr__(self) -> str: - """Represent the DescriptorInfo object as a string.""" + """Represent the DescriptorInfo object as a string. + + :return: a string representing the info object. + """ descriptor = f"{self.owning_class.__name__}.{self.name}" bound = f" bound to {self.owning_object}>" if self.is_bound else "" return f"<{self.__class__.__name__} for {descriptor}{bound}>"