Merge pull request #131 from tiqi-group/85-optionally-call-getter-after-setter

Adding validate_set decorator to ensure values are set correctly
This commit is contained in:
Mose Müller 2024-05-28 13:12:39 +02:00 committed by GitHub
commit fe5d0eed2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 281 additions and 2 deletions

View File

@ -30,6 +30,7 @@
- [Controlling Property State Loading with `@load_state`](#controlling-property-state-loading-with-load_state)
- [Understanding Tasks in pydase](#understanding-tasks-in-pydase)
- [Understanding Units in pydase](#understanding-units-in-pydase)
- [Using `validate_set` to Validate Property Setters](#using-validate_set-to-validate-property-setters)
- [Configuring pydase via Environment Variables](#configuring-pydase-via-environment-variables)
- [Customizing the Web Interface](#customizing-the-web-interface)
- [Enhancing the Web Interface Style with Custom CSS](#enhancing-the-web-interface-style-with-custom-css)
@ -52,6 +53,7 @@
- [Saving and restoring the service state for service persistence](#understanding-service-persistence)
- [Automated task management with built-in start/stop controls and optional autostart](#understanding-tasks-in-pydase)
- [Support for units](#understanding-units-in-pydase)
- [Validating Property Setters](#using-validate_set-to-validate-property-setters)
<!-- Support for additional servers for specific use-cases -->
## Installation
@ -800,6 +802,45 @@ if __name__ == "__main__":
For more information about what you can do with the units, please consult the documentation of [`pint`](https://pint.readthedocs.io/en/stable/).
## Using `validate_set` to Validate Property Setters
The `validate_set` decorator ensures that a property setter reads back the set value using the property getter and checks it against the desired value.
This decorator can be used to validate that a parameter has been correctly set on a device within a specified precision and timeout.
The decorator takes two keyword arguments: `timeout` and `precision`. The `timeout` argument specifies the maximum time (in seconds) to wait for the value to be within the precision boundary.
If the value is not within the precision boundary after this time, an exception is raised.
The `precision` argument defines the acceptable deviation from the desired value.
If `precision` is `None`, the value must be exact.
For example, if `precision` is set to `1e-5`, the value read from the device must be within ±0.00001 of the desired value.
Heres how to use the `validate_set` decorator in a `DataService` class:
```python
import pydase
from pydase.observer_pattern.observable.decorators import validate_set
class Service(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._device = RemoteDevice() # dummy class
@property
def value(self) -> float:
# Implement how to get the value from the remove device...
return self._device.value
@value.setter
@validate_set(timeout=1.0, precision=1e-5)
def value(self, value: float) -> None:
# Implement how to set the value from the remove device...
self._device.value = value
if __name__ == "__main__":
pydase.Server(Service()).run()
```
## Configuring pydase via Environment Variables
Configuring `pydase` through environment variables enhances flexibility, security, and reusability. This approach allows for easy adaptation of services across different environments without code changes, promoting scalability and maintainability. With that, it simplifies deployment processes and facilitates centralized configuration management. Moreover, environment variables enable separation of configuration from code, aiding in secure and collaborative development.

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "pydase"
version = "0.8.3"
version = "0.8.4"
description = "A flexible and robust Python library for creating, managing, and interacting with data services, with built-in support for web and RPC servers, and customizable features for diverse use cases."
authors = ["Mose Mueller <mosmuell@ethz.ch>"]
readme = "README.md"

View File

@ -0,0 +1,101 @@
import time
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
if TYPE_CHECKING:
from pydase.observer_pattern.observable.observable import Observable
P = ParamSpec("P")
R = TypeVar("R")
def validate_set(
*, timeout: float = 0.1, precision: float | None = None
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""
Decorator marking a property setter to read back the set value using the property
getter and check against the desired value.
Args:
timeout (float):
The maximum time (in seconds) to wait for the value to be within the
precision boundary.
precision (float | None):
The acceptable deviation from the desired value. If None, the value must be
exact.
"""
def validate_set_decorator(func: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(*args, **kwargs)
wrapper._validate_kwargs = { # type: ignore
"timeout": timeout,
"precision": precision,
}
return wrapper
return validate_set_decorator
def has_validate_set_decorator(prop: property) -> bool:
"""
Checks if a property setter has been decorated with the `validate_set` decorator.
Args:
prop (property):
The property to check.
Returns:
bool:
True if the property setter has the `validate_set` decorator, False
otherwise.
"""
property_setter = prop.fset
return hasattr(property_setter, "_validate_kwargs")
def _validate_value_was_correctly_set(
*,
obj: "Observable",
name: str,
value: Any,
) -> None:
"""
Validates if the property `name` of `obj` attains the desired `value` within the
specified `precision` and time `timeout`.
Args:
obj (Observable):
The instance of the class containing the property.
name (str):
The name of the property to validate.
value (Any):
The desired value to check against.
Raises:
ValueError:
If the property value does not match the desired value within the specified
precision and timeout.
"""
prop: property = getattr(type(obj), name)
timeout = prop.fset._validate_kwargs["timeout"] # type: ignore
precision = prop.fset._validate_kwargs["precision"] # type: ignore
if precision is None:
precision = 0.0
start_time = time.time()
while time.time() - start_time < timeout:
current_value = obj.__getattribute__(name)
# This check is faster than rounding and comparing to 0
if abs(current_value - value) <= precision:
return
time.sleep(0.01)
raise ValueError(
f"Failed to set value to {value} within {timeout} seconds. Current value: "
f"{current_value}."
)

View File

@ -1,6 +1,10 @@
import logging
from typing import Any
from pydase.observer_pattern.observable.decorators import (
_validate_value_was_correctly_set,
has_validate_set_decorator,
)
from pydase.observer_pattern.observable.observable_object import ObservableObject
from pydase.utils.helpers import is_property_attribute
@ -35,7 +39,12 @@ class Observable(ObservableObject):
super().__setattr__(name, value)
self._notify_changed(name, value)
if is_property_attribute(self, name) and has_validate_set_decorator(
getattr(type(self), name)
):
_validate_value_was_correctly_set(obj=self, name=name, value=value)
else:
self._notify_changed(name, value)
def __getattribute__(self, name: str) -> Any:
if is_property_attribute(self, name):

View File

@ -0,0 +1,128 @@
import asyncio
import threading
import pydase
import pytest
from pydase.observer_pattern.observable.decorators import validate_set
def linspace(start: float, stop: float, n: int):
if n == 1:
yield stop
return
h = (stop - start) / (n - 1)
for i in range(n):
yield start + h * i
def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
asyncio.set_event_loop(loop)
loop.run_forever()
def test_validate_set_precision(caplog: pytest.LogCaptureFixture) -> None:
class Service(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._value_1 = 0.0
self._value_2 = 0.0
@property
def value_1(self) -> float:
return self._value_1
@value_1.setter
@validate_set(precision=None)
def value_1(self, value: float) -> None:
self._value_1 = round(value, 1)
@property
def value_2(self) -> float:
return self._value_2
@value_2.setter
@validate_set(precision=1e-1)
def value_2(self, value: float) -> None:
self._value_2 = round(value, 1)
service_instance = Service()
pydase.Server(service_instance) # needed to initialise observer
with pytest.raises(ValueError) as exc_info:
service_instance.value_1 = 1.12
assert "Failed to set value to 1.12 within 1 second. Current value: 1.1" in str(
exc_info
)
caplog.clear()
service_instance.value_2 = 1.12 # no assertion raised
assert service_instance.value_2 == 1.1 # noqa
assert "'value_2' changed to '1.1'" in caplog.text
def test_validate_set_timeout(caplog: pytest.LogCaptureFixture) -> None:
class RemoteDevice:
def __init__(self) -> None:
self._value = 0.0
self.loop = asyncio.new_event_loop()
self._lock = asyncio.Lock()
self.thread = threading.Thread(
target=asyncio_loop_thread, args=(self.loop,), daemon=True
)
self.thread.start()
def __del__(self) -> None:
self.loop.call_soon_threadsafe(self.loop.stop)
self.thread.join()
@property
def value(self) -> float:
future = asyncio.run_coroutine_threadsafe(self._get_value(), self.loop)
return future.result()
async def _get_value(self) -> float:
return self._value
@value.setter
def value(self, value: float) -> None:
self.loop.create_task(self.set_value(value))
async def set_value(self, value) -> None:
for i in linspace(self._value, value, 10):
self._value = i
await asyncio.sleep(0.1)
class Service(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._driver = RemoteDevice()
@property
def value_1(self) -> float:
return self._driver.value
@value_1.setter
@validate_set(timeout=0.5)
def value_1(self, value: float) -> None:
self._driver.value = value
@property
def value_2(self) -> float:
return self._driver.value
@value_2.setter
@validate_set(timeout=1)
def value_2(self, value: float) -> None:
self._driver.value = value
service_instance = Service()
with pytest.raises(ValueError) as exc_info:
service_instance.value_1 = 2.0
assert "Failed to set value to 2.0 within 0.5 seconds. Current value:" in str(
exc_info
)
service_instance.value_2 = 3.0 # no assertion raised