mirror of
https://github.com/tiqi-group/pydase.git
synced 2025-04-20 00:10:03 +02:00
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:
commit
fe5d0eed2d
41
README.md
41
README.md
@ -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.
|
||||
|
||||
Here’s 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.
|
||||
|
@ -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"
|
||||
|
101
src/pydase/observer_pattern/observable/decorators.py
Normal file
101
src/pydase/observer_pattern/observable/decorators.py
Normal 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}."
|
||||
)
|
@ -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):
|
||||
|
128
tests/observer_pattern/observable/test_decorators.py
Normal file
128
tests/observer_pattern/observable/test_decorators.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user