diff --git a/docs/source/properties.rst b/docs/source/properties.rst index cbd3a20c..19a3ffd5 100644 --- a/docs/source/properties.rst +++ b/docs/source/properties.rst @@ -45,6 +45,15 @@ It is a good idea to make sure there is a docstring for your property. This will You don't need to include the type in the docstring, as it will be inferred from the type hint. However, you can include additional information about the property, such as its units or any constraints on its value. +If your property's default value is a mutable datatype, like a list or dictionary, it's a good idea to use a *default factory* instead of a default value, in order to prevent strange behaviour. This may be done as shown: + +.. code-block:: python + + class MyThing(lt.Thing): + my_list: list[int] = lt.property(default_factory=list) + +The example above will have its default value set to the empty list, as that's what is returned when ``list()`` is called. It's often convenient to use a "lambda function" as a default factory, for example `lambda: [1,2,3]` is a function that returns the list `[1,2,3]`\ . This is better than specifying a default value, because it returns a fresh copy of the object every time - using a list as a default value can lead to multiple `.Thing` instances changing in sync unexpectedly, which gets very confusing. + Data properties may be *observed*, which means notifications will be sent when the property is written to (see below). Functional properties @@ -93,7 +102,7 @@ Adding a setter makes the property read-write (if only a getter is present, it m The setter method for regular Python properties is usually named the same as the property itself (e.g. ``def twice_my_property(self, value: int)``). Unfortunately, doing this with LabThings properties causes problems for static type checkers such as `mypy`\ . We therefore recommend you prefix setters with ``_set_`` (e.g. ``def _set_twice_my_property(self, value: int)``). This is optional, and doesn't change the way the property works - but it is useful if you need `mypy` to work on your code, and don't want to ignore every property setter. -It is possible to make a property read-only for clients by setting its ``readonly`` attribute: this has the same behaviour as for data properties. +It is possible to make a property read-only for clients by setting its ``readonly`` attribute: this has the same behaviour as for data properties. A default can also be specified in the same way: .. code-block:: python @@ -115,11 +124,41 @@ It is possible to make a property read-only for clients by setting its ``readonl # Make the property read-only for clients twice_my_property.readonly = True + # Add a default to the Thing Description + twice_my_property.default = 84 -In the example above, ``twice_my_property`` may be set by code within ``MyThing`` but cannot be written to via HTTP requests or `.DirectThingClient` instances. +In the example above, ``twice_my_property`` may be set by code within ``MyThing`` but cannot be written to via HTTP requests or `.DirectThingClient` instances. It's worth noting that you may assign to ``twice_my_property.default_factory`` instead, just like using the ``default_factory`` argument of ``lt.property``\ . Functional properties may not be observed, as they are not backed by a simple value. If you need to notify clients when the value changes, you can use a data property that is updated by the functional property. In the example above, ``my_property`` may be observed, while ``twice_my_property`` cannot be observed. It would be possible to observe changes in ``my_property`` and then query ``twice_my_property`` for its new value. +Functional properties may define a "resetter" method, which resets them to some initial state. This may be done even if a default has not been defined. To do this, you may use the property as a decorator, just like adding a setter: + +.. code-block:: python + + import labthings_fastapi as lt + + class MyThing(lt.Thing): + def __init__(self, **kwargs): + super().__init__(self, **kwargs) + self._hardware = MyHardwareClass() + + @lt.property + def setpoint(self) -> int: + """The hardware's setpoint.""" + return self._hardware.get_setpoint() + + @setpoint.setter + def _set_setpoint(self, value: int): + """Change the hardware setpoint.""" + self._hardware.set_setpoint(value) + + @setpoint.resetter + def _reset_setpoint(self): + """Reset the hardware's setpoint.""" + self._hardware.reset_setpoint() + +A resetter method, if defined, will take precedence over a default value if both are present. If a default value (or factory) is set and there is no resetter method, resetting the property will simply call the property's setter with the default value. + .. _property_constraints: Property constraints @@ -167,6 +206,11 @@ Note that the constraints for functional properties are set by assigning a dicti Property values are not validated when they are set directly, only via HTTP. This behaviour may change in the future. +Property metadata +----------------- + +Properties in LabThings are intended to work very much like native Python properties. This means that getting and setting the attributes of a `.Thing` get and set the value of the property. Other operations, like reading the default value or resetting to default, need a different interface. For this, we use `.Thing.properties` which is a mapping of names to `.PropertyInfo` objects. These expose the extra functionality of properties in a convenient way. For example, I can reset a property by calling ``self.properties["myprop"].reset()`` or get its default by reading ``self.properties["myprop"].default``\ . See the `.PropertyInfo` API documentation for a full list of available properties and methods. + HTTP interface -------------- diff --git a/src/labthings_fastapi/exceptions.py b/src/labthings_fastapi/exceptions.py index 57d8f6b0..e670e0a9 100644 --- a/src/labthings_fastapi/exceptions.py +++ b/src/labthings_fastapi/exceptions.py @@ -213,3 +213,12 @@ class FeatureNotAvailableError(NotImplementedError): Currently this is done for the default value of properties, and their reset method. """ + + +class PropertyRedefinitionError(AttributeError): + """A property is being incorrectly redefined. + + This method is raised if a property is at risk of being redefined. This usually + happens when a decorator is applied to a function with the same name as the + property. The solution is usually to rename the function. + """ diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index 610e0b30..df911a32 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -85,6 +85,7 @@ class attribute. Documentation is in strings immediately following the from .exceptions import ( FeatureNotAvailableError, NotConnectedToServerError, + PropertyRedefinitionError, ReadOnlyPropertyError, MissingTypeError, UnsupportedConstraintError, @@ -394,7 +395,7 @@ def model(self) -> type[BaseModel]: ) return self._model - def default(self, obj: Owner | None) -> Value: + def get_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. @@ -404,8 +405,8 @@ def default(self, obj: Owner | None) -> Value: :raises FeatureNotAvailableError: as this must be overridden. """ raise FeatureNotAvailableError( - f"{obj.name if obj else self.__class__}.{self.name} cannot be reset, " - f"as it's not supported by {self.__class__}." + f"{obj.name if obj else self.__class__}.{self.name} can't return a " + f"default, as it's not supported by {self.__class__}." ) def reset(self, obj: Owner) -> None: @@ -534,7 +535,7 @@ def property_affordance( extra_fields = {} try: # Try to get hold of the default - may raise FeatureNotAvailableError - default = self.default(thing) + default = self.get_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() @@ -686,7 +687,7 @@ def __set__( if emit_changed_event: self.emit_changed_event(obj, value) - def default(self, obj: Owner | None) -> Value: + def get_default(self, obj: Owner | None) -> Value: """Return the default value of this property. Note that this implementation is independent of the `.Thing` instance, @@ -705,7 +706,7 @@ def reset(self, obj: Owner) -> None: :param obj: the `.Thing` instance we want to reset. """ - self.__set__(obj, self.default(obj)) + self.__set__(obj, self.get_default(obj)) def _observers_set(self, obj: Thing) -> WeakSet: """Return the observers of this property. @@ -796,7 +797,19 @@ def __init__( "Return type annotations are required for property getters." ) raise MissingTypeError(msg) - self._fset: Callable[[Owner, Value], None] | None = None + self._fset: Callable[[Owner, Value], None] | None = None # setter function + # `_freset` should reset the property to its default value. + self._freset: ( + Callable[ + [ + Owner, + ], + None, + ] + | None + ) = None + # `_default_factory` should return a default value. + self._default_factory: Callable[[], Value] | None = None self.readonly: bool = True @builtins.property @@ -915,6 +928,150 @@ 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 default(self) -> Value: + r"""The default value for this property. + + This attribute is mostly provided to allow it to be set at class definition + time - it should usually be retrieved through + ``thing_instance.properties['name'].default``\ . + + + .. warning:: + + The default is not guaranteed to be available! It should usually be accessed + via a `PropertyInfo` object, as ``thing.properties['name'].default``\ . + If a default is not available, a `FeatureNotAvailableError` will be raised. + + :raises FeatureNotAvailableError: if no default is defined. + :return: the default value. + """ + if self.default_factory is None: + msg = "No default has been defined for this property." + raise FeatureNotAvailableError(msg) + return self.default_factory() + + @default.setter + def default(self, value: Value) -> None: + """Set the default value. + + :param value: the new default value. + """ + self.default_factory = default_factory_from_arguments(default=value) + + @builtins.property + def default_factory(self) -> Callable[[], Value] | None: + """The default factory function, if available. + + This property will be `None` if no default is set, or it will be a function + that returns a default value. + + Setting the default factory will also allow this property to be reset using + its `reset()` method, which will call the property's setter with the default + value. If a reset function was already specified (e.g. with the ``resetter`` + decorator), it will not be overwritten. + + :return: the default factory function, or `None` if it is not set. + """ + return self._default_factory + + @default_factory.setter + def default_factory(self, value: Callable[[], Value] | None) -> None: + """Set the default factory. + + :param value: a function that takes no arguments and returns a default value. + """ + self._default_factory = value + + def get_default(self, obj: Owner | None) -> Value: + """Return a default value, if available. + + :param obj: The Thing for which we are retrieving the default value, or + `None` if we are referring only to the class. + + :return: the default value. + :raises FeatureNotAvailable: if no default has been defined. + """ + if self._default_factory is None: + msg = "No default has been defined for {self._owner_name}.{self.name}." + raise FeatureNotAvailableError(msg) + return self._default_factory() + + def resetter(self, freset: Callable[[Owner], None]) -> Callable[[Owner], None]: + r"""Decorate a method that resets the property to a default state. + + Functional properties may optionally define a function that resets the property + to a default state. This method is intended to be used as a decorator: + + .. code-block:: python + + + import labthings_fastapi as lt + + + class MyThing(lt.Thing): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._myprop = 42 + + @lt.property + def myprop(self) -> int: + return self._myprop + + @myprop.setter + def _set_myprop(self, val: int) -> None: + self._myprop = val + + @myprop.resetter + def _reset_myprop(self) -> None: + self._myprop = 42 + + :param freset: The method being decorated. This should take one + positional argument, ``self``\ , which has the usual meaning for + Python methods. + :raises PropertyRedefinitionError: if the decorated method has the same name as + the property. Please use a different name, as shown in the example above. + :return: the decorated function (unchanged). Note that we don't return the + property, so you must choose a different name for the reset function. + """ + self._freset = freset + if freset.__name__ == self.fget.__name__: + msg = "The resetter function may not have the same name as the property." + raise PropertyRedefinitionError(msg) + return freset + + 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. + :raises FeatureNotAvailable: if no reset method is available, which means there + is no default defined, and no resetter method. + """ + if self._freset: + self._freset(obj) + elif self._default_factory and self.fset: + self.__set__(obj, self._default_factory()) + else: + msg = f"Property {self._owner_name}.{self.name} cannot be reset." + raise FeatureNotAvailableError(msg) + + def is_resettable(self, obj: Owner | None) -> bool: + """Whether the property may be reset. + + This will be true if a `resetter` function has been added, or if a default is + defined and the property has a `setter` defined. + + :param obj: the object on which we are defined. + :return: whether a call to ``reset()`` should succeed. + """ + if self._freset is not None: + return True + if self._default_factory is not None and self.fset is not None: + return True + return False + class PropertyInfo( FieldTypedBaseDescriptorInfo[BasePropertyT, Owner, Value], @@ -966,7 +1123,7 @@ def default(self) -> Value: # noqa: DOC201 Note that this is an optional feature, so calling code must handle `.FeatureNotAvailableError` exceptions. """ - return self.get_descriptor().default(self.owning_object) + return self.get_descriptor().get_default(self.owning_object) @builtins.property def is_resettable(self) -> bool: # noqa: DOC201 diff --git a/tests/test_property.py b/tests/test_property.py index 95c251ff..05a5a0d5 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -11,6 +11,10 @@ same way. """ +from dataclasses import dataclass +import json +from typing import Any + import fastapi from fastapi.testclient import TestClient import pydantic @@ -30,6 +34,7 @@ MissingTypeError, NotBoundToInstanceError, NotConnectedToServerError, + PropertyRedefinitionError, ) import labthings_fastapi as lt from labthings_fastapi.testing import create_thing_without_server @@ -463,59 +468,179 @@ def _set_funcprop(self, val: int) -> None: assert td.properties[name].readOnly is True -def test_default_and_reset(): +@dataclass +class PropertyDefaultInfo: + name: str + resettable: bool + default: Any + resets_to: Any = ... + + +DEFAULT_AND_RESET_PROPS = [ + PropertyDefaultInfo("intprop", True, 42, 42), + PropertyDefaultInfo("listprop", True, ["a", "list"], ["a", "list"]), + PropertyDefaultInfo("strprop", False, ...), + PropertyDefaultInfo("tupleprop", False, (42, 42)), + PropertyDefaultInfo("flistprop", True, [], []), + PropertyDefaultInfo("resettable_strprop", True, ..., "Reset"), + PropertyDefaultInfo("resettable_strprop_with_default", True, "Default", "Reset"), +] + + +@pytest.mark.parametrize("prop", DEFAULT_AND_RESET_PROPS) +def test_default_and_reset(prop: PropertyDefaultInfo): """Test retrieving property defaults, and resetting to default.""" class Example(lt.Thing): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._flistprop = [0] + self._resettable_strprop = "Hello World!" + self._resettable_strprop_with_default = "Hello World!" + intprop: int = lt.property(default=42) listprop: list[str] = lt.property(default_factory=lambda: ["a", "list"]) @lt.property def strprop(self) -> str: + """A functional property without resetter or default""" return "Hello World!" + @lt.property + def tupleprop(self) -> tuple[int, int]: + """A functional property with a default but no setter.""" + return (42, 42) + + tupleprop.default = (42, 42) + + @lt.property + def flistprop(self) -> list[int]: + """A functional property with a default and a setter.""" + return self._listprop + + @flistprop.setter + def _set_flistprop(self, value: list[int]) -> None: + self._listprop = value + + flistprop.default_factory = list + + @lt.property + def resettable_strprop(self) -> str: + """A string property that may be reset, but has no default defined.""" + return self._resettable_strprop + + @resettable_strprop.resetter + def _reset_resettable_strprop(self) -> None: + self._resettable_strprop = "Reset" + + @lt.property + def resettable_strprop_with_default(self) -> str: + """A string property with a default, and a resetter.""" + return self._resettable_strprop_with_default + + @resettable_strprop_with_default.setter + def _set_resettable_strprop_with_default(self, value: str): + self._resettable_strprop_with_default = value + + @resettable_strprop_with_default.resetter + def _reset_resettable_strprop_with_default(self): + self._resettable_strprop_with_default = "Reset" + + resettable_strprop_with_default.default_factory = lambda: "Default" + 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(FeatureNotAvailableError): - _ = thing.properties["strprop"].default + if prop.default is not ...: + assert thing.properties[prop.name].default == prop.default + else: + with pytest.raises(FeatureNotAvailableError): + _ = thing.properties[prop.name].default # Resetting to default isn't available on classes - for name in ["intprop", "listprop", "strprop"]: - with pytest.raises(NotBoundToInstanceError): - thing.properties[name].reset() + with pytest.raises(NotBoundToInstanceError): + thing.properties[prop.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(FeatureNotAvailableError): - example.properties["strprop"].reset() + assert thing.properties[prop.name].is_resettable is prop.resettable + + # Check resetting either works as expected, or fails with the right error + if prop.resettable: + example.properties[prop.name].reset() + assert getattr(example, prop.name) == prop.resets_to + else: + with pytest.raises(FeatureNotAvailableError): + example.properties[prop.name].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"] + if prop.default is not ...: + # The TD goes via JSON, so types may get changed + default = json.loads(json.dumps(prop.default)) + assert td["properties"][prop.name]["default"] == default + else: + assert "default" not in td["properties"][prop.name] + + +def test_reading_default_and_factory(): + """Ensure reading the default/factory does what's expected. + + Note that this is **not** the same as Example.properties["prop"].default, + which uses ``Example.prop.get_default()`` internally. + + This property really only exists for use during class definitions, and + would be write-only if that wasn't confusing! + """ + + class Example(lt.Thing): + @lt.property + def prop(self) -> int: + return 42 + + @lt.property + def prop_d(self) -> int: + return 42 + + prop_d.default = 42 + assert prop_d.default == 42 + assert prop_d.default_factory is not None + assert prop_d.default_factory() == 42 + + @lt.property + def prop_df(self) -> int: + return 42 + + prop_df.default_factory = lambda: 42 + assert prop_df.default == 42 + assert prop_df.default_factory is not None + assert prop_df.default_factory() == 42 + + with pytest.raises(FeatureNotAvailableError): + _ = Example.prop.default + assert Example.prop.default_factory is None + + assert Example.prop_d.default == 42 + assert Example.prop_d.default_factory is not None + assert Example.prop_d.default_factory() == 42 + + assert Example.prop_df.default == 42 + assert Example.prop_df.default_factory is not None + assert Example.prop_df.default_factory() == 42 + + +def test_bad_reset_decorator(): + """Check that a resetter can't have the same name as the property.""" + + with pytest.raises(PropertyRedefinitionError): + + class Example(lt.Thing): + @lt.property + def myprop(self) -> int: + return 42 + + @myprop.resetter + def myprop(self) -> None: + pass