Skip to content
Open
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
34 changes: 34 additions & 0 deletions docs/source/properties.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,40 @@ The example above will have its default value set to the empty list, as that's w

Data properties may be *observed*, which means notifications will be sent when the property is written to (see below).

.. _properties_on_set:

Data properties with setters
----------------------------

It is possible to add a method that will be called each time a data property (or setting) is set. This may be useful in several situations:

* You want to do some validation or coercion that's not done by the type hint and constraints.
* There should be a side-effect of setting the property, like updating a setting on some hardware.

To do this, you should use the `lt.on_set` decorator as shown below:

.. code-block:: python

class MyThing(lt.Thing):
my_property: int = lt.property(default=42, readonly=True)
"""A property that holds an integer value."""

@lt.on_set("my_property")
def _on_set_my_property(self, value: int):
"""Take action because my_property was set."""
self._hardware.set_my_property(value)
return value

There are a few important points to note when using `lt.on_set` in your code:

* Your function *must* return a value, which will be used as the property's value. This allows `lt.on_set` to coerce values to valid ones.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are any errors or warning messages raised if the function doesn't return a value in the on_set function?
Might be helpful to a user, and we can write a test for it

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, I think there should be an error if an on_set function returns a value that's not valid against the property's model. That probably isn't very helpful in what it says, I should take a look.

I don't think it's possible to distinguish between not returning a value and returning None. If None is a valid value, I don't think there's a sensible runtime check that would work.

I will take a look at the validation error that gets raised and see whether it could have a helpful message added if there's an on_set function present.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have implemented (and tested) two checks:

  1. I now test that the function has a return type annotation. I don't check it matches the property (that's complicated by deferred annotations) but it should mean mypy enforces that it has to return a value, if mypy is in use.
  2. I check if the on_set function modified the value. If so, I check if the modified value is None. If that happens, I log a warning. I'm not 100% sure I like this - I would welcome thoughts.

I suppose I could ask the function to return (True, value) to make it easier to spot - but I'm not sure I like that either...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the checks that have been implemented. One other idea I had is to change what the function is expected to return, so that an implicit None becomes an immediate, catchable error (i.e. force all on-set functions to return a specific dataclass or something). Instead of asking users to make on_set return a raw value or None, we could require them to return a specific class.

Here is a kind of example:

from dataclasses import dataclass
from typing import Any

@dataclass
class SetValue:
    value: Any

# Inside the descriptor:
result = self.on_set_func(obj, value)

if result is None:
    raise ValueError(
        f"You forgot to return a value in {self.name}.on_set! "
        "You must return SetValue(new_value) or SetValue(None)."
    )

if not isinstance(result, SetValue):
    raise TypeError(f"on_set must return a SetValue instance, got {type(result)}.")

value = result.value

SetValue(None) means "I explicitly want to set this to None." An implicit None raises a loud, helpful error. The downside is a slightly more verbose API for the user.

Just an idea, maybe not a useful one! I think we keep the return type-hinting check regardless, I think thats super useful

Copy link
Copy Markdown
Collaborator Author

@rwb27 rwb27 Jun 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about changing both the name and the mode of operation to look a bit like a Pydantic "wrap validator"?

class MyThing(lt.Thing):
    intprop: int = lt.property(default=0)

    @lt.wrap_setter("intprop")
    def _wrap_set_intprop(self, value: int, setter: lt.Setter[int]) -> None:
        # my logic goes here (exceptions prevent the value changing
        setter(value)
        # I can add more logic here if I want it to run after the value is set

lt.Setter[int] would be a callable accepting an int, and when it's called it would set the value. That way, it's clear which code executes before and after setting the value.

The documentation ought to make it clear that you must either call the setter (exactly once) or raise an exception. That way, it's clear when we have a logic error. I think the new name is likely less confusing, and removes any ambiguity around when it's called in relation to the value changing.

PS I've edited this comment a few times to remove stream-of-consciousness wittering. Sorry if you saw a previous version.

