Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions docs/source/properties.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
--------------

Expand Down
9 changes: 9 additions & 0 deletions src/labthings_fastapi/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
173 changes: 165 additions & 8 deletions src/labthings_fastapi/properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
from .exceptions import (
FeatureNotAvailableError,
NotConnectedToServerError,
PropertyRedefinitionError,
ReadOnlyPropertyError,
MissingTypeError,
UnsupportedConstraintError,
Expand Down Expand Up @@ -394,7 +395,7 @@
)
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.
Expand All @@ -403,9 +404,9 @@
:return: the default value of this property.
:raises FeatureNotAvailableError: as this must be overridden.
"""
raise FeatureNotAvailableError(

Check warning on line 407 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

407 line is not covered with tests
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:
Expand All @@ -421,7 +422,7 @@
:param obj: the `.Thing` instance we want to reset.
:raises FeatureNotAvailableError: as only some subclasses implement resetting.
"""
raise FeatureNotAvailableError(

Check warning on line 425 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

425 line is not covered with tests
f"{obj.name}.{self.name} cannot be reset, as it's not supported by "
f"{self.__class__}."
)
Expand Down Expand Up @@ -501,7 +502,7 @@
),
)
def reset() -> None:
self.reset(thing)

Check warning on line 505 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

505 line is not covered with tests

def property_affordance(
self, thing: Owner, path: str | None = None
Expand Down Expand Up @@ -534,7 +535,7 @@
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()
Expand Down Expand Up @@ -686,7 +687,7 @@
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,
Expand All @@ -705,7 +706,7 @@

: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.
Expand Down Expand Up @@ -791,12 +792,24 @@
self._fget = fget
self._type = return_type(self._fget)
if self._type is None:
msg = (

Check warning on line 795 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

795 line is not covered with tests
f"{fget} does not have a valid type. "
"Return type annotations are required for property getters."
)
raise MissingTypeError(msg)

Check warning on line 799 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

799 line is not covered with tests
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
Expand All @@ -818,10 +831,10 @@
:param fget: The new getter function.
:return: this descriptor (i.e. ``self``). This allows use as a decorator.
"""
self._fget = fget
self._type = return_type(self._fget)
self.__doc__ = fget.__doc__
return self

Check warning on line 837 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

834-837 lines are not covered with tests

def setter(self, fset: Callable[[Owner, Value], None]) -> Self:
r"""Set the setter function of the property.
Expand Down Expand Up @@ -893,7 +906,7 @@
# Don't return the descriptor if it's named differently.
# see typing notes in docstring.
return fset # type: ignore[return-value]
return self

Check warning on line 909 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

909 line is not covered with tests

def instance_get(self, obj: Owner) -> Value:
"""Get the value of the property.
Expand All @@ -912,9 +925,153 @@
:raises ReadOnlyPropertyError: if the property cannot be set.
"""
if self.fset is None:
raise ReadOnlyPropertyError(f"Property {self.name} of {obj} has no setter.")

Check warning on line 928 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

928 line is not covered with tests
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],
Expand Down Expand Up @@ -966,7 +1123,7 @@
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
Expand Down Expand Up @@ -1163,7 +1320,7 @@

:raises NotImplementedError: this method should be implemented in subclasses.
"""
raise NotImplementedError("This method should be implemented in subclasses.")

Check warning on line 1323 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

1323 line is not covered with tests

def descriptor_info(self, owner: Owner | None = None) -> SettingInfo[Owner, Value]:
r"""Return an object that allows access to this descriptor's metadata.
Expand Down
Loading
Loading