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..38918005 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,50 @@ 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 obj: 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 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`\ . + + :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 + def add_to_fastapi(self, app: FastAPI, thing: Owner) -> None: """Add this action to a FastAPI app, bound to a particular Thing. @@ -439,8 +484,27 @@ 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: Thing, path: str | None = None + self, thing: Owner, path: str | None = None ) -> PropertyAffordance: """Represent the property in a Thing Description. @@ -467,12 +531,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. @@ -612,6 +686,27 @@ 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. + + :param obj: the `.Thing` instance we want to reset. + + :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`\ . + + :param obj: the `.Thing` instance we want to reset. + """ + self.__set__(obj, self.default(obj)) + def _observers_set(self, obj: Thing) -> WeakSet: """Return the observers of this property. @@ -863,6 +958,30 @@ def model_instance(self) -> BaseModel: # noqa: DOC201 raise TypeError(msg) return cls(root=value) + @builtins.property + def default(self) -> Value: # noqa: DOC201 + """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) + + @builtins.property + 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) + + 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..19e54e3c 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,61 @@ 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 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 + 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() + + # 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 + 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() + + # 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"]