* If your function raises an exception, the value will *not* be set, and the property will keep its previous value. This allows invalid values to be rejected.
* Your function will run every time the property is set, meaning it should complete quickly. If this function takes longer than a second, it is likely to cause HTTP timeouts.
* It's ok to communicate with hardware, but you are likely to need to acquire any locks you need manually.
* If global locking is enabled, the global lock will already have been acquired when your function is run: there's no need to acquire it again.
* There is no need to store the value in a private attribute or provide a getter function.
* You must not use the name of the property as the name of the function: this will overwrite the property and cause an error.

Functional properties
-------------------------

Expand Down
36 changes: 31 additions & 5 deletions docs/source/public_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ This page summarises the parts of the LabThings API that should be most frequent
.. automethod:: labthings_fastapi.thing.Thing.get_current_invocation_logs
:no-index:

.. py:function:: property(getter: Callable[[Owner], Value]) -> FunctionalProperty[Owner, Value]
property(*, default: Value, readonly: bool = False, use_global_lock: bool | None = None, **constraints: Any) -> Value
.. py:function:: property(*, default: Value, readonly: bool = False, use_global_lock: bool | None = None, **constraints: Any) -> Value
property(*, default_factory: Callable[[], Value], readonly: bool = False, use_global_lock: bool | None = None, **constraints: Any) -> Value
@lt.property

This function may be used to define :ref:`properties` either by decorating a function, or marking an attribute. Full documentation is available at `labthings_fastapi.properties.property` and a more in-depth discussion is available at :ref:`properties`\ . This page focuses on the most frequently used examples.

Expand Down Expand Up @@ -142,12 +142,37 @@ This page summarises the parts of the LabThings API that should be most frequent
For a full listing of attributes that may be modified, see `DataProperty`\ .


.. py:function:: setting(getter: Callable[[Owner], Value]) -> FunctionalSetting[Owner, Value]
setting(*, default: Value, readonly: bool = False, use_global_lock: bool | None = None, **constraints: Any) -> Value
.. py:function:: setting(default: Value, readonly: bool = False, use_global_lock: bool | None = None, **constraints: Any) -> Value
setting(*, default_factory: Callable[[], Value], readonly: bool = False, use_global_lock: bool | None = None, **constraints: Any) -> Value
@lt.setting

A setting is a property that is saved to disk. It is defined in the same way as `property` but will be synchronised with the `Thing`\ 's settings file. Full documentation is available at `labthings_fastapi.properties.setting`

.. py:decorator:: on_set(property_name: str)

Decorate a method to run when a data `~lt.property` is set.

This decorator causes a method to be called whenever a property
is set. The method must return the value (and may modify it), but
is not responsible for "remembering" the value: that's done by
the data property.

`on_set` methods should have only ``self`` and the property value as arguments,
and must either return a valid value for the property, or raise an exception.
They are intended to allow validation and coercion of values, as well as
allowing synchronisation, for example synchronising the value of a setting with
a piece of hardware.

If the method raises an exception, the property will not change
its value, and the error will propagate.

Side effects should be brief: they are performed synchronously
during HTTP request handling, so should not exceed a fraction
of a second. This is similar to the constraint on functional property setters:
anything likely to take a long time should be done in an action instead.

:param property_name: the name of the property to which we are
attaching a side effect.

.. py:decorator:: action
action(use_global_lock: bool | None = None, **kwargs: Any)
Expand Down Expand Up @@ -277,7 +302,7 @@ This page summarises the parts of the LabThings API that should be most frequent
:param \**kwargs: additional keyword arguments are passed to `ThingServerConfig`\ .

.. py:property:: things
:type: collections.abc.Mapping[str, Thing]
:type: collections.abc.Mapping[str, lt.Thing]

A read-only mapping of names to `~lt.Thing` instances, for every `~lt.Thing` attached to the server.

Expand Down Expand Up @@ -333,6 +358,7 @@ This page summarises the parts of the LabThings API that should be most frequent
:no-index:

.. py:property:: global_lock

:type GlobalLock | None:

