From 18605bca15790b34b6c6957706c982bb1f85714e Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 5 May 2026 23:31:35 +0100 Subject: [PATCH 1/5] First implementation of @lt.on_set This adds basic side-effects to properties, along with some tests. --- src/labthings_fastapi/__init__.py | 3 +- src/labthings_fastapi/properties.py | 111 +++++++++++++++++++++++++++- tests/test_property.py | 60 +++++++++++++++ 3 files changed, 170 insertions(+), 4 deletions(-) diff --git a/src/labthings_fastapi/__init__.py b/src/labthings_fastapi/__init__.py index 4dad40c5..10cc841c 100644 --- a/src/labthings_fastapi/__init__.py +++ b/src/labthings_fastapi/__init__.py @@ -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 @@ -54,6 +54,7 @@ "ThingClassSettings", "property", "setting", + "on_set", "DataProperty", "DataSetting", "action", diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index e3de1b6b..99739d3a 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -48,6 +48,7 @@ class attribute. Documentation is in strings immediately following the from __future__ import annotations import builtins from collections.abc import Mapping +from functools import partial from types import EllipsisType from typing import ( Annotated, @@ -741,6 +742,13 @@ def __init__( ) 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. @@ -775,9 +783,10 @@ def __set__( ): 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) + if self.on_set_func: + value = self.on_set_func(obj, value) + obj.__dict__[self.name] = value if emit_changed_event: self.emit_changed_event(obj, value) @@ -853,6 +862,102 @@ async def emit_changed_event_async(self, obj: Thing, value: Value) -> None: ) +def on_set( + property_name: str, +) -> Callable[[Callable[[Owner, Value], Value]], OnSetDescriptor[Owner, Value]]: + """Run a function when a data 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. + + 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. + + :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. + """ + super().__init__() + 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, + not a data property, assigned to multiple times, or overwritten by + this descriptor. + """ + if self.property_name == name: + msg = f"On-set function '{name}' overwrites its property: rename it." + raise AttributeError(msg) + prop = getattr(owner, self.property_name, None) + if not isinstance(prop, DataProperty): + msg = "On-set functions may only be attached to data properties. " + msg += f"'{self.property_name}' is not a data property" + raise AttributeError(msg) + if prop.on_set_func is not None: + raise AttributeError(f"'{self.property_name}.on_set' has already been set.") + 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. diff --git a/tests/test_property.py b/tests/test_property.py index a778700c..44f17772 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -693,3 +693,63 @@ def myprop(self) -> int: @myprop.resetter def myprop(self) -> None: pass + + +def test_on_set(): + """Test that `on_set` works as expected.""" + + class Example(lt.Thing): + intprop: int = lt.property(default=0) + + shadow: int = lt.property(default=0) + + @lt.on_set("intprop") + def _on_set_intprop(self, val: int) -> int: + """A function to run when intprop is set.""" + if val < 0: + raise ValueError("Can't be negative.") + self.shadow = val + return val + + thing = create_thing_without_server(Example) + assert thing.shadow == 0 + thing.intprop = 42 + assert thing.shadow == 42 + with pytest.raises(ValueError, match="Can't be negative"): + thing.intprop = -1 + + +def test_bad_on_set_definitions(): + """Test that helpful errors are raise if `on_set` is used incorrectly.""" + with raises_or_is_caused_by(AttributeError) as excinfo: + + class Example2(lt.Thing): + @lt.on_set("missing") + def set_missing(self, value): + return value + + assert "'missing' is not a data property" in str(excinfo) + + with raises_or_is_caused_by(AttributeError) as excinfo: + + class Example3(lt.Thing): + @lt.on_set("myprop") + def myprop(self, value): + return value + + assert "On-set function 'myprop' overwrites its property" in str(excinfo) + + with raises_or_is_caused_by(AttributeError) as excinfo: + + class Example4(lt.Thing): + intprop: int = lt.property(default=0) + + @lt.on_set("intprop") + def set_intprop(self, value): + return value + + @lt.on_set("intprop") + def set_intprop2(self, value): + return value + + assert "'intprop.on_set' has already been set" in str(excinfo) From a8c8683768ca63cca45da37389c1bc56dfb40407 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 6 May 2026 09:28:34 +0100 Subject: [PATCH 2/5] Run `on_set` before validating. Running the `on_set` function before validating the property (if enabled) has a few advantages: * If the user has forgotten to return a value, it's more likely to catch the `None` if it's invalid. * It allows user code --- src/labthings_fastapi/properties.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index 99739d3a..b985c523 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -781,11 +781,11 @@ def __set__( with obj._thing_server_interface._optionally_hold_global_lock( self.use_global_lock ): + if self.on_set_func: + value = self.on_set_func(obj, value) if get_validate_properties_on_set(obj.__class__): property_info = self.descriptor_info(obj) value = property_info.validate(value) - if self.on_set_func: - value = self.on_set_func(obj, value) obj.__dict__[self.name] = value if emit_changed_event: self.emit_changed_event(obj, value) From 3045760312b22803410ca07ba87473bd9105ca80 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 26 May 2026 14:07:42 +0100 Subject: [PATCH 3/5] Add documentation This adds `on_set` to the public API docs and to the conceptual page on properties. It also tidies up a couple of decorators in the public API docs. --- docs/source/properties.rst | 33 ++++++++++++++++++++++++++ docs/source/public_api.rst | 36 +++++++++++++++++++++++++---- src/labthings_fastapi/properties.py | 11 ++++++++- 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/docs/source/properties.rst b/docs/source/properties.rst index 3abbf9fc..987e5303 100644 --- a/docs/source/properties.rst +++ b/docs/source/properties.rst @@ -56,6 +56,39 @@ 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 _my_property_was_set(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. +* 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. +* You do not need to "remember" the value as you would for a regular Python property - the data property already takes care of that. + Functional properties ------------------------- diff --git a/docs/source/public_api.rst b/docs/source/public_api.rst index 2b555507..e607321d 100644 --- a/docs/source/public_api.rst +++ b/docs/source/public_api.rst @@ -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. @@ -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) @@ -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. @@ -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. diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index b985c523..c9480f5b 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -867,17 +867,26 @@ def on_set( ) -> 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), 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. + 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. From fe4f5fcae75306f3f10ae79b31860d86dfedee34 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 26 May 2026 15:47:29 +0100 Subject: [PATCH 4/5] Tidy naming convention for `lt.on_set` functions. This now matches the test code. I've also improved testing and error checking, so that naming errors give a custom exception, and said exception should happen earlier with a stack trace pointing to the problematic method. I've added tests of the on_set descriptor's __get__ method too, to ensure it works as expected. --- docs/source/properties.rst | 3 ++- src/labthings_fastapi/properties.py | 22 +++++++++++++++------- tests/test_property.py | 13 +++++++++++-- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/docs/source/properties.rst b/docs/source/properties.rst index 987e5303..11ea4591 100644 --- a/docs/source/properties.rst +++ b/docs/source/properties.rst @@ -75,7 +75,7 @@ To do this, you should use the `lt.on_set` decorator as shown below: """A property that holds an integer value.""" @lt.on_set("my_property") - def _my_property_was_set(self, value: int): + def _on_set_my_property(self, value: int): """Take action because my_property was set.""" self._hardware.set_my_property(value) return value @@ -88,6 +88,7 @@ There are a few important points to note when using `lt.on_set` in your code: * 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. * You do not need to "remember" the value as you would for a regular Python property - the data property already takes care of that. +* 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 ------------------------- diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index c9480f5b..6635775d 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -912,8 +912,17 @@ def __init__( :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. """ 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. + msg = f"On-set function '{property_name}' overwrites its property: " + msg += "rename it." + raise PropertyRedefinitionError(msg) self.property_name = property_name self.func = func @@ -925,20 +934,19 @@ def __set_name__(self, owner: type[Owner], name: str) -> None: :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, - not a data property, assigned to multiple times, or overwritten by - this descriptor. + :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. """ - if self.property_name == name: - msg = f"On-set function '{name}' overwrites its property: rename it." - raise AttributeError(msg) prop = getattr(owner, self.property_name, None) if not isinstance(prop, DataProperty): msg = "On-set functions may only be attached to data properties. " msg += f"'{self.property_name}' is not a data property" raise AttributeError(msg) if prop.on_set_func is not None: - raise AttributeError(f"'{self.property_name}.on_set' has already been set.") + msg = f"'{self.property_name}.on_set' has already been set." + raise PropertyRedefinitionError(msg) prop.on_set_func = self.func @overload diff --git a/tests/test_property.py b/tests/test_property.py index 44f17772..158ca22f 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -12,8 +12,10 @@ """ from dataclasses import dataclass +import functools import json from typing import Any +import types import warnings import fastapi @@ -718,6 +720,13 @@ def _on_set_intprop(self, val: int) -> int: with pytest.raises(ValueError, match="Can't be negative"): thing.intprop = -1 + # Accessing the function on a class should return the function + assert isinstance(Example._on_set_intprop, types.FunctionType) + assert Example._on_set_intprop.__name__ == "_on_set_intprop" + # Accessing it on an instance should return a bound partial object + assert isinstance(thing._on_set_intprop, functools.partial) + assert thing._on_set_intprop.args == (thing,) + def test_bad_on_set_definitions(): """Test that helpful errors are raise if `on_set` is used incorrectly.""" @@ -730,7 +739,7 @@ def set_missing(self, value): assert "'missing' is not a data property" in str(excinfo) - with raises_or_is_caused_by(AttributeError) as excinfo: + with raises_or_is_caused_by(PropertyRedefinitionError) as excinfo: class Example3(lt.Thing): @lt.on_set("myprop") @@ -739,7 +748,7 @@ def myprop(self, value): assert "On-set function 'myprop' overwrites its property" in str(excinfo) - with raises_or_is_caused_by(AttributeError) as excinfo: + with raises_or_is_caused_by(PropertyRedefinitionError) as excinfo: class Example4(lt.Thing): intprop: int = lt.property(default=0) From c7834cfd9b454bd48af01a5d3652656551410a15 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 26 May 2026 15:50:31 +0100 Subject: [PATCH 5/5] Parametrize test for on_set to add settings. --- tests/test_property.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/test_property.py b/tests/test_property.py index 158ca22f..9dbdc9f0 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -697,11 +697,16 @@ def myprop(self) -> None: pass -def test_on_set(): - """Test that `on_set` works as expected.""" +@pytest.mark.parametrize("prop_or_setting", [lt.property, lt.setting]) +def test_on_set(prop_or_setting): + """Test that `on_set` works as expected. + + Note that this test is parametrised so that it checks both properties + and settings. + """ class Example(lt.Thing): - intprop: int = lt.property(default=0) + intprop: int = prop_or_setting(default=0) shadow: int = lt.property(default=0)