From bf463a56a2a1338eb5cf4bd96d87680e936e984d Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 28 Jan 2026 16:53:36 +0000 Subject: [PATCH 01/14] Make BaseDescriptor generic wrt the owning class Previously, BaseDescriptor typed the owning object as `Thing`, which was a bit loose and led to some vague type hints (particularly for functional properties and actions). I've added a second generic parameter for the owning class. This makes some of the type test code a bit more verbose, but it gets rid of a fair few `type: ignore` statements and also means we now detect a few type errors that were previously missed. This is mostly tidying up, but will be useful for some PRs in the near future. --- src/labthings_fastapi/actions.py | 8 +- src/labthings_fastapi/base_descriptor.py | 16 ++-- src/labthings_fastapi/properties.py | 106 +++++++++++------------ src/labthings_fastapi/thing_slots.py | 4 +- tests/test_base_descriptor.py | 15 ++-- typing_tests/thing_properties.py | 67 +++++++------- 6 files changed, 112 insertions(+), 104 deletions(-) diff --git a/src/labthings_fastapi/actions.py b/src/labthings_fastapi/actions.py index 95b9910f..7e2b3015 100644 --- a/src/labthings_fastapi/actions.py +++ b/src/labthings_fastapi/actions.py @@ -247,10 +247,10 @@ def response(self, request: Optional[Request] = None) -> InvocationModel: ] # The line below confuses MyPy because self.action **evaluates to** a Descriptor # object (i.e. we don't call __get__ on the descriptor). - return self.action.invocation_model( # type: ignore[call-overload] + return self.action.invocation_model( # type: ignore[attr-defined] status=self.status, id=self.id, - action=self.thing.path + self.action.name, # type: ignore[call-overload] + action=self.thing.path + self.action.name, # type: ignore[attr-defined] href=href, timeStarted=self._start_time, timeCompleted=self._end_time, @@ -290,7 +290,7 @@ def run(self) -> None: """ # self.action evaluates to an ActionDescriptor. This confuses mypy, # which thinks we are calling ActionDescriptor.__get__. - action: ActionDescriptor = self.action # type: ignore[call-overload] + action: ActionDescriptor = self.action # type: ignore[assignment] logger = self.thing.logger # The line below saves records matching our ID to ``self._log`` add_thing_log_destination(self.id, self._log) @@ -626,7 +626,7 @@ def delete_invocation(id: uuid.UUID) -> None: class ActionDescriptor( - BaseDescriptor[Callable[ActionParams, ActionReturn]], + BaseDescriptor[OwnerT, Callable[ActionParams, ActionReturn]], Generic[ActionParams, ActionReturn, OwnerT], ): """Wrap actions to enable them to be run over HTTP. diff --git a/src/labthings_fastapi/base_descriptor.py b/src/labthings_fastapi/base_descriptor.py index 35b0436f..6012d550 100644 --- a/src/labthings_fastapi/base_descriptor.py +++ b/src/labthings_fastapi/base_descriptor.py @@ -26,6 +26,12 @@ Value = TypeVar("Value") """The value returned by the descriptor, when called on an instance.""" +Owner = TypeVar("Owner", bound="Thing") +"""A Thing subclass that owns a descriptor.""" + +Descriptor = TypeVar("Descriptor", bound="BaseDescriptor") +"""The type of a descriptor that's referred to by a `BaseDescriptorInfo` object.""" + class DescriptorNotAddedToClassError(RuntimeError): """Descriptor has not yet been added to a class. @@ -138,7 +144,7 @@ def _set_prop4(self, val): """ -class BaseDescriptor(Generic[Value]): +class BaseDescriptor(Generic[Owner, Value]): r"""A base class for descriptors in LabThings-FastAPI. This class implements several behaviours common to descriptors in LabThings: @@ -306,12 +312,12 @@ def description(self) -> str | None: # I have ignored D105 (missing docstrings) on the overloads - these should not # exist on @overload definitions. @overload - def __get__(self, obj: Thing, type: type | None = None) -> Value: ... + def __get__(self, obj: Owner, type: type | None = None) -> Value: ... @overload def __get__(self, obj: None, type: type) -> Self: ... - def __get__(self, obj: Thing | None, type: type | None = None) -> Value | Self: + def __get__(self, obj: Owner | None, type: type | None = None) -> Value | Self: """Return the value or the descriptor, as per `property`. If ``obj`` is ``None`` (i.e. the descriptor is accessed as a class attribute), @@ -331,7 +337,7 @@ def __get__(self, obj: Thing | None, type: type | None = None) -> Value | Self: return self.instance_get(obj) return self - def instance_get(self, obj: Thing) -> Value: + def instance_get(self, obj: Owner) -> Value: """Return the value of the descriptor. This method is called from ``__get__`` if the descriptor is accessed as an @@ -357,7 +363,7 @@ def instance_get(self, obj: Thing) -> Value: ) -class FieldTypedBaseDescriptor(Generic[Value], BaseDescriptor[Value]): +class FieldTypedBaseDescriptor(Generic[Owner, Value], BaseDescriptor[Owner, Value]): """A BaseDescriptor that determines its type like a dataclass field.""" def __init__(self) -> None: diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index 5be2998e..c948c9d4 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -54,7 +54,6 @@ class attribute. Documentation is in strings immediately following the Any, Callable, Generic, - TypeAlias, TypeVar, overload, TYPE_CHECKING, @@ -133,29 +132,16 @@ class MissingDefaultError(ValueError): Value = TypeVar("Value") -if TYPE_CHECKING: - # It's hard to type check methods, because the type of ``self`` - # will be a subclass of `.Thing`, and `Callable` types are - # contravariant in their arguments (i.e. - # ``Callable[[SpecificThing,], Value])`` is not a subtype of - # ``Callable[[Thing,], Value]``. - # It is probably not particularly important for us to check th - # type of ``self`` when decorating methods, so it is left as - # ``Any`` to avoid the major confusion that would result from - # trying to type it more tightly. - # - # Note: in ``@overload`` definitions, it's sometimes necessary - # to avoid the use of these aliases, as ``mypy`` can't - # pick which variant is in use without the explicit `Callable`. - ValueFactory: TypeAlias = Callable[[], Value] - ValueGetter: TypeAlias = Callable[[Any], Value] - ValueSetter: TypeAlias = Callable[[Any, Value], None] +"""The value returned by a property.""" + +Owner = TypeVar("Owner", bound="Thing") +"""The `.Thing` instance on which a property is bound.""" def default_factory_from_arguments( default: Value | EllipsisType = ..., - default_factory: ValueFactory | None = None, -) -> ValueFactory: + default_factory: Callable[[], Value] | None = None, +) -> Callable[[], Value]: """Process default arguments to get a default factory function. This function takes the ``default`` and ``default_factory`` arguments @@ -198,9 +184,11 @@ def default_factory_from_arguments( raise OverspecifiedDefaultError() if default is not ...: - def default_factory() -> Value: + def dummy_default_factory() -> Value: return default + default_factory = dummy_default_factory + if not callable(default_factory): raise MissingDefaultError("The default_factory must be callable.") return default_factory @@ -209,8 +197,8 @@ def default_factory() -> Value: # See comment at the top of the file regarding ignored linter rules. @overload # use as a decorator @property def property( - getter: Callable[[Any], Value], -) -> FunctionalProperty[Value]: ... + getter: Callable[[Owner], Value], +) -> FunctionalProperty[Owner, Value]: ... @overload # use as `field: int = property(default=0)` @@ -226,13 +214,13 @@ def property( def property( - getter: ValueGetter | EllipsisType = ..., + getter: Callable[[Owner], Value] | EllipsisType = ..., *, default: Value | EllipsisType = ..., - default_factory: ValueFactory | None = None, + default_factory: Callable[[], Value] | None = None, readonly: bool = False, **constraints: Any, -) -> Value | FunctionalProperty[Value]: +) -> Value | FunctionalProperty[Owner, Value]: r"""Define a Property on a `.Thing`\ . This function may be used to define :ref:`properties` in @@ -328,7 +316,7 @@ def property( ) -class BaseProperty(FieldTypedBaseDescriptor[Value], Generic[Value]): +class BaseProperty(FieldTypedBaseDescriptor[Owner, Value], Generic[Owner, Value]): """A descriptor that marks Properties on Things. This class is used to determine whether an attribute of a `.Thing` should @@ -396,7 +384,7 @@ def model(self) -> type[BaseModel]: ) return self._model - def add_to_fastapi(self, app: FastAPI, thing: Thing) -> None: + def add_to_fastapi(self, app: FastAPI, thing: Owner) -> None: """Add this action to a FastAPI app, bound to a particular Thing. :param app: The FastAPI application we are adding endpoints to. @@ -487,7 +475,7 @@ def property_affordance( } ) - def __set__(self, obj: Thing, value: Any) -> None: + def __set__(self, obj: Owner, value: Any) -> None: """Set the property (stub method). This is a stub ``__set__`` method to mark this as a data descriptor. @@ -501,7 +489,7 @@ def __set__(self, obj: Thing, value: Any) -> None: ) -class DataProperty(BaseProperty[Value], Generic[Value]): +class DataProperty(BaseProperty[Owner, Value], Generic[Owner, Value]): """A Property descriptor that acts like a regular variable. `.DataProperty` descriptors remember their value, and can be read and @@ -521,7 +509,7 @@ def __init__( # noqa: DOC101,DOC103 def __init__( # noqa: DOC101,DOC103 self, *, - default_factory: ValueFactory, + default_factory: Callable[[], Value], readonly: bool = False, constraints: Mapping[str, Any] | None = None, ) -> None: ... @@ -530,7 +518,7 @@ def __init__( self, default: Value | EllipsisType = ..., *, - default_factory: ValueFactory | None = None, + default_factory: Callable[[], Value] | None = None, readonly: bool = False, constraints: Mapping[str, Any] | None = None, ) -> None: @@ -573,7 +561,7 @@ def __init__( ) self.readonly = readonly - def instance_get(self, obj: Thing) -> Value: + def instance_get(self, obj: Owner) -> Value: """Return the property's value. This will supply a default if the property has not yet been set. @@ -588,7 +576,7 @@ def instance_get(self, obj: Thing) -> Value: return obj.__dict__[self.name] def __set__( - self, obj: Thing, value: Value, emit_changed_event: bool = True + self, obj: Owner, value: Value, emit_changed_event: bool = True ) -> None: """Set the property's value. @@ -653,7 +641,7 @@ async def emit_changed_event_async(self, obj: Thing, value: Value) -> None: ) -class FunctionalProperty(BaseProperty[Value], Generic[Value]): +class FunctionalProperty(BaseProperty[Owner, Value], Generic[Owner, Value]): """A property that uses a getter and a setter. For properties that should work like variables, use `.DataProperty`. For @@ -665,7 +653,7 @@ class FunctionalProperty(BaseProperty[Value], Generic[Value]): def __init__( self, - fget: ValueGetter, + fget: Callable[[Owner], Value], constraints: Mapping[str, Any] | None = None, ) -> None: """Set up a FunctionalProperty. @@ -683,7 +671,7 @@ def __init__( :raises MissingTypeError: if the getter does not have a return type annotation. """ super().__init__(constraints=constraints) - self._fget: ValueGetter = fget + self._fget = fget self._type = return_type(self._fget) if self._type is None: msg = ( @@ -691,20 +679,20 @@ def __init__( "Return type annotations are required for property getters." ) raise MissingTypeError(msg) - self._fset: ValueSetter | None = None + self._fset: Callable[[Owner, Value], None] | None = None self.readonly: bool = True @builtins.property - def fget(self) -> ValueGetter: # noqa: DOC201 + def fget(self) -> Callable[[Owner], Value]: # noqa: DOC201 """The getter function.""" return self._fget @builtins.property - def fset(self) -> ValueSetter | None: # noqa: DOC201 + def fset(self) -> Callable[[Owner, Value], None] | None: # noqa: DOC201 """The setter function.""" return self._fset - def getter(self, fget: ValueGetter) -> Self: + def getter(self, fget: Callable[[Owner], Value]) -> Self: """Set the getter function of the property. This function returns the descriptor, so it may be used as a decorator. @@ -718,7 +706,7 @@ def getter(self, fget: ValueGetter) -> Self: self.__doc__ = fget.__doc__ return self - def setter(self, fset: ValueSetter) -> Self: + def setter(self, fset: Callable[[Owner, Value], None]) -> Self: r"""Set the setter function of the property. This function returns the descriptor, so it may be used as a decorator. @@ -790,7 +778,7 @@ def _set_myprop(self, val: int) -> None: return fset # type: ignore[return-value] return self - def instance_get(self, obj: Thing) -> Value: + def instance_get(self, obj: Owner) -> Value: """Get the value of the property. :param obj: the `.Thing` on which the attribute is accessed. @@ -798,7 +786,7 @@ def instance_get(self, obj: Thing) -> Value: """ return self.fget(obj) - def __set__(self, obj: Thing, value: Value) -> None: + def __set__(self, obj: Owner, value: Value) -> None: """Set the value of the property. :param obj: the `.Thing` on which the attribute is accessed. @@ -813,8 +801,8 @@ def __set__(self, obj: Thing, value: Value) -> None: @overload # use as a decorator @setting def setting( - getter: Callable[[Any], Value], -) -> FunctionalSetting[Value]: ... + getter: Callable[[Owner], Value], +) -> FunctionalSetting[Owner, Value]: ... @overload # use as `field: int = setting(default=0)`` @@ -828,13 +816,13 @@ def setting( def setting( - getter: ValueGetter | EllipsisType = ..., + getter: Callable[[Owner], Value] | EllipsisType = ..., *, default: Value | EllipsisType = ..., - default_factory: ValueFactory | None = None, + default_factory: Callable[[], Value] | None = None, readonly: bool = False, **constraints: Any, -) -> FunctionalSetting[Value] | Value: +) -> FunctionalSetting[Owner, Value] | Value: r"""Define a Setting on a `.Thing`\ . A setting is a property that is saved to disk. @@ -920,7 +908,7 @@ def setting( ) -class BaseSetting(BaseProperty[Value], Generic[Value]): +class BaseSetting(BaseProperty[Owner, Value], Generic[Owner, Value]): r"""A base class for settings. This is a subclass of `.BaseProperty` that is used to define settings. @@ -928,7 +916,7 @@ class BaseSetting(BaseProperty[Value], Generic[Value]): two concrete implementations: `.DataSetting` and `.FunctionalSetting`\ . """ - def set_without_emit(self, obj: Thing, value: Value) -> None: + def set_without_emit(self, obj: Owner, value: Value) -> None: """Set the setting's value without emitting an event. This is used to set the setting's value without notifying observers. @@ -943,7 +931,9 @@ def set_without_emit(self, obj: Thing, value: Value) -> None: raise NotImplementedError("This method should be implemented in subclasses.") -class DataSetting(DataProperty[Value], BaseSetting[Value], Generic[Value]): +class DataSetting( + DataProperty[Owner, Value], BaseSetting[Owner, Value], Generic[Owner, Value] +): """A `.DataProperty` that persists on disk. A setting can be accessed via the HTTP API and is persistent between sessions. @@ -961,7 +951,7 @@ class DataSetting(DataProperty[Value], BaseSetting[Value], Generic[Value]): """ def __set__( - self, obj: Thing, value: Value, emit_changed_event: bool = True + self, obj: Owner, value: Value, emit_changed_event: bool = True ) -> None: """Set the setting's value. @@ -974,7 +964,7 @@ def __set__( super().__set__(obj, value, emit_changed_event) obj.save_settings() - def set_without_emit(self, obj: Thing, value: Value) -> None: + def set_without_emit(self, obj: Owner, value: Value) -> None: """Set the property's value, but do not emit event to notify the server. This function is not expected to be used externally. It is called during @@ -987,7 +977,9 @@ def set_without_emit(self, obj: Thing, value: Value) -> None: super().__set__(obj, value, emit_changed_event=False) -class FunctionalSetting(FunctionalProperty[Value], BaseSetting[Value], Generic[Value]): +class FunctionalSetting( + FunctionalProperty[Owner, Value], BaseSetting[Owner, Value], Generic[Owner, Value] +): """A `.FunctionalProperty` that persists on disk. A setting can be accessed via the HTTP API and is persistent between sessions. @@ -1005,7 +997,7 @@ class FunctionalSetting(FunctionalProperty[Value], BaseSetting[Value], Generic[V getter and a setter function. """ - def __set__(self, obj: Thing, value: Value) -> None: + def __set__(self, obj: Owner, value: Value) -> None: """Set the setting's value. This will cause the settings to be saved to disk. @@ -1016,7 +1008,7 @@ def __set__(self, obj: Thing, value: Value) -> None: super().__set__(obj, value) obj.save_settings() - def set_without_emit(self, obj: Thing, value: Value) -> None: + def set_without_emit(self, obj: Owner, value: Value) -> None: """Set the property's value, but do not emit event to notify the server. This function is not expected to be used externally. It is called during diff --git a/src/labthings_fastapi/thing_slots.py b/src/labthings_fastapi/thing_slots.py index 5d953fa5..5c5ea745 100644 --- a/src/labthings_fastapi/thing_slots.py +++ b/src/labthings_fastapi/thing_slots.py @@ -59,7 +59,9 @@ def say_hello(self) -> str: ) -class ThingSlot(Generic[ConnectedThings], FieldTypedBaseDescriptor[ConnectedThings]): +class ThingSlot( + Generic[ConnectedThings], FieldTypedBaseDescriptor["Thing", ConnectedThings] +): r"""Descriptor that instructs the server to supply other Things. A `.ThingSlot` provides either one or several diff --git a/tests/test_base_descriptor.py b/tests/test_base_descriptor.py index 4a11b6ed..ed69985e 100644 --- a/tests/test_base_descriptor.py +++ b/tests/test_base_descriptor.py @@ -9,9 +9,10 @@ ) from .utilities import raises_or_is_caused_by from labthings_fastapi.exceptions import MissingTypeError, InconsistentTypeError +import labthings_fastapi as lt -class MockProperty(BaseDescriptor[str]): +class MockProperty(BaseDescriptor[lt.Thing, str]): """A mock property class.""" # The line below isn't defined on a `Thing`, so mypy @@ -299,10 +300,10 @@ class FieldTypedExample: """An example with field-typed descriptors.""" int_or_str_prop: int | str = FieldTypedBaseDescriptor() - int_or_str_subscript = FieldTypedBaseDescriptor[int | str]() + int_or_str_subscript = FieldTypedBaseDescriptor[lt.Thing, int | str]() int_or_str_stringified: "int | str" = FieldTypedBaseDescriptor() customprop: CustomType = FieldTypedBaseDescriptor() - customprop_subscript = FieldTypedBaseDescriptor[CustomType]() + customprop_subscript = FieldTypedBaseDescriptor[lt.Thing, CustomType]() futureprop: "FutureType" = FieldTypedBaseDescriptor() @@ -408,7 +409,7 @@ class Example3: with raises_or_is_caused_by(MissingTypeError) as excinfo: class Example4: - field6 = FieldTypedBaseDescriptor["str"]() + field6 = FieldTypedBaseDescriptor[lt.Thing, "str"]() msg = str(excinfo.value) assert "forward reference" in msg @@ -421,7 +422,7 @@ def test_mismatched_types(): with raises_or_is_caused_by(InconsistentTypeError): class Example3: - field: int = FieldTypedBaseDescriptor[str]() + field: int = FieldTypedBaseDescriptor[lt.Thing, str]() def test_double_specified_types(): @@ -432,7 +433,7 @@ def test_double_specified_types(): """ class Example4: - field: int | None = FieldTypedBaseDescriptor[int | None]() + field: int | None = FieldTypedBaseDescriptor[lt.Thing, int | None]() assert Example4.field.value_type == int | None @@ -448,4 +449,4 @@ def test_stringified_vs_unstringified_mismatch(): with raises_or_is_caused_by(InconsistentTypeError): class Example5: - field: "int" = FieldTypedBaseDescriptor[int]() + field: "int" = FieldTypedBaseDescriptor[lt.Thing, int]() diff --git a/typing_tests/thing_properties.py b/typing_tests/thing_properties.py index ee9a421a..31a10b3a 100644 --- a/typing_tests/thing_properties.py +++ b/typing_tests/thing_properties.py @@ -66,7 +66,7 @@ def strprop(self: typing.Any) -> str: return "foo" -assert_type(strprop, FunctionalProperty[str]) +assert_type(strprop, FunctionalProperty[typing.Any, str]) class TestPropertyDefaultsMatch(lt.Thing): @@ -138,34 +138,38 @@ class TestExplicitDescriptor(lt.Thing): the underlying class as well. """ - intprop1 = lt.DataProperty[int](default=0) + intprop1 = lt.DataProperty["TestExplicitDescriptor", int](default=0) """A DataProperty that should not cause mypy errors.""" - intprop2 = lt.DataProperty[int](default_factory=int_factory) + intprop2 = lt.DataProperty["TestExplicitDescriptor", int]( + default_factory=int_factory + ) """The factory matches the type hint, so this should be OK.""" - intprop3 = lt.DataProperty[int](default_factory=optional_int_factory) - """Uses a factory function that doesn't match the type hint. - - This ought to cause mypy to throw an error, as the factory function can - return None, but at time of writing this doesn't happen. - - This error is caught correctly when called via `lt.property`. - """ + intprop3 = lt.DataProperty["TestExplicitDescriptor", int]( + default_factory=optional_int_factory + ) # type: ignore[arg-type] + """Uses a factory function that doesn't match the type hint.""" - intprop4 = lt.DataProperty[int](default="foo") # type: ignore[call-overload] + intprop4 = lt.DataProperty["TestExplicitDescriptor", int](default="foo") # type: ignore[call-overload] """This property should cause an error, as the default is a string.""" - intprop5 = lt.DataProperty[int]() # type: ignore[call-overload] + intprop5 = lt.DataProperty["TestExplicitDescriptor", int]() # type: ignore[call-overload] """This property should cause mypy to throw an error, as it has no default.""" - optionalintprop1 = lt.DataProperty[int | None](default=None) + optionalintprop1 = lt.DataProperty["TestExplicitDescriptor", int | None]( + default=None + ) """A DataProperty that should not cause mypy errors.""" - optionalintprop2 = lt.DataProperty[int | None](default_factory=optional_int_factory) + optionalintprop2 = lt.DataProperty["TestExplicitDescriptor", int | None]( + default_factory=optional_int_factory + ) """This property should not cause mypy errors: the factory matches the type hint.""" - optionalintprop3 = lt.DataProperty[int | None](default_factory=int_factory) + optionalintprop3 = lt.DataProperty["TestExplicitDescriptor", int | None]( + default_factory=int_factory + ) """Uses a factory function that is a subset of the type hint.""" @@ -181,19 +185,21 @@ class TestExplicitDescriptor(lt.Thing): assert_type(test_explicit_descriptor.optionalintprop3, int | None) # Check class attributes are typed correctly. -assert_type(TestExplicitDescriptor.intprop1, lt.DataProperty[int]) -assert_type(TestExplicitDescriptor.intprop2, lt.DataProperty[int]) -assert_type(TestExplicitDescriptor.intprop3, lt.DataProperty[int]) +cls: typing.TypeAlias = TestExplicitDescriptor +assert_type(cls.intprop1, lt.DataProperty[cls, int]) +assert_type(cls.intprop2, lt.DataProperty[cls, int]) +assert_type(cls.intprop3, lt.DataProperty[cls, int]) -assert_type(TestExplicitDescriptor.optionalintprop1, lt.DataProperty[int | None]) -assert_type(TestExplicitDescriptor.optionalintprop2, lt.DataProperty[int | None]) -assert_type(TestExplicitDescriptor.optionalintprop3, lt.DataProperty[int | None]) +assert_type(cls.optionalintprop1, lt.DataProperty[cls, int | None]) +assert_type(cls.optionalintprop2, lt.DataProperty[cls, int | None]) +assert_type(cls.optionalintprop3, lt.DataProperty[cls, int | None]) +Owner = typing.TypeVar("Owner", bound=lt.Thing) Val = typing.TypeVar("Val") -def f_property(getter: typing.Callable[..., Val]) -> FunctionalProperty[Val]: +def f_property(getter: typing.Callable[[Owner], Val]) -> FunctionalProperty[Owner, Val]: """A function that returns a FunctionalProperty with a getter.""" return FunctionalProperty(getter) @@ -231,7 +237,7 @@ def intprop3(self) -> int: """This getter is fine, but the setter should fail type checking.""" return 0 - @intprop3.setter + @intprop3.setter # type: ignore[arg-type] def _set_intprop3(self, value: str) -> None: """Setter for intprop3. It's got the wrong type so should fail.""" pass @@ -267,15 +273,16 @@ def strprop(self, val: str) -> None: pass -assert_type(TestFunctionalProperty.intprop1, FunctionalProperty[int]) -assert_type(TestFunctionalProperty.intprop2, FunctionalProperty[int]) -assert_type(TestFunctionalProperty.intprop3, FunctionalProperty[int]) -assert_type(TestFunctionalProperty.fprop, FunctionalProperty[int]) +cls1: typing.TypeAlias = TestFunctionalProperty +assert_type(cls1.intprop1, FunctionalProperty[cls1, int]) +assert_type(cls1.intprop2, FunctionalProperty[cls1, int]) +assert_type(cls1.intprop3, FunctionalProperty[cls1, int]) +assert_type(cls1.fprop, FunctionalProperty[cls1, int]) # Don't check ``strprop`` because it caused an error and thus will # not have the right type, even though the error is ignored. -test_functional_property = create_thing_without_server(TestFunctionalProperty) -assert_type(test_functional_property, TestFunctionalProperty) +test_functional_property = create_thing_without_server(cls1) +assert_type(test_functional_property, cls1) assert_type(test_functional_property.intprop1, int) assert_type(test_functional_property.intprop2, int) assert_type(test_functional_property.intprop3, int) From 2bad560caa3b34d7ebad616b4e62702b63dcd274 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 28 Jan 2026 23:33:40 +0000 Subject: [PATCH 02/14] Move an ignore to the right line --- typing_tests/thing_properties.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/typing_tests/thing_properties.py b/typing_tests/thing_properties.py index 31a10b3a..0a5962ad 100644 --- a/typing_tests/thing_properties.py +++ b/typing_tests/thing_properties.py @@ -147,8 +147,8 @@ class TestExplicitDescriptor(lt.Thing): """The factory matches the type hint, so this should be OK.""" intprop3 = lt.DataProperty["TestExplicitDescriptor", int]( - default_factory=optional_int_factory - ) # type: ignore[arg-type] + default_factory=optional_int_factory, # type: ignore[arg-type] + ) """Uses a factory function that doesn't match the type hint.""" intprop4 = lt.DataProperty["TestExplicitDescriptor", int](default="foo") # type: ignore[call-overload] From 96183a5503da3cc8c99f26b2f43416f0cc222386 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 28 Jan 2026 23:34:44 +0000 Subject: [PATCH 03/14] Improve typing of Actions This uses the new generic parameter for the descriptor's Owner to correctly type the function's `self` parameter, among other things. --- src/labthings_fastapi/actions.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/labthings_fastapi/actions.py b/src/labthings_fastapi/actions.py index 7e2b3015..f75325e7 100644 --- a/src/labthings_fastapi/actions.py +++ b/src/labthings_fastapi/actions.py @@ -445,10 +445,7 @@ def list_invocations( i.response(request=request) for i in self.invocations if thing is None or i.thing == thing - if action is None or i.action == action # type: ignore[call-overload] - # i.action evaluates to an ActionDescriptor, which confuses mypy - it - # thinks we are calling ActionDescriptor.__get__ but this isn't ever - # called. + if action is None or i.action == action ] def expire_invocations(self) -> None: @@ -691,7 +688,7 @@ def __init__( ) self.invocation_model.__name__ = f"{name}_invocation" - def __set_name__(self, owner: type[Thing], name: str) -> None: + def __set_name__(self, owner: type[OwnerT], name: str) -> None: """Ensure the action name matches the function name. It's assumed in a few places that the function name and the @@ -709,7 +706,7 @@ def __set_name__(self, owner: type[Thing], name: str) -> None: f"'{self.func.__name__}'", ) - def instance_get(self, obj: Thing) -> Callable[ActionParams, ActionReturn]: + def instance_get(self, obj: OwnerT) -> Callable[ActionParams, ActionReturn]: """Return the function, bound to an object as for a normal method. This currently doesn't validate the arguments, though it may do so @@ -721,10 +718,7 @@ def instance_get(self, obj: Thing) -> Callable[ActionParams, ActionReturn]: descriptor. :return: the action function, bound to ``obj``. """ - # `obj` should be of type `OwnerT`, but `BaseDescriptor` currently - # isn't generic in the type of the owning Thing, so we can't express - # that here. - return partial(self.func, obj) # type: ignore[arg-type] + return partial(self.func, obj) def _observers_set(self, obj: Thing) -> WeakSet: """Return a set used to notify changes. From a1fd2f16266ff39f4bca881d6dbb785ff2ac19a5 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 28 Jan 2026 23:40:15 +0000 Subject: [PATCH 04/14] Fix subscript-typing for BaseDescriptor This was a one-character fix: we now need the second argument when inspecting `__orig_class__` to get the value type. This is only used when a descriptor is created using subscript syntax, e.g. `prop = Property[Thing, int](default=0)`. Happily, it was picked up in tests, and is now passing. --- 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 6012d550..eeb6e838 100644 --- a/src/labthings_fastapi/base_descriptor.py +++ b/src/labthings_fastapi/base_descriptor.py @@ -437,7 +437,7 @@ class MyThing(Thing): # __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] + self._type = typing.get_args(self.__orig_class__)[1] if isinstance(self._type, typing.ForwardRef): raise MissingTypeError( f"{owner}.{name} is a subscripted descriptor, where the " From 0baff38d00fbcc3ba493a927d12e843211f715db Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 28 Jan 2026 23:46:21 +0000 Subject: [PATCH 05/14] Fix typing of __set_name__ This now correctly uses the `Owner` type variable in `BaseDescriptor`. `mypy` picked up an inconsistency between this and `ActionDescriptor`, but oddly not on Python 3.10. --- src/labthings_fastapi/base_descriptor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/labthings_fastapi/base_descriptor.py b/src/labthings_fastapi/base_descriptor.py index eeb6e838..f229c1d2 100644 --- a/src/labthings_fastapi/base_descriptor.py +++ b/src/labthings_fastapi/base_descriptor.py @@ -190,7 +190,7 @@ def __init__(self) -> None: self._set_name_called: bool = False self._owner_name: str = "" - def __set_name__(self, owner: type[Thing], name: str) -> None: + def __set_name__(self, owner: type[Owner], name: str) -> None: r"""Take note of the name to which the descriptor is assigned. This is called when the descriptor is assigned to an attribute of a class. @@ -385,7 +385,7 @@ def __init__(self) -> None: # the object on which they are defined, to provide the context for the # evaluation. - def __set_name__(self, owner: type[Thing], name: str) -> None: + def __set_name__(self, owner: type[Owner], name: str) -> None: r"""Take note of the name and type. This function is where we determine the type of the property. It may From 8eb31cd3faf2f40c0a6477088359418d51d4e96d Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 28 Jan 2026 23:25:17 +0000 Subject: [PATCH 06/14] 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 38f5920081f251c0fc83c6c3fdbfa6c77d57878d Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Sat, 31 Jan 2026 09:24:51 +0000 Subject: [PATCH 07/14] 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 e520d0ebdbe35ff315ee61e20fb03c76256a1916 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Sun, 1 Feb 2026 00:15:02 +0100 Subject: [PATCH 08/14] 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 989d38ff859a7637ce39f7a2d0c6d50b7b898777 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Sun, 1 Feb 2026 22:30:24 +0100 Subject: [PATCH 09/14] 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 c948c9d4..444ff9b3 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 = ..., @@ -488,6 +495,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. @@ -799,6 +812,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], @@ -930,6 +971,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] @@ -1021,3 +1066,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 fb23dee4e1becdf600bec716ee34c0700513d2c7 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Feb 2026 01:01:05 +0100 Subject: [PATCH 10/14] 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 9b81576e982d3be0573af281f4119683aafbfd47 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Feb 2026 01:03:44 +0100 Subject: [PATCH 11/14] 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 444ff9b3..f36883fa 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 ( @@ -498,7 +498,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()) @@ -824,10 +829,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. @@ -972,7 +1032,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()) @@ -1073,6 +1138,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. @@ -1085,14 +1158,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 baf2fdb9c97f73efd0a6ea4d3efbe33e68830f70 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Feb 2026 01:20:41 +0100 Subject: [PATCH 12/14] 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 f36883fa..bd02e770 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -881,13 +881,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. @@ -1146,6 +1139,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 83b142252b1e9cf41ae25bf2f683033ed6b35ac4 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Feb 2026 01:46:06 +0100 Subject: [PATCH 13/14] Add `.Thing.actions` This is added, largely for completeness so it's consistent between actions, properties and settings. --- src/labthings_fastapi/actions.py | 63 +++++++++++++++++++++++++++++++- src/labthings_fastapi/thing.py | 11 +++++- tests/test_actions.py | 50 +++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) diff --git a/src/labthings_fastapi/actions.py b/src/labthings_fastapi/actions.py index f75325e7..be4dfb44 100644 --- a/src/labthings_fastapi/actions.py +++ b/src/labthings_fastapi/actions.py @@ -39,7 +39,11 @@ from fastapi import FastAPI, HTTPException, Request, Body, BackgroundTasks from pydantic import BaseModel, create_model -from .base_descriptor import BaseDescriptor +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 @@ -622,6 +626,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], @@ -914,6 +966,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 ac376f3fcf45cd6c1aef3b0d08e04950bf7e683e Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Feb 2026 22:25:53 +0000 Subject: [PATCH 14/14] WIP: overhaul of websockets This has rather run away with me and will need a lot of cutting-down. It attempts to: * Centralise event pub/sub handling in a `MessageBroker` * Align with the webthing subprotocol * Introduce models for more consistent and robust serialisation/deserialisation of websocket messages --- src/labthings_fastapi/actions.py | 34 ++-- src/labthings_fastapi/events.py | 93 +++++++++++ src/labthings_fastapi/properties.py | 145 +++++++++++------- src/labthings_fastapi/server/__init__.py | 2 + src/labthings_fastapi/testing.py | 8 + src/labthings_fastapi/thing.py | 37 +---- .../thing_server_interface.py | 38 +++++ src/labthings_fastapi/websockets.py | 99 ++++++------ src/labthings_fastapi/webthing_subprotocol.py | 93 +++++++++++ tests/test_property.py | 10 ++ tests/test_websocket.py | 74 +++++---- 11 files changed, 449 insertions(+), 184 deletions(-) create mode 100644 src/labthings_fastapi/events.py create mode 100644 src/labthings_fastapi/webthing_subprotocol.py diff --git a/src/labthings_fastapi/actions.py b/src/labthings_fastapi/actions.py index be4dfb44..853bba4d 100644 --- a/src/labthings_fastapi/actions.py +++ b/src/labthings_fastapi/actions.py @@ -34,10 +34,10 @@ TypeVar, overload, ) -from weakref import WeakSet import weakref from fastapi import FastAPI, HTTPException, Request, Body, BackgroundTasks from pydantic import BaseModel, create_model +from anyio.abc import ObjectSendStream from .base_descriptor import ( BaseDescriptor, @@ -48,10 +48,12 @@ from .utilities import model_to_dict, wrap_plain_types_in_rootmodel from .invocations import InvocationModel, InvocationStatus, LogRecordModel from .dependencies.invocation import NonWarningInvocationID +from .events import Message from .exceptions import ( InvocationCancelledError, InvocationError, NoBlobManagerError, + NotBoundToInstanceError, NotConnectedToServerError, ) from .outputs.blob import BlobIOContextDep, blobdata_to_url_ctx @@ -65,7 +67,6 @@ ) from .thing_description import type_to_dataschema from .thing_description._model import ActionAffordance, ActionOp, Form, LinkElement -from .utilities import labthings_data if TYPE_CHECKING: @@ -664,6 +665,18 @@ def func(self) -> Callable[Concatenate[OwnerT, ActionParams], ActionReturn]: """The function that runs the action.""" return self.get_descriptor().func + def observe(self, stream: ObjectSendStream[Message]) -> None: + """Observe changes to this property. + + Changes to this property will be sent to the supplied stream. + + :param stream: The stream to which updated values should be sent. + """ + if self.owning_object is None: + msg = "Can't observe action status from an unbound ActionInfo." + raise NotBoundToInstanceError(msg) + self.owning_object._thing_server_interface.subscribe(self.name, stream) + class ActionCollection( DescriptorInfoCollection[OwnerT, ActionInfo], @@ -772,23 +785,6 @@ def instance_get(self, obj: OwnerT) -> Callable[ActionParams, ActionReturn]: """ return partial(self.func, obj) - def _observers_set(self, obj: Thing) -> WeakSet: - """Return a set used to notify changes. - - Note that we need to supply the `.Thing` we are looking at, as in - general there may be more than one object of the same type, and - descriptor instances are shared between all instances of their class. - - :param obj: The `.Thing` on which the action is being observed. - - :return: a weak set of callables to notify on changes to the action. - This is used by websocket endpoints. - """ - ld = labthings_data(obj) - if self.name not in ld.action_observers: - ld.action_observers[self.name] = WeakSet() - return ld.action_observers[self.name] - def emit_changed_event(self, obj: Thing, status: str) -> None: """Notify subscribers that the action status has changed. diff --git a/src/labthings_fastapi/events.py b/src/labthings_fastapi/events.py new file mode 100644 index 00000000..258435be --- /dev/null +++ b/src/labthings_fastapi/events.py @@ -0,0 +1,93 @@ +"""Handle pub-sub style events. + +Both properties and actions can emit events that may be observed. This module handles +all the pub-sub messaging in LabThings. + +This module defines models for the messages sent over websockets, which are aligned with +the ``webthingprotocol`` subprotocol as set out in the `community group draft report`_. + +.. _community group draft report: https://w3c.github.io/web-thing-protocol/ +""" + +from dataclasses import dataclass +from typing import Any, Literal +from weakref import WeakSet + +from anyio.abc import ObjectSendStream + + +@dataclass +class Message: + """A pub-sub event message. + + This is the message that is sent when a property or action generates + an event. + + :param thing: The name of the Thing generating the event. + :param affordance: The name of the affordance generating the event. + :param message: The message to send. + """ + + thing: str + affordance: str + message_type: Literal["property", "action", "event"] + payload: Any + + +class MessageBroker: + """A class that relays pub/sub messages.""" + + def __init__(self) -> None: + """Initialise the message broker.""" + # Note that we use a weak set below, so that when a websocket disconnects, + # its stream is removed automatically. + self._subscriptions: dict[ + str, dict[str, WeakSet[ObjectSendStream[Message]]] + ] = {} + + def subscribe( + self, thing: str, affordance: str, stream: ObjectSendStream[Message] + ) -> None: + """Subscribe to messages from a particular affordance. + + Note that this method is not async - it just registers the stream and so + can be run from any thread. + + :param thing: The name of the `.Thing` being subscribed to. + :param affordance: The name of the affordance being subscribed to. + :param stream: A stream to send the messages to. + """ + if thing not in self._subscriptions: + self._subscriptions[thing] = {} + if affordance not in self._subscriptions[thing]: + self._subscriptions[thing][affordance] = WeakSet() + self._subscriptions[thing][affordance].add(stream) + + def unsubscribe( + self, thing: str, affordance: str, stream: ObjectSendStream[Message] + ) -> None: + """Unsubscribe a stream from messages from a particular affordance. + + :param thing: The name of the `.Thing` being unsubscribed from. + :param affordance: The name of the affordance being unsubscribed from. + :param stream: The stream to unsubscribe. + :raises KeyError: if there is no such subscription. + """ + try: + self._subscriptions[thing][affordance].discard(stream) + except KeyError as e: + raise e + + async def publish(self, message: Message) -> None: + """Publish a message. + + :param thing: the name of the `.Thing` we are publishing about. + :param affordance: the name of the affordance generating the message. + :param message: the message to send. + """ + try: + subscriptions = self._subscriptions[message.thing][message.affordance] + except KeyError: + return # No subscribers for this thing. + for stream in subscriptions: + await stream.send(message) diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index bd02e770..8f213841 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -59,10 +59,10 @@ class attribute. Documentation is in strings immediately following the TYPE_CHECKING, ) from typing_extensions import Self -from weakref import WeakSet from fastapi import Body, FastAPI from pydantic import BaseModel, ConfigDict, RootModel, create_model +from anyio.abc import ObjectSendStream from .thing_description import type_to_dataschema from .thing_description._model import ( @@ -71,15 +71,18 @@ class attribute. Documentation is in strings immediately following the PropertyAffordance, PropertyOp, ) -from .utilities import labthings_data, wrap_plain_types_in_rootmodel +from .utilities import wrap_plain_types_in_rootmodel from .utilities.introspection import return_type from .base_descriptor import ( DescriptorInfoCollection, FieldTypedBaseDescriptor, FieldTypedBaseDescriptorInfo, ) +from .events import Message from .exceptions import ( + NotBoundToInstanceError, NotConnectedToServerError, + PropertyNotObservableError, ReadOnlyPropertyError, MissingTypeError, UnsupportedConstraintError, @@ -391,6 +394,39 @@ def model(self) -> type[BaseModel]: ) return self._model + def value_to_model(self, value: Value) -> BaseModel: + """Convert a property value to its Pydantic model representation. + + :param value: the property value. + :return: the property value wrapped in its Pydantic model. + """ + 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) + + @builtins.property + def observable(self) -> bool: + """Whether this property is observable. + + :raises NotImplementedError: as this must be implemented by subclasses. + """ + raise NotImplementedError("observable must be implemented by subclasses.") + def add_to_fastapi(self, app: FastAPI, thing: Owner) -> None: """Add this action to a FastAPI app, bound to a particular Thing. @@ -606,24 +642,9 @@ def __set__( """ obj.__dict__[self.name] = value if emit_changed_event: - self.emit_changed_event(obj, value) - - def _observers_set(self, obj: Thing) -> WeakSet: - """Return the observers of this property. - - Each observer in this set will be notified when the property is changed. - See ``.DataProperty.emit_changed_event`` + self.publish_change(obj, self.value_to_model(value)) - :param obj: the `.Thing` to which we are attached. - - :return: the set of observers corresponding to ``obj``. - """ - ld = labthings_data(obj) - if self.name not in ld.property_observers: - ld.property_observers[self.name] = WeakSet() - return ld.property_observers[self.name] - - def emit_changed_event(self, obj: Thing, value: Value) -> None: + def publish_change(self, obj: Thing, value: BaseModel) -> None: """Notify subscribers that the property has changed. This function is run when properties are updated. It must be run from @@ -632,31 +653,24 @@ def emit_changed_event(self, obj: Thing, value: Value) -> None: thread as it is communicating with the event loop via an `asyncio` blocking portal and can cause deadlock if run in the event loop. - This method will raise a `.ServerNotRunningError` if the event loop is not - running, and should only be called after the server has started. + This method will silently do nothing if the event loop is not running. :param obj: the `.Thing` to which we are attached. :param value: the new property value, to be sent to observers. """ - obj._thing_server_interface.start_async_task_soon( - self.emit_changed_event_async, - obj, - value, + obj._thing_server_interface.publish( + Message( + thing=obj.name, + affordance=self.name, + message_type="property", + payload=value, + ) ) - async def emit_changed_event_async(self, obj: Thing, value: Value) -> None: - """Notify subscribers that the property has changed. - - This function may only be run in the `anyio` event loop. See - `.DataProperty.emit_changed_event`. - - :param obj: the `.Thing` to which we are attached. - :param value: the new property value, to be sent to observers. - """ - for observer in self._observers_set(obj): - await observer.send( - {"messageType": "propertyStatus", "data": {self._name: value}} - ) + @builtins.property + def observable(self) -> bool: + """Whether this property is observable. Always True for DataProperty.""" + return True class FunctionalProperty(BaseProperty[Owner, Value], Generic[Owner, Value]): @@ -816,6 +830,11 @@ def __set__(self, obj: Owner, value: Value) -> None: raise ReadOnlyPropertyError(f"Property {self.name} of {obj} has no setter.") self.fset(obj, value) + @builtins.property + def observable(self) -> bool: + """Whether this property is observable. Always False for FunctionalProperty.""" + return False + class PropertyInfo( FieldTypedBaseDescriptorInfo[BasePropertyT, Owner, Value], @@ -839,25 +858,7 @@ def model_instance(self) -> BaseModel: # noqa: DOC201 :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) + return self.get_descriptor().value_to_model(self.get()) def model_to_value(self, value: BaseModel) -> Value: r"""Convert a model to a value for this property. @@ -881,6 +882,36 @@ 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) + @builtins.property + def observable(self) -> bool: # noqa: DOC201 + """Whether this property is observable. + + Observable properties may be observed for changes using the + `.PropertyInfo.observe` method. + + :return: `True` if the property is observable, `False` otherwise. + """ + return isinstance(self.get_descriptor(), DataProperty) + + def observe(self, stream: ObjectSendStream[Message]) -> None: + """Observe changes to this property. + + Changes to this property will be sent to the supplied stream. + + :param stream: The stream to which updated values should be sent. + + :raises NotBoundToInstanceError: if this `.PropertyInfo` is not + bound to a `.Thing` instance. + :raises PropertyNotObservableError: if this property is not observable. + """ + if self.owning_object is None: + msg = "Can't observe property changes from an unbound PropertyInfo." + raise NotBoundToInstanceError(msg) + if not self.observable: + msg = f"Property {self.name} is not observable." + raise PropertyNotObservableError(msg) + self.owning_object._thing_server_interface.subscribe(self.name, stream) + class PropertyCollection(DescriptorInfoCollection[Owner, PropertyInfo], Generic[Owner]): """Access to metadata on all the properties of a `.Thing` instance or subclass. diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index eb7434ff..78d0c95d 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -27,6 +27,7 @@ from ..thing_server_interface import ThingServerInterface from ..thing_description._model import ThingDescription from ..dependencies.thing_server import _thing_servers # noqa: F401 +from ..events import MessageBroker from .config_model import ( ThingsConfig, ThingServerConfig, @@ -92,6 +93,7 @@ def __init__( blob_data_manager.attach_to_app(self.app) self._add_things_view_to_app() self.blocking_portal: Optional[BlockingPortal] = None + self.message_broker = MessageBroker() self.startup_status: dict[str, str | dict] = {"things": {}} global _thing_servers # noqa: F824 _thing_servers.add(self) diff --git a/src/labthings_fastapi/testing.py b/src/labthings_fastapi/testing.py index 02f51ca4..a8600696 100644 --- a/src/labthings_fastapi/testing.py +++ b/src/labthings_fastapi/testing.py @@ -4,6 +4,7 @@ from collections.abc import Iterator from concurrent.futures import Future from contextlib import contextmanager +from functools import cached_property from typing import ( TYPE_CHECKING, Any, @@ -17,6 +18,8 @@ from tempfile import TemporaryDirectory from unittest.mock import Mock +from labthings_fastapi.events import MessageBroker + from .utilities import class_attributes from .thing_slots import ThingSlot from .thing_server_interface import ThingServerInterface @@ -121,6 +124,11 @@ def _action_manager(self) -> ActionManager: """ raise NotImplementedError("MockThingServerInterface has no ActionManager.") + @cached_property + def _message_broker(self) -> MessageBroker: + """A message broker.""" + return MessageBroker() + ThingSubclass = TypeVar("ThingSubclass", bound="Thing") diff --git a/src/labthings_fastapi/thing.py b/src/labthings_fastapi/thing.py index 86b1f6f5..4792ae0b 100644 --- a/src/labthings_fastapi/thing.py +++ b/src/labthings_fastapi/thing.py @@ -14,7 +14,6 @@ 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 @@ -22,18 +21,15 @@ from .logs import THING_LOGGER from .properties import ( - BaseProperty, - DataProperty, PropertyCollection, SettingCollection, ) -from .actions import ActionCollection, ActionDescriptor +from .actions import ActionCollection from .thing_description._model import ThingDescription, NoSecurityScheme from .utilities import class_attributes from .thing_description import validation from .utilities.introspection import get_summary, get_docstring from .websockets import websocket_endpoint -from .exceptions import PropertyNotObservableError from .thing_server_interface import ThingServerInterface @@ -180,6 +176,7 @@ def attach_to_server(self, server: ThingServer) -> None: description=get_docstring(self.thing_description), response_model_exclude_none=True, response_model_by_alias=True, + name=f"things.{self.name}", ) def thing_description(request: Request) -> ThingDescription: return self.thing_description(base=str(request.base_url)) @@ -357,33 +354,3 @@ def thing_description_dict( td: ThingDescription = self.thing_description(path=path, base=base) td_dict: dict = td.model_dump(exclude_none=True, by_alias=True) return jsonable_encoder(td_dict) - - def observe_property(self, property_name: str, stream: ObjectSendStream) -> None: - """Register a stream to receive property change notifications. - - :param property_name: the property to register for. - :param stream: the stream used to send events. - - :raise KeyError: if the requested name is not defined on this Thing. - :raise PropertyNotObservableError: if the property is not observable. - """ - prop = getattr(self.__class__, property_name, None) - if not isinstance(prop, BaseProperty): - raise KeyError(f"{property_name} is not a LabThings Property") - if not isinstance(prop, DataProperty): - raise PropertyNotObservableError(f"{property_name} is not observable.") - prop._observers_set(self).add(stream) - - def observe_action(self, action_name: str, stream: ObjectSendStream) -> None: - """Register a stream to receive action status change notifications. - - :param action_name: the action to register for. - :param stream: the stream used to send events. - - :raise KeyError: if the requested name is not defined on this Thing. - """ - action = getattr(self.__class__, action_name, None) - if not isinstance(action, ActionDescriptor): - raise KeyError(f"{action_name} is not an LabThings Action") - observers = action._observers_set(self) - observers.add(stream) diff --git a/src/labthings_fastapi/thing_server_interface.py b/src/labthings_fastapi/thing_server_interface.py index 4df8f556..1d52b16f 100644 --- a/src/labthings_fastapi/thing_server_interface.py +++ b/src/labthings_fastapi/thing_server_interface.py @@ -14,11 +14,14 @@ ) from weakref import ref, ReferenceType +from anyio.abc import ObjectSendStream + from .exceptions import ServerNotRunningError if TYPE_CHECKING: from .server import ThingServer from .actions import ActionManager + from .events import MessageBroker, Message Params = ParamSpec("Params") @@ -131,6 +134,33 @@ def call_async_task( raise ServerNotRunningError("Can't run async code without an event loop.") return portal.call(async_function, *args) + def publish(self, message: Message) -> None: + """Publish an event. + + Use the async event loop to notify websocket subscribers that something has + happened. + + Note that this function will do nothing if the event loop is not yet running. + + :param affordance: the name of the affordance publishing the event. + :param message: the message being published. + """ + try: + self.start_async_task_soon(self._message_broker.publish, message) + except ServerNotRunningError: + pass # If the server isn't running yet, we can't publish events. + + def subscribe(self, affordance: str, stream: ObjectSendStream[Message]) -> None: + """Subscribe to events from an affordance. + + Use the async event loop to register a stream to receive events + from a particular affordance on this Thing. + + :param affordance: the name of the affordance to subscribe to. + :param stream: the stream to which events should be sent. + """ + self._message_broker.subscribe(self.name, affordance, stream) + @property def settings_folder(self) -> str: """The path to a folder where persistent files may be saved.""" @@ -175,3 +205,11 @@ def _action_manager(self) -> ActionManager: This property may be removed in future, and is for internal use only. """ return self._get_server().action_manager + + @property + def _message_broker(self) -> MessageBroker: + """The message broker attached to the server. + + This property may be removed in the future, and is for internal use. + """ + return self._get_server().message_broker diff --git a/src/labthings_fastapi/websockets.py b/src/labthings_fastapi/websockets.py index a206a4f7..534b1a03 100644 --- a/src/labthings_fastapi/websockets.py +++ b/src/labthings_fastapi/websockets.py @@ -1,21 +1,14 @@ -"""Handle notification of events, property, and action status changes. +r"""Handle notification of events, property, and action status changes via web sockets. -There are several kinds of "event" in the WoT vocabulary, not all of which -are called Event, which is why this module is called `notifications`. -In all cases, these are events that happen on an exposed Thing, and -may need to be relayed to one or more listeners (currently via a -WebSocket connection, though long polling may also be an option in the -future). - -The aim at this stage (July 2023) is for a minimal working example that -enables property changes to be fed via a websocket. Events proper should -not be a big step thereafter. +This module implements a websocket endpoint for a `.Thing`\ . It follows the +subprotocol set out at https://w3c.github.io/web-thing-protocol/ though compliance +with this protocol is not yet verified. The W3C standard does not define a way for one websocket to handle multiple Things, so for now the websocket endpoint will be associated with a single Thing instance. This may change in the future. -(c) Richard Bowman July 2023, released under GNU-LGPL-3.0 +(c) Richard Bowman July 2023, released under MIT license """ from __future__ import annotations @@ -23,9 +16,12 @@ from anyio.abc import ObjectReceiveStream, ObjectSendStream import logging from fastapi import WebSocket, WebSocketDisconnect -from fastapi.encoders import jsonable_encoder from typing import TYPE_CHECKING, Literal + +from labthings_fastapi.middleware.url_for import URLFor from .exceptions import PropertyNotObservableError +from .events import Message +from . import webthing_subprotocol if TYPE_CHECKING: from .thing import Thing @@ -35,8 +31,11 @@ def observation_error_response( - name: str, affordance_type: Literal["action", "property"], exception: Exception -) -> dict[str, str | dict]: + thing: Thing, + affordance: str, + affordance_type: Literal["action", "property"], + exception: Exception, +) -> webthing_subprotocol.ObservationErrorResponse: r"""Generate a websocket error response for observing an action or property. When a websocket client asks to observe a property or action that either @@ -52,31 +51,39 @@ def observation_error_response( or `.PropertyNotObservableError`\ . """ if isinstance(exception, KeyError): - error = { - "status": "404", - "type": f"{WEBTHING_ERROR_URL}#not-found", - "title": "Not Found", - "detail": f"No {affordance_type} found with the name '{name}'.", - } + error = webthing_subprotocol.ProblemDetails( + status=404, + type=f"{WEBTHING_ERROR_URL}#not-found", + title="Not Found", + detail=f"No {affordance_type} found with the name '{affordance}'.", + ) elif isinstance(exception, PropertyNotObservableError): - error = { - "status": "403", - "type": f"{WEBTHING_ERROR_URL}#not-observable", - "title": "Not Observable", - "detail": f"Property '{name}' is not observable.", - } + error = webthing_subprotocol.ProblemDetails( + status=403, + type=f"{WEBTHING_ERROR_URL}#not-observable", + title="Not Observable", + detail=f"Property '{affordance}' is not observable.", + ) else: raise TypeError(f"Can't generate an error response for {exception}.") - return { - "messageType": "response", - "operation": f"observe{affordance_type}", - "name": name, - "error": error, - } + return webthing_subprotocol.ObservationErrorResponse( + thingID=URLFor(f"things.{thing.name}"), + messageType="response", + operation="observeaction" if affordance_type == "action" else "observeproperty", + name=affordance, + error=error, + ) + + +NOTIFICATION_MODELS = { + "property": webthing_subprotocol.ObservePropertyNotification, + "action": webthing_subprotocol.ObserveActionNotification, + "event": webthing_subprotocol.ObserveEventNotification, +} async def relay_notifications_to_websocket( - websocket: WebSocket, receive_stream: ObjectReceiveStream + websocket: WebSocket, receive_stream: ObjectReceiveStream[Message] ) -> None: """Relay objects from a stream to a websocket as JSON. @@ -90,11 +97,11 @@ async def relay_notifications_to_websocket( """ async with receive_stream: async for item in receive_stream: - await websocket.send_json(jsonable_encoder(item)) + await websocket.send_text(item.model_dump_json()) async def process_messages_from_websocket( - websocket: WebSocket, send_stream: ObjectSendStream, thing: Thing + websocket: WebSocket, send_stream: ObjectSendStream[Message], thing: Thing ) -> None: r"""Process messages received from a websocket. @@ -114,20 +121,26 @@ async def process_messages_from_websocket( except WebSocketDisconnect: await send_stream.aclose() return - if data["messageType"] == "addPropertyObservation": + if data["messageType"] == "request" and data["operation"] == "observeproperty": try: - for k in data["data"].keys(): - thing.observe_property(k, send_stream) + k = data.get("name", default="") # The empty string will raise KeyError + thing.properties[k].observe(send_stream) except (KeyError, PropertyNotObservableError) as e: logging.error(f"Got a bad websocket message: {data}, caused {e!r}.") await send_stream.send(observation_error_response(k, "property", e)) - if data["messageType"] == "addActionObservation": + if data["messageType"] == "request" and data["operation"] == "observeaction": try: - for k in data["data"].keys(): - thing.observe_action(k, send_stream) + k = data.get("name", default="") # The empty string will raise KeyError + thing.actions[k].observe(send_stream) except KeyError as e: logging.error(f"Got a bad websocket message: {data}, caused {e!r}.") await send_stream.send(observation_error_response(k, "action", e)) + else: + logging.error(f"Got an unknown websocket message: {data}.") + # Close the stream rather than ignoring bad messages. This will + # cause the websocket client to error out rather than hanging. + await send_stream.aclose() + return async def websocket_endpoint(thing: Thing, websocket: WebSocket) -> None: @@ -141,7 +154,7 @@ async def websocket_endpoint(thing: Thing, websocket: WebSocket) -> None: :param websocket: the web socket that has been created. """ await websocket.accept() - send_stream, receive_stream = create_memory_object_stream[dict]() + send_stream, receive_stream = create_memory_object_stream[Message]() async with create_task_group() as tg: tg.start_soon(relay_notifications_to_websocket, websocket, receive_stream) tg.start_soon(process_messages_from_websocket, websocket, send_stream, thing) diff --git a/src/labthings_fastapi/webthing_subprotocol.py b/src/labthings_fastapi/webthing_subprotocol.py new file mode 100644 index 00000000..9a34d06f --- /dev/null +++ b/src/labthings_fastapi/webthing_subprotocol.py @@ -0,0 +1,93 @@ +"""WebThing WebSocket subprotocol models. + +This module defines models for the messages sent over websockets, which are aligned with +the ``webthingprotocol`` subprotocol as set out in the `community group draft report`_. + +.. _community group draft report: https://w3c.github.io/web-thing-protocol/ + +(c) Richard Bowman July 2023, released under MIT license +""" + +from datetime import datetime +from uuid import uuid4, UUID +from typing import Any, Literal +from pydantic import BaseModel, Field + +from labthings_fastapi.middleware.url_for import URLFor + + +class WebsocketMessage(BaseModel): + """A base model for all websocket messages.""" + + thingID: str | URLFor + messageID: UUID = Field(default_factory=uuid4) + messageType: Literal["request", "response", "notification"] + operation: str + correlationID: UUID | None = None + + +class ObservePropertyMessage(WebsocketMessage): + """A base model for messages related to observing a property.""" + + name: str + operation: Literal["observeproperty"] = "observeproperty" + + +class ObservePropertyNotification(ObservePropertyMessage): + """A model for property change messages.""" + + messageType: Literal["notification"] = "notification" + value: Any + + +class ObserveActionMessage(WebsocketMessage): + """A base model for messages related to observing an action.""" + + name: str + operation: Literal["observeaction"] = "observeaction" + + +class ObserveActionNotification(ObserveActionMessage): + """A model for action notification messages. + + This is not part of the webthingprotocol draft, so should be considered + at risk of summary removal. + """ + + messageType: Literal["notification"] = "notification" + actionID: UUID + state: Literal["pending", "running", "completed", "failed"] + + +class ActionStatus(BaseModel): + """The status of an action invocation.""" + + actionID: UUID + state: Literal["pending", "running", "completed", "failed"] + output: Any | None = None + error: "ProblemDetails | None" = None + timeRequested: datetime | None = None + timeEnded: datetime | None = None + + +class ProblemDetails(BaseModel): + """Details of an error. + + This follows RFC9457_. + + .. _RFC9457: https://datatracker.ietf.org/doc/html/rfc9457 + """ + + status: int + type: str + title: str + detail: str | None = None + + +class ObservationErrorResponse(WebsocketMessage): + """A websocket error response for observing an action or property.""" + + messageType: Literal["response"] = "response" + operation: Literal["observeaction", "observeproperty"] + name: str + error: ProblemDetails diff --git a/tests/test_property.py b/tests/test_property.py index 2ba34417..1d9fbc96 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -179,6 +179,12 @@ class Example: assert str(Example.prop.value_type) == "str | None" assert issubclass(Example.prop.model, pydantic.RootModel) assert str(Example.prop.model.model_fields["root"].annotation) == "str | None" + testmodel = Example.prop.value_to_model("test") + assert isinstance(testmodel, pydantic.RootModel) + assert testmodel.root == "test" + nonemodel = Example.prop.value_to_model(None) + assert isinstance(nonemodel, pydantic.RootModel) + assert nonemodel.root is None def test_baseproperty_type_and_model_pydantic(): @@ -198,6 +204,10 @@ class Example: assert Example.prop.value_type is MyModel assert Example.prop.model is MyModel + value = MyModel(foo="test", bar=42) + assert isinstance(value, Example.prop.value_type) + assert Example.prop.value_to_model(value) is value + def test_baseproperty_add_to_fastapi(): """Check the method that adds the property to the HTTP API.""" diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 2781e04f..d56f015b 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -1,4 +1,6 @@ +from anyio import create_memory_object_stream from fastapi.testclient import TestClient +from pydantic import BaseModel import pytest import labthings_fastapi as lt from labthings_fastapi.exceptions import ( @@ -86,17 +88,18 @@ def thing(): return create_thing_without_server(ThingWithProperties) -def test_observing_dataprop(thing, mocker): +def test_observing_dataprop(thing): """Check `observe_property` is OK on a data property. This checks that something is added to the set of observers. We don't check for events, as there's no event loop: this is tested in `test_observing_dataprop_with_ws` below. """ - observers_set = ThingWithProperties.dataprop._observers_set(thing) - fake_observer = mocker.Mock() - thing.observe_property("dataprop", fake_observer) - assert fake_observer in observers_set + send_stream, receive_stream = create_memory_object_stream[BaseModel]() + thing.properties["dataprop"].observe(send_stream) + event_broker = thing._thing_server_interface._event_broker + observers_set = event_broker._subscriptions[thing.name]["dataprop"] + assert send_stream in observers_set @pytest.mark.parametrize( @@ -113,7 +116,7 @@ def test_observing_dataprop(thing, mocker): def test_observing_errors(thing, mocker, name, exception): """Check errors are raised if we observe an unsuitable property.""" with pytest.raises(exception): - thing.observe_property(name, mocker.Mock()) + thing.properties[name].observe(mocker.Mock()) def test_observing_dataprop_with_ws(client, ws): @@ -125,8 +128,9 @@ def test_observing_dataprop_with_ws(client, ws): # Observe the property. ws.send_json( { - "messageType": "addPropertyObservation", - "data": {"dataprop": True}, + "messageType": "request", + "operation": "observeproperty", + "name": "dataprop", } ) for val in [1, 10, 0]: @@ -134,24 +138,28 @@ def test_observing_dataprop_with_ws(client, ws): client.put("/thing/dataprop", json=val) # Receive the message and check it's as expected. message = ws.receive_json(mode="text") - assert message["messageType"] == "propertyStatus" - assert message["data"]["dataprop"] == val + assert message["messageType"] == "notification" + assert message["operation"] == "observeproperty" + assert message["name"] == "dataprop" + assert message["value"] == val # Increment the value with an action client.post("/thing/increment_dataprop") message = ws.receive_json(mode="text") - assert message["messageType"] == "propertyStatus" - assert message["data"]["dataprop"] == 1 + assert message["messageType"] == "notification" + assert message["operation"] == "observeproperty" + assert message["name"] == "dataprop" + assert message["value"] == 1 @pytest.mark.parametrize( argnames=["name", "title", "status"], argvalues=[ - ("funcprop", "Not Observable", "403"), - ("non_property", "Not Found", "404"), - ("python_property", "Not Found", "404"), - ("undecorated", "Not Found", "404"), - ("increment_dataprop", "Not Found", "404"), - ("missing", "Not Found", "404"), + ("funcprop", "Not Observable", 403), + ("non_property", "Not Found", 404), + ("python_property", "Not Found", 404), + ("undecorated", "Not Found", 404), + ("increment_dataprop", "Not Found", 404), + ("missing", "Not Found", 404), ], ) def test_observing_dataprop_error_with_ws(ws, name, title, status): @@ -162,8 +170,9 @@ def test_observing_dataprop_error_with_ws(ws, name, title, status): # Observe the property. ws.send_json( { - "messageType": "addPropertyObservation", - "data": {name: True}, + "messageType": "request", + "operation": "observeproperty", + "name": name, } ) # Receive the message and check for the error. @@ -178,9 +187,10 @@ def test_observing_action(thing, mocker): This verifies we've added an observer to the set, but doesn't test for notifications: that would require an event loop. """ - observers_set = ThingWithProperties.increment_dataprop._observers_set(thing) fake_observer = mocker.Mock() - thing.observe_action("increment_dataprop", fake_observer) + thing.actions["increment_dataprop"].observe(fake_observer) + event_broker = thing._thing_server_interface._event_broker + observers_set = event_broker._subscriptions[thing.name]["increment_dataprop"] assert fake_observer in observers_set @@ -190,7 +200,7 @@ def test_observing_action(thing, mocker): def test_observing_action_error(thing, mocker, name): """Check observing an attribute that's not an action raises an error.""" with pytest.raises(KeyError): - thing.observe_action(name, mocker.Mock()) + thing.actions[name].observe(mocker.Mock()) @pytest.mark.parametrize( @@ -206,8 +216,9 @@ def test_observing_action_with_ws(client, ws, name, final_status): # Observe the property. ws.send_json( { - "messageType": "addActionObservation", - "data": {name: True}, + "messageType": "request", + "operation": "observeaction", + "name": name, } ) # Invoke the action (via HTTP) @@ -215,8 +226,10 @@ def test_observing_action_with_ws(client, ws, name, final_status): # We should see the status go through the expected sequence for expected_status in ["pending", "running", final_status]: message = ws.receive_json(mode="text") - assert message["messageType"] == "actionStatus" - assert message["data"]["status"] == expected_status + assert message["messageType"] == "notification" + assert message["operation"] == "observeaction" + assert message["name"] == name + assert message["status"] == expected_status @pytest.mark.parametrize( @@ -230,11 +243,12 @@ def test_observing_action_error_with_ws(ws, name): # Observe the property. ws.send_json( { - "messageType": "addActionObservation", - "data": {name: True}, + "messageType": "request", + "operation": "observeaction", + "name": name, } ) # Receive the message and check for the error. message = ws.receive_json(mode="text") assert message["error"]["title"] == "Not Found" - assert message["error"]["status"] == "404" + assert message["error"]["status"] == 404