diff --git a/README.md b/README.md index ef4e766..d8e2b9a 100644 --- a/README.md +++ b/README.md @@ -398,7 +398,32 @@ if __name__ == "__main__": In this example, the state of the `Device` service will be saved to `device_state.json` when the service is shut down. If `device_state.json` exists when the server is started, the state manager will restore the state of the service from this file. -Note: If the service class structure has changed since the last time its state was saved, only the attributes that have remained the same will be restored from the settings file. +### Controlling Property State Loading with `@load_state` + +By default, the state manager only restores values for public attributes of your service. If you have properties that you want to control the loading for, you can use the `@load_state` decorator on your property setters. This indicates to the state manager that the value of the property should be loaded from the state file. + +Here is how you can apply the `@load_state` decorator: + +```python +from pydase import DataService +from pydase.data_service.state_manager import load_state + +class Device(DataService): + _name = "Default Device Name" + + @property + def name(self) -> str: + return self._name + + @name.setter + @load_state + def name(self, value: str) -> None: + self._name = value +``` + +With the `@load_state` decorator applied to the `name` property setter, the state manager will load and apply the `name` property's value from the file storing the state upon server startup, assuming it exists. + +Note: If the service class structure has changed since the last time its state was saved, only the attributes and properties decorated with `@load_state` that have remained the same will be restored from the settings file. ## Understanding Tasks in pydase diff --git a/src/pydase/data_service/state_manager.py b/src/pydase/data_service/state_manager.py index d43d5e5..82b1c66 100644 --- a/src/pydase/data_service/state_manager.py +++ b/src/pydase/data_service/state_manager.py @@ -1,6 +1,7 @@ import json import logging import os +from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, Any, Optional, cast @@ -8,7 +9,6 @@ import pydase.units as u from pydase.data_service.data_service_cache import DataServiceCache from pydase.utils.helpers import ( get_object_attr_from_path_list, - is_property_attribute, parse_list_attr_and_index, ) from pydase.utils.serializer import ( @@ -23,6 +23,39 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +def load_state(func: Callable[..., Any]) -> Callable[..., Any]: + """This function should be used as a decorator on property setters to indicate that + the value should be loaded from the JSON file. + + Example: + >>> class Service(pydase.DataService): + ... _name = "Service" + ... + ... @property + ... def name(self) -> str: + ... return self._name + ... + ... @name.setter + ... @load_state + ... def name(self, value: str) -> None: + ... self._name = value + """ + + func._load_state = True + return func + + +def has_load_state_decorator(prop: property): + """Determines if the property's setter method is decorated with the `@load_state` + decorator. + """ + + try: + return getattr(prop.fset, "_load_state") + except AttributeError: + return False + + class StateManager: """ Manages the state of a DataService instance, serving as both a cache and a @@ -211,6 +244,8 @@ class StateManager: def __attr_value_should_change(self, parent_object: Any, attr_name: str) -> bool: # If the attribute is a property, change it using the setter without getting # the property value (would otherwise be bad for expensive getter methods) - if is_property_attribute(parent_object, attr_name): - return True + prop = getattr(type(parent_object), attr_name, None) + + if isinstance(prop, property): + return has_load_state_decorator(prop) return True diff --git a/tests/data_service/test_state_manager.py b/tests/data_service/test_state_manager.py index 6aed30a..d7a0b7f 100644 --- a/tests/data_service/test_state_manager.py +++ b/tests/data_service/test_state_manager.py @@ -7,7 +7,11 @@ from pytest import LogCaptureFixture import pydase import pydase.units as u from pydase.components.coloured_enum import ColouredEnum -from pydase.data_service.state_manager import StateManager +from pydase.data_service.state_manager import ( + StateManager, + has_load_state_decorator, + load_state, +) class SubService(pydase.DataService): @@ -28,7 +32,7 @@ class Service(pydase.DataService): self.list_attr = [1.0, 2.0] self._property_attr = 1337.0 self._name = "Service" - self._state = State.RUNNING + self.state = State.RUNNING super().__init__(**kwargs) @property @@ -43,14 +47,6 @@ class Service(pydase.DataService): def property_attr(self, value: float) -> None: self._property_attr = value - @property - def state(self) -> State: - return self._state - - @state.setter - def state(self, value: State) -> None: - self._state = value - CURRENT_STATE = Service().serialize() @@ -148,7 +144,9 @@ def test_load_state(tmp_path: Path, caplog: LogCaptureFixture): assert service.some_unit == u.Quantity(12, "A") # has changed assert service.list_attr[0] == 1.4 # has changed assert service.list_attr[1] == 2.0 # has not changed - assert service.property_attr == 1337.1 # has changed + assert ( + service.property_attr == 1337 + ) # has not changed as property has not @load_state decorator assert service.state == State.FAILED # has changed assert service.name == "Service" # has not changed as readonly assert service.some_float == 1.0 # has not changed due to different type @@ -215,3 +213,59 @@ def test_changed_type(tmp_path: Path, caplog: LogCaptureFixture): "Attribute type of 'some_float' changed from 'int' to " "'float'. Ignoring value from JSON file..." ) in caplog.text + + +def test_property_load_state(tmp_path: Path): + # Create a StateManager instance with a temporary file + file = tmp_path / "test_state.json" + + LOAD_STATE = { + "name": { + "type": "str", + "value": "Some other name", + "readonly": False, + "doc": None, + }, + "not_loadable_attr": { + "type": "str", + "value": "But I AM loadable!?", + "readonly": False, + "doc": None, + }, + } + + # Write a temporary JSON file to read back + with open(file, "w") as f: + json.dump(LOAD_STATE, f, indent=4) + + class Service(pydase.DataService): + _name = "Service" + _not_loadable_attr = "Not loadable" + + @property + def name(self) -> str: + return self._name + + @name.setter + @load_state + def name(self, value: str) -> None: + self._name = value + + @property + def not_loadable_attr(self) -> str: + return self._not_loadable_attr + + @not_loadable_attr.setter + def not_loadable_attr(self, value: str) -> None: + self._not_loadable_attr = value + + @property + def property_without_setter(self) -> None: + return + + service_instance = Service() + StateManager(service_instance, filename=file).load_state() + + assert service_instance.name == "Some other name" + assert service_instance.not_loadable_attr == "Not loadable" + assert not has_load_state_decorator(type(service_instance).property_without_setter)