From 2bf597ee9b9b4d3949d110d728fed8c4e041dbc8 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Mar 2026 12:14:27 +0000 Subject: [PATCH 1/5] Implement default and reset for properties This works on DataProperty and raises a specific exception for FunctionalProperty. It is not yet exposed over HTTP, but is tested in Python. --- src/labthings_fastapi/exceptions.py | 11 +++++ src/labthings_fastapi/properties.py | 69 +++++++++++++++++++++++++++++ tests/test_property.py | 50 ++++++++++++++++++++- 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/labthings_fastapi/exceptions.py b/src/labthings_fastapi/exceptions.py index 24957305..faba1d68 100644 --- a/src/labthings_fastapi/exceptions.py +++ b/src/labthings_fastapi/exceptions.py @@ -202,3 +202,14 @@ class NotBoundToInstanceError(RuntimeError): generated from a `.Thing` class. Usually, they should be accessed via a `.Thing` instance, in which case they will be bound. """ + + +class FeatureNotAvailable(NotImplementedError): + """A feature is not available. + + There are some methods provided by base classes where implementation is optional. + These methods raise `FeatureNotAvailable` if they are not implemented. + + Currently this is done for the default value of properties, and their reset + method. + """ diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index 9c374a04..9d6088f3 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -83,6 +83,7 @@ class attribute. Documentation is in strings immediately following the FieldTypedBaseDescriptorInfo, ) from .exceptions import ( + FeatureNotAvailable, NotConnectedToServerError, ReadOnlyPropertyError, MissingTypeError, @@ -393,6 +394,38 @@ def model(self) -> type[BaseModel]: ) return self._model + def default(self, obj: Owner | None) -> Value: + """Return the default value of this property. + + :param obj: the `.Thing` instance on which we are looking for the default. + or `None` if referring to the class. For now, this is ignored. + + :return: the default value of this property. + :raises FeatureNotAvailable: as this must be overridden. + """ + raise FeatureNotAvailable( + f"{obj.name if obj else self.__class__}.{self.name} cannot be reset, " + f"as it's not supported by {self.__class__}." + ) + + def reset(self, obj: Owner) -> None: + """Reset the property's value to a default state. + + If there is a defined default value for the property, this method + should reset the property to that default. + + Not every property is expected to implement ``reset`` so it is important + to handle `.FeatureNotAvailable` exceptions, which will be raised if this + method is not overridden. + + :param thing: the `.Thing` instance we want to reset. + :raises FeatureNotAvailable: as only some subclasses implement resetting. + """ + raise FeatureNotAvailable( + f"{obj.name}.{self.name} cannot be reset, as it's not supported by " + f"{self.__class__}." + ) + def add_to_fastapi(self, app: FastAPI, thing: Owner) -> None: """Add this action to a FastAPI app, bound to a particular Thing. @@ -612,6 +645,23 @@ def __set__( if emit_changed_event: self.emit_changed_event(obj, value) + def default(self, obj: Owner | None) -> Value: + """Return the default value of this property. + + Note that this implementation is independent of the `.Thing` instance, + as there's currently no way to specify a per-instance default. + + :return: the default value of this property. + """ + return self._default_factory() + + def reset(self, obj: Owner) -> None: + r"""Reset the property to its default value. + + This resets to the value returned by ``default`` for `.DataProperty`\ . + """ + self.__set__(obj, self.default(obj)) + def _observers_set(self, obj: Thing) -> WeakSet: """Return the observers of this property. @@ -863,6 +913,25 @@ def model_instance(self) -> BaseModel: # noqa: DOC201 raise TypeError(msg) return cls(root=value) + @builtins.property + def default(self) -> Value: + """The default value of this property. + + .. warning:: + Note that this is an optional feature, so calling code must handle + `.FeatureNotAvailable` exceptions. + """ + return self.get_descriptor().default(self.owning_object) + + def reset(self) -> None: + """Reset the property to a default value. + + .. warning:: + Note that this is an optional feature, so calling code must handle + `.FeatureNotAvailable` exceptions. + """ + return self.get_descriptor().reset(self.owning_object_or_error()) + def validate(self, value: Any) -> Value: """Use the validation logic in `self.model`. diff --git a/tests/test_property.py b/tests/test_property.py index fe285dbd..ad8e0dba 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -25,7 +25,12 @@ default_factory_from_arguments, ) from labthings_fastapi.base_descriptor import DescriptorAddedToClassTwiceError -from labthings_fastapi.exceptions import MissingTypeError, NotConnectedToServerError +from labthings_fastapi.exceptions import ( + FeatureNotAvailable, + MissingTypeError, + NotBoundToInstanceError, + NotConnectedToServerError, +) import labthings_fastapi as lt from labthings_fastapi.testing import create_thing_without_server from .utilities import raises_or_is_caused_by @@ -456,3 +461,46 @@ def _set_funcprop(self, val: int) -> None: "ro_functional_property_with_setter", ]: assert td.properties[name].readOnly is True + + +def test_default_and_reset(): + """Test retrieving property defaults, and resetting to default.""" + + class Example(lt.Thing): + intprop: int = lt.property(default=42) + listprop: list[str] = lt.property(default_factory=lambda: ["a", "list"]) + + @lt.property + def strprop(self) -> str: + return "Hello World!" + + example = create_thing_without_server(Example) + + # Defaults should be available on classes and instances + for thing in [example, Example]: + # We shoulld get expected values for defaults + assert thing.properties["intprop"].default == 42 + assert thing.properties["listprop"].default == ["a", "list"] + # Defaults are not available for FunctionalProperties + with pytest.raises(FeatureNotAvailable): + _ = thing.properties["strprop"].default + + # Resetting to default isn't available on classes + for name in ["intprop", "listprop", "strprop"]: + with pytest.raises(NotBoundToInstanceError): + thing.properties[name].reset() + + # Resetting should work for DataProperty + example.intprop = 43 + assert example.intprop == 43 + example.properties["intprop"].reset() + assert example.intprop == 42 + + example.listprop = [] + assert example.listprop == [] + example.properties["listprop"].reset() + assert example.listprop == ["a", "list"] + + # Resetting won't work for FunctionalProperty + with pytest.raises(FeatureNotAvailable): + example.properties["strprop"].reset() From ea58d9782c8e533a0162991413cc78f013926d0f Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Mar 2026 12:46:02 +0000 Subject: [PATCH 2/5] Expose default values in the Thing Description This will fail if the default won't validate. We probably want a check for this when the `Thing` is defined, as otherwise we will get annoying and unclear errors after the server has started. --- src/labthings_fastapi/properties.py | 12 +++++++++++- tests/test_property.py | 6 ++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index 9d6088f3..69722abf 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -473,7 +473,7 @@ def get_property() -> Any: return self.__get__(thing) def property_affordance( - self, thing: Thing, path: str | None = None + self, thing: Owner, path: str | None = None ) -> PropertyAffordance: """Represent the property in a Thing Description. @@ -500,12 +500,22 @@ def property_affordance( ), ] data_schema: DataSchema = type_to_dataschema(self.model) + extra_fields = {} + try: + # Try to get hold of the default - may raise FeatureNotAvailable + default = self.default(thing) + # Validate and dump it with the model to ensure it's simple types only + default_validated = self.model.model_validate(default) + extra_fields["default"] = default_validated.model_dump() + except FeatureNotAvailable: + pass # Default should only be included if it's needed. pa: PropertyAffordance = PropertyAffordance( title=self.title, forms=forms, description=self.description, readOnly=self.readonly, writeOnly=False, # write-only properties are not yet supported + **extra_fields, ) # We merge the data schema with the property affordance (which subclasses the # DataSchema model) with the affordance second so its values take priority. diff --git a/tests/test_property.py b/tests/test_property.py index ad8e0dba..a2f31247 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -504,3 +504,9 @@ def strprop(self) -> str: # Resetting won't work for FunctionalProperty with pytest.raises(FeatureNotAvailable): example.properties["strprop"].reset() + + # Check defaults show up in the Thing Description + td = example.thing_description_dict() + assert td["properties"]["intprop"]["default"] == 42 + assert td["properties"]["listprop"]["default"] == ["a", "list"] + assert "default" not in td["properties"]["strprop"] From f75224bf4d9c90531afff5249e7d46985d0532bd Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Mar 2026 14:44:38 +0000 Subject: [PATCH 3/5] Make it possible to check (from Python) if a property can be reset. --- src/labthings_fastapi/properties.py | 33 +++++++++++++++++++++++++++++ tests/test_property.py | 9 ++++++++ 2 files changed, 42 insertions(+) diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index 69722abf..a4e932c1 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -426,6 +426,15 @@ def reset(self, obj: Owner) -> None: f"{self.__class__}." ) + def is_resettable(self, obj: Owner | None) -> bool: + r"""Determine if it's possible to reset this property. + + By default, this returns `True` if ``reset`` has been overridden. + If you override ``reset`` but want more control over this behaviour, + you probably need to override `is_resettable`\ . + """ + return BaseProperty.reset is not self.__class__.reset + def add_to_fastapi(self, app: FastAPI, thing: Owner) -> None: """Add this action to a FastAPI app, bound to a particular Thing. @@ -472,6 +481,25 @@ def set_property(body: Any) -> None: def get_property() -> Any: return self.__get__(thing) + if self.is_resettable(thing): + + @app.post( + thing.path + self.name + "/reset", + summary=f"Reset {self.title}.", + description=( + f"## Reset {self.title}\n\n" + "This endpoint will reset the property to its default value. " + "The default value should be detailed in the Thing Description.\n\n" + "Not every property supports the reset-to-default operation, and " + "this endpoint is only present (e.g. in the OpenAPI docs) " + "for those that do.\n\n" + "This endpoint is identical to using the ``reset_property`` action" + rf"with the ``name`` argument set to ``{self.name}``\ ." + ), + ) + def reset() -> None: + self.reset(thing) + def property_affordance( self, thing: Owner, path: str | None = None ) -> PropertyAffordance: @@ -933,6 +961,11 @@ def default(self) -> Value: """ return self.get_descriptor().default(self.owning_object) + @builtins.property + def is_resettable(self) -> bool: + """Whether the property may be reset using the ``reset()`` method.""" + return self.get_descriptor().is_resettable(self.owning_object) + def reset(self) -> None: """Reset the property to a default value. diff --git a/tests/test_property.py b/tests/test_property.py index a2f31247..d1c974d5 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -490,6 +490,15 @@ def strprop(self) -> str: with pytest.raises(NotBoundToInstanceError): thing.properties[name].reset() + # Check the `resettable` property is correct + for thing in [example, Example]: + for name, resettable in [ + ("intprop", True), + ("listprop", True), + ("strprop", False), + ]: + assert thing.properties[name].is_resettable is resettable + # Resetting should work for DataProperty example.intprop = 43 assert example.intprop == 43 From 81d273e2d280aaf96755a4ff75df9b32c33ee5e7 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Mar 2026 15:05:22 +0000 Subject: [PATCH 4/5] Fix typos and docstrings --- src/labthings_fastapi/properties.py | 13 ++++++++++--- tests/test_property.py | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index a4e932c1..38918005 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -418,7 +418,7 @@ def reset(self, obj: Owner) -> None: to handle `.FeatureNotAvailable` exceptions, which will be raised if this method is not overridden. - :param thing: the `.Thing` instance we want to reset. + :param obj: the `.Thing` instance we want to reset. :raises FeatureNotAvailable: as only some subclasses implement resetting. """ raise FeatureNotAvailable( @@ -432,6 +432,9 @@ def is_resettable(self, obj: Owner | None) -> bool: By default, this returns `True` if ``reset`` has been overridden. If you override ``reset`` but want more control over this behaviour, you probably need to override `is_resettable`\ . + + :param obj: the `.Thing` instance we want to reset. + :return: `True` if a call to ``reset()`` should work. """ return BaseProperty.reset is not self.__class__.reset @@ -689,6 +692,8 @@ def default(self, obj: Owner | None) -> Value: Note that this implementation is independent of the `.Thing` instance, as there's currently no way to specify a per-instance default. + :param obj: the `.Thing` instance we want to reset. + :return: the default value of this property. """ return self._default_factory() @@ -697,6 +702,8 @@ def reset(self, obj: Owner) -> None: r"""Reset the property to its default value. This resets to the value returned by ``default`` for `.DataProperty`\ . + + :param obj: the `.Thing` instance we want to reset. """ self.__set__(obj, self.default(obj)) @@ -952,7 +959,7 @@ def model_instance(self) -> BaseModel: # noqa: DOC201 return cls(root=value) @builtins.property - def default(self) -> Value: + def default(self) -> Value: # noqa: DOC201 """The default value of this property. .. warning:: @@ -962,7 +969,7 @@ def default(self) -> Value: return self.get_descriptor().default(self.owning_object) @builtins.property - def is_resettable(self) -> bool: + def is_resettable(self) -> bool: # noqa: DOC201 """Whether the property may be reset using the ``reset()`` method.""" return self.get_descriptor().is_resettable(self.owning_object) diff --git a/tests/test_property.py b/tests/test_property.py index d1c974d5..19e54e3c 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -478,7 +478,7 @@ def strprop(self) -> str: # Defaults should be available on classes and instances for thing in [example, Example]: - # We shoulld get expected values for defaults + # We should get expected values for defaults assert thing.properties["intprop"].default == 42 assert thing.properties["listprop"].default == ["a", "list"] # Defaults are not available for FunctionalProperties From fbbb590acd48f4dd837061d0b93d7a485cc8722a Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 23 Mar 2026 16:54:35 +0000 Subject: [PATCH 5/5] Rename `FeatureNotAvailable` This custom exception now ends with `Error` as recommended by @julianstirling during code review. --- src/labthings_fastapi/exceptions.py | 4 ++-- src/labthings_fastapi/properties.py | 20 ++++++++++---------- tests/test_property.py | 6 +++--- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/labthings_fastapi/exceptions.py b/src/labthings_fastapi/exceptions.py index faba1d68..57d8f6b0 100644 --- a/src/labthings_fastapi/exceptions.py +++ b/src/labthings_fastapi/exceptions.py @@ -204,11 +204,11 @@ class NotBoundToInstanceError(RuntimeError): """ -class FeatureNotAvailable(NotImplementedError): +class FeatureNotAvailableError(NotImplementedError): """A feature is not available. There are some methods provided by base classes where implementation is optional. - These methods raise `FeatureNotAvailable` if they are not implemented. + These methods raise `FeatureNotAvailableError` if they are not implemented. Currently this is done for the default value of properties, and their reset method. diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index 38918005..610e0b30 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -83,7 +83,7 @@ class attribute. Documentation is in strings immediately following the FieldTypedBaseDescriptorInfo, ) from .exceptions import ( - FeatureNotAvailable, + FeatureNotAvailableError, NotConnectedToServerError, ReadOnlyPropertyError, MissingTypeError, @@ -401,9 +401,9 @@ def default(self, obj: Owner | None) -> Value: or `None` if referring to the class. For now, this is ignored. :return: the default value of this property. - :raises FeatureNotAvailable: as this must be overridden. + :raises FeatureNotAvailableError: as this must be overridden. """ - raise FeatureNotAvailable( + raise FeatureNotAvailableError( f"{obj.name if obj else self.__class__}.{self.name} cannot be reset, " f"as it's not supported by {self.__class__}." ) @@ -415,13 +415,13 @@ def reset(self, obj: Owner) -> None: should reset the property to that default. Not every property is expected to implement ``reset`` so it is important - to handle `.FeatureNotAvailable` exceptions, which will be raised if this + to handle `.FeatureNotAvailableError` exceptions, which will be raised if this method is not overridden. :param obj: the `.Thing` instance we want to reset. - :raises FeatureNotAvailable: as only some subclasses implement resetting. + :raises FeatureNotAvailableError: as only some subclasses implement resetting. """ - raise FeatureNotAvailable( + raise FeatureNotAvailableError( f"{obj.name}.{self.name} cannot be reset, as it's not supported by " f"{self.__class__}." ) @@ -533,12 +533,12 @@ def property_affordance( data_schema: DataSchema = type_to_dataschema(self.model) extra_fields = {} try: - # Try to get hold of the default - may raise FeatureNotAvailable + # Try to get hold of the default - may raise FeatureNotAvailableError default = self.default(thing) # Validate and dump it with the model to ensure it's simple types only default_validated = self.model.model_validate(default) extra_fields["default"] = default_validated.model_dump() - except FeatureNotAvailable: + except FeatureNotAvailableError: pass # Default should only be included if it's needed. pa: PropertyAffordance = PropertyAffordance( title=self.title, @@ -964,7 +964,7 @@ def default(self) -> Value: # noqa: DOC201 .. warning:: Note that this is an optional feature, so calling code must handle - `.FeatureNotAvailable` exceptions. + `.FeatureNotAvailableError` exceptions. """ return self.get_descriptor().default(self.owning_object) @@ -978,7 +978,7 @@ def reset(self) -> None: .. warning:: Note that this is an optional feature, so calling code must handle - `.FeatureNotAvailable` exceptions. + `.FeatureNotAvailableError` exceptions. """ return self.get_descriptor().reset(self.owning_object_or_error()) diff --git a/tests/test_property.py b/tests/test_property.py index 19e54e3c..95c251ff 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -26,7 +26,7 @@ ) from labthings_fastapi.base_descriptor import DescriptorAddedToClassTwiceError from labthings_fastapi.exceptions import ( - FeatureNotAvailable, + FeatureNotAvailableError, MissingTypeError, NotBoundToInstanceError, NotConnectedToServerError, @@ -482,7 +482,7 @@ def strprop(self) -> str: assert thing.properties["intprop"].default == 42 assert thing.properties["listprop"].default == ["a", "list"] # Defaults are not available for FunctionalProperties - with pytest.raises(FeatureNotAvailable): + with pytest.raises(FeatureNotAvailableError): _ = thing.properties["strprop"].default # Resetting to default isn't available on classes @@ -511,7 +511,7 @@ def strprop(self) -> str: assert example.listprop == ["a", "list"] # Resetting won't work for FunctionalProperty - with pytest.raises(FeatureNotAvailable): + with pytest.raises(FeatureNotAvailableError): example.properties["strprop"].reset() # Check defaults show up in the Thing Description