From 203cc0f0f5d8005f2cbcf01313f1cab77600e3c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Tue, 28 May 2024 11:15:08 +0200 Subject: [PATCH 1/7] adds validate_set decorator --- .../observer_pattern/observable/decorators.py | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/pydase/observer_pattern/observable/decorators.py diff --git a/src/pydase/observer_pattern/observable/decorators.py b/src/pydase/observer_pattern/observable/decorators.py new file mode 100644 index 0000000..550a066 --- /dev/null +++ b/src/pydase/observer_pattern/observable/decorators.py @@ -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 = { + "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}." + ) From 052bf79487f0c265336aaa4829b8143894ccea87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Tue, 28 May 2024 11:21:55 +0200 Subject: [PATCH 2/7] adds setattr validation to observable if validate_set decorator is used --- src/pydase/observer_pattern/observable/observable.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/pydase/observer_pattern/observable/observable.py b/src/pydase/observer_pattern/observable/observable.py index b22c71d..c24b64c 100644 --- a/src/pydase/observer_pattern/observable/observable.py +++ b/src/pydase/observer_pattern/observable/observable.py @@ -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): From f49cdd87e48ff691d91456c6148e15536ea403ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Tue, 28 May 2024 11:45:43 +0200 Subject: [PATCH 3/7] updates Readme --- README.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/README.md b/README.md index 7d86433..a60c20d 100644 --- a/README.md +++ b/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) ## 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. From 00c6d4c06820c718dfeed1a11849dcd2f56819ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Tue, 28 May 2024 12:07:52 +0200 Subject: [PATCH 4/7] adds validate_set decorator precision test --- .../observable/test_decorators.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/observer_pattern/observable/test_decorators.py diff --git a/tests/observer_pattern/observable/test_decorators.py b/tests/observer_pattern/observable/test_decorators.py new file mode 100644 index 0000000..6f9164f --- /dev/null +++ b/tests/observer_pattern/observable/test_decorators.py @@ -0,0 +1,45 @@ +import pydase +import pytest +from pydase.observer_pattern.observable.decorators import validate_set + + +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 From 7fdd08021a59ecf1de740fe3fc03b6cf8f8377a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Tue, 28 May 2024 12:56:43 +0200 Subject: [PATCH 5/7] ignore mypy error --- src/pydase/observer_pattern/observable/decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pydase/observer_pattern/observable/decorators.py b/src/pydase/observer_pattern/observable/decorators.py index 550a066..fe21f54 100644 --- a/src/pydase/observer_pattern/observable/decorators.py +++ b/src/pydase/observer_pattern/observable/decorators.py @@ -29,7 +29,7 @@ def validate_set( def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return func(*args, **kwargs) - wrapper._validate_kwargs = { + wrapper._validate_kwargs = { # type: ignore "timeout": timeout, "precision": precision, } From ae791502528ec908e8b936b30578a26fb3f85196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Tue, 28 May 2024 13:08:01 +0200 Subject: [PATCH 6/7] adds tests for validate_set timeout --- .../observable/test_decorators.py | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/observer_pattern/observable/test_decorators.py b/tests/observer_pattern/observable/test_decorators.py index 6f9164f..8772e6d 100644 --- a/tests/observer_pattern/observable/test_decorators.py +++ b/tests/observer_pattern/observable/test_decorators.py @@ -1,8 +1,25 @@ +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: @@ -43,3 +60,69 @@ def test_validate_set_precision(caplog: pytest.LogCaptureFixture) -> None: 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 From a11ab1520f1fac43a18cc53dfe4c1a37f9f0e678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Tue, 28 May 2024 13:12:15 +0200 Subject: [PATCH 7/7] updates version to v0.8.4 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6304dcc..454d060 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] readme = "README.md"