A global lock object that is used to restrict concurrent execution of actions and setting of properties.
Expand Down
3 changes: 2 additions & 1 deletion src/labthings_fastapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from .thing_slots import thing_slot
from .thing_server_interface import ThingServerInterface
from .thing_class_settings import ThingClassSettings
from .properties import property, setting, DataProperty, DataSetting
from .properties import property, setting, on_set, DataProperty, DataSetting
from .actions import action
from .endpoints import endpoint
from . import outputs
Expand All @@ -54,6 +54,7 @@
"ThingClassSettings",
"property",
"setting",
"on_set",
"DataProperty",
"DataSetting",
"action",
Expand Down
148 changes: 145 additions & 3 deletions src/labthings_fastapi/properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
from __future__ import annotations
import builtins
from collections.abc import Mapping
from functools import partial
import inspect
from types import EllipsisType
from typing import (
Annotated,
Expand Down Expand Up @@ -481,7 +483,7 @@
:return: the default value of this property.
:raises FeatureNotAvailableError: as this must be overridden.
"""
raise FeatureNotAvailableError(

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

View workflow job for this annotation

GitHub Actions / coverage

486 line is not covered with tests
f"{obj.name if obj else self.__class__}.{self.name} can't return a "
f"default, as it's not supported by {self.__class__}."
)
Expand All @@ -499,7 +501,7 @@
:param obj: the `~lt.Thing` instance we want to reset.
:raises FeatureNotAvailableError: as only some subclasses implement resetting.
"""
raise FeatureNotAvailableError(

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

View workflow job for this annotation

GitHub Actions / coverage

504 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 @@ -579,7 +581,7 @@
),
)
def reset() -> None:
self.reset(thing)

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

View workflow job for this annotation

GitHub Actions / coverage

584 line is not covered with tests

def property_affordance(
self, thing: Owner, path: str | None = None
Expand Down Expand Up @@ -741,6 +743,13 @@
)
self.readonly = readonly

on_set_func: Callable[[Owner, Value], Value] | None = None
"""A function that is called when the property is set.

This function must return the new value of the property. If it raises
an exception, the property's value will not change.
"""

def instance_get(self, obj: Owner) -> Value:
"""Return the property's value.

Expand Down Expand Up @@ -773,11 +782,20 @@
with obj._thing_server_interface._optionally_hold_global_lock(
self.use_global_lock
):
if self.on_set_func:
original_value = value
value = self.on_set_func(obj, value)
# This warning tries to make it easier to spot if the on_set
# function has forgotten to return a value.
if value is None and original_value is not None:
obj.logger.warning(
f"{self.name}.on_set modified the value from "
f"'{original_value}' to None."
)
if get_validate_properties_on_set(obj.__class__):
property_info = self.descriptor_info(obj)
obj.__dict__[self.name] = property_info.validate(value)
else:
obj.__dict__[self.name] = value
value = property_info.validate(value)
obj.__dict__[self.name] = value
if emit_changed_event:
self.emit_changed_event(obj, value)

Expand Down Expand Up @@ -853,6 +871,130 @@
)


def on_set(
property_name: str,
) -> Callable[[Callable[[Owner, Value], Value]], OnSetDescriptor[Owner, Value]]:
"""Run a function when a data property is set.

See the description at :ref:`properties_on_set` for an example.

This decorator causes a method to be called whenever a property
is set. The method must return the value (and may modify it), or raise an exception,
but is not responsible for "remembering" the value: that's done by
the data property.

`on_set` methods should have only ``self`` and the property value as arguments,
and must either return a valid value for the property, or raise an exception.
They are intended to allow validation and coercion of values, as well as
allowing synchronisation, for example synchronising the value of a setting with
a piece of hardware.

If the method raises an exception, the property will not change
its value, and the error will propagate.

Side effects should be brief: they are performed synchronously
during HTTP request handling, so should not exceed a fraction
of a second. This is similar to the constraint on functional property setters:
anything likely to take a long time should be done in an action instead.

