From 7a035223bce4df50f8468a6a82afe3d589e50c72 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Mar 2026 12:14:27 +0000 Subject: [PATCH 1/4] 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 25c3f750108bfc1fbf1e2b5d949dd0867c91439b Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Mar 2026 12:46:02 +0000 Subject: [PATCH 2/4] 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 efd3eb357be46998459396578d06b550ce5afcab Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Mar 2026 14:44:38 +0000 Subject: [PATCH 3/4] 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 6b7928818e0459703097d0cf297c1c00133ae4e3 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 2 Mar 2026 15:05:22 +0000 Subject: [PATCH 4/4] 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