:param property_name: the name of the property to which we are
attaching a side effect.
:return: a descriptor object that will attach the method to the
property, once the class is fully defined.
"""

def decorator(
func: Callable[[Owner, Value], Value],
) -> OnSetDescriptor[Owner, Value]:
return OnSetDescriptor(property_name=property_name, func=func)

return decorator


class OnSetDescriptor(Generic[Owner, Value]):
"""A class to add side effects to data properties."""

def __init__(
self, property_name: str, func: Callable[[Owner, Value], Value]
) -> None:
"""Initialise an OnSetDescriptor.

:param property_name: the name of the property we're attaching a side-effect to.
:param func: the function to run when the property is set.
:raises PropertyRedefinitionError: if the `lt.on_set` function has the same name
as its property. This is not allowed, as it will cause the property to be
overwritten.
:raises MissingTypeError: if there's no type hint for the return value. This is
the closest we can get to checking that the function returns a value,
which is required for `~lt.on_set` to work properly.
"""
super().__init__()
if func.__name__ == property_name:
# Note: this is also checked in __set_name__, but it raises a more helpful
# error if it's checked here.
raise PropertyRedefinitionError(
f"On-set function '{property_name}' overwrites its property: rename it."
)
sig = inspect.signature(func)
if sig.return_annotation == inspect.Signature.empty:
raise MissingTypeError(
f"On-set function '{func.__name__}' does not have a return type "
"annotation. On-set functions must return a value, so an annotation "
"is required."
)
self.property_name = property_name
self.func = func

def __set_name__(self, owner: type[Owner], name: str) -> None:
"""Attach the function to the property.

``__set_name__`` is part of the Descriptor protocol, and is where we
are notified of the owning class and our name.

:param owner: the class on which we are defined.
:param name: the name to which this descriptor is assigned.
:raises AttributeError: if the specified property name is missing or
not a data property.
:raises PropertyRedefinitionError: if the specified property already has
an `lt.on_set` method.
"""
prop = getattr(owner, self.property_name, None)
if not isinstance(prop, DataProperty):
raise AttributeError(
"On-set functions may only be attached to data properties. "
f"'{self.property_name}' is not a data property"
)
if prop.on_set_func is not None:
msg = f"'{self.property_name}.on_set' has already been set."
raise PropertyRedefinitionError(msg)
prop.on_set_func = self.func

@overload
def __get__(self, obj: Owner) -> Callable[[Value], Value]: ...

@overload
def __get__(
self, obj: None, type: type[Owner]
) -> Callable[[Owner, Value], Value]: ...

def __get__(
self, obj: Owner | None, type: type[Owner] | None = None
) -> Callable[[Owner, Value], Value] | Callable[[Value], Value]:
"""Return the function.

As for regular methods, we return the function if accessed on the class, and
a bound version if accessed on an instance.

:param obj: the instance, if accessed on an instance.
:param type: the class, if accessed on a class.
:return: the function, or a partial object binding the function to the object.
"""
if obj is None:
return self.func
else:
return partial(self.func, obj)


class FunctionalProperty(BaseProperty[Owner, Value], Generic[Owner, Value]):
"""A property that uses a getter and a setter.

Expand Down Expand Up @@ -894,11 +1036,11 @@
# BaseDescriptor parses __doc__ to generate the title and description.
self.__doc__ = fget.__doc__
if self._type is None:
msg = (

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

View workflow job for this annotation

GitHub Actions / coverage

1039 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 1043 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

1043 line is not covered with tests
self._fset: Callable[[Owner, Value], None] | None = None # setter function
# `_freset` should reset the property to its default value.
self._freset: (
Expand Down Expand Up @@ -994,7 +1136,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 1139 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

1139 line is not covered with tests

def instance_get(self, obj: Owner) -> Value:
"""Get the value of the property.
Expand All @@ -1017,7 +1159,7 @@
: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 1162 in src/labthings_fastapi/properties.py

View workflow job for this annotation

GitHub Actions / coverage

1162 line is not covered with tests
if get_validate_properties_on_set(obj.__class__):
property_info = self.descriptor_info(obj)
value = property_info.validate(value)
Expand Down Expand Up @@ -1433,7 +1575,7 @@

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

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

View workflow job for this annotation

GitHub Actions / coverage

1578 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