From 6c2c5d4ad1f8ff1e823e27a409336a3ff60850fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Wed, 8 Nov 2023 17:05:55 +0100 Subject: [PATCH 01/17] deprecates update_DataService_attribute function --- src/pydase/data_service/data_service.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/pydase/data_service/data_service.py b/src/pydase/data_service/data_service.py index b676327..bd4df93 100644 --- a/src/pydase/data_service/data_service.py +++ b/src/pydase/data_service/data_service.py @@ -222,6 +222,15 @@ class DataService(rpyc.Service, AbstractDataService): attr_name: str, value: Any, ) -> None: + warnings.warn( + "'update_DataService_attribute' is deprecated and will be removed in a " + "future version. " + "Service state management is handled by `pydase.data_service.state_manager`" + "now, instead.", + DeprecationWarning, + stacklevel=2, + ) + # If attr_name corresponds to a list entry, extract the attr_name and the index attr_name, index = parse_list_attr_and_index(attr_name) # Traverse the object according to the path parts From e708d6f1c36d4019630a53cfb90a3da840b5d2e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Wed, 8 Nov 2023 17:07:37 +0100 Subject: [PATCH 02/17] adds logic of updating DataService attributes to StateManager --- src/pydase/data_service/state_manager.py | 78 ++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/pydase/data_service/state_manager.py b/src/pydase/data_service/state_manager.py index 790c406..95f7b51 100644 --- a/src/pydase/data_service/state_manager.py +++ b/src/pydase/data_service/state_manager.py @@ -1,12 +1,20 @@ import json import logging import os +from enum import Enum from pathlib import Path from typing import TYPE_CHECKING, Any, Optional, cast import pydase.units as u +from pydase.data_service.data_service import process_callable_attribute from pydase.data_service.data_service_cache import DataServiceCache +from pydase.utils.helpers import ( + get_object_attr_from_path, + is_property_attribute, + parse_list_attr_and_index, +) from pydase.utils.serializer import ( + dump, generate_serialized_data_paths, get_nested_dict_by_path, ) @@ -142,3 +150,73 @@ class StateManager: # values return cast(dict[str, Any], json.load(f)) return {} + + def set_service_attribute_value_by_path( + self, + path: str, + value: Any, + ) -> None: + current_value_dict = get_nested_dict_by_path(self.cache, path) + + if current_value_dict["readonly"]: + logger.debug( + f"Attribute {path!r} is read-only. Ignoring value from JSON " "file..." + ) + return + + converted_value = self.__convert_value_if_needed(value, current_value_dict) + + # only set value when it has changed + if self.__attr_value_has_changed(converted_value, current_value_dict["value"]): + self.__update_attribute_by_path(path, converted_value) + else: + logger.debug(f"Value of attribute {path!r} has not changed...") + + def __attr_value_has_changed(self, value_object: Any, current_value: Any) -> bool: + """Check if the serialized value of `value_object` differs from `current_value`. + + The method serializes `value_object` to compare it, which is mainly + necessary for handling Quantity objects. + """ + + return dump(value_object)["value"] != current_value + + def __convert_value_if_needed( + self, value: Any, current_value_dict: dict[str, Any] + ) -> Any: + if current_value_dict["type"] == "Quantity": + return u.convert_to_quantity(value, current_value_dict["value"]["unit"]) + return value + + def __update_attribute_by_path(self, path: str, value: Any) -> None: + parent_path_list, attr_name = path.split(".")[:-1], path.split(".")[-1] + + # If attr_name corresponds to a list entry, extract the attr_name and the + # index + attr_name, index = parse_list_attr_and_index(attr_name) + + # Traverse the object according to the path parts + target_obj = get_object_attr_from_path(self.service, parent_path_list) + + # 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(target_obj, attr_name): + setattr(target_obj, attr_name, value) + return + + attr = get_object_attr_from_path(target_obj, [attr_name]) + if attr is None: + # If the attribute does not exist, abort setting the value. An error + # message has already been logged. + # This will never happen as this function is only called when the + # attribute exists in the cache. + return + + if isinstance(attr, Enum): + setattr(target_obj, attr_name, attr.__class__[value]) + elif isinstance(attr, list) and index is not None: + attr[index] = value + elif callable(attr): + process_callable_attribute(attr, value["args"]) + else: + setattr(target_obj, attr_name, value) From b594a91a1889d38ee1b2a61ab2a5dad87b7a7ee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Wed, 8 Nov 2023 17:08:00 +0100 Subject: [PATCH 03/17] refactors load_state method --- src/pydase/data_service/state_manager.py | 25 +++++------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/src/pydase/data_service/state_manager.py b/src/pydase/data_service/state_manager.py index 95f7b51..c53c5ec 100644 --- a/src/pydase/data_service/state_manager.py +++ b/src/pydase/data_service/state_manager.py @@ -113,32 +113,17 @@ class StateManager: serialized_class = self.cache for path in generate_serialized_data_paths(json_dict): nested_json_dict = get_nested_dict_by_path(json_dict, path) - value = nested_json_dict["value"] - value_type = nested_json_dict["type"] - nested_class_dict = get_nested_dict_by_path(serialized_class, path) - class_value_type = nested_class_dict.get("type", None) - if class_value_type == value_type: - class_attr_is_read_only = nested_class_dict["readonly"] - if class_attr_is_read_only: - logger.debug( - f"Attribute {path!r} is read-only. Ignoring value from JSON " - "file..." - ) - continue - # Split the path into parts - parts = path.split(".") - attr_name = parts[-1] - # Convert dictionary into Quantity - if class_value_type == "Quantity": - value = u.convert_to_quantity(value) + value, value_type = nested_json_dict["value"], nested_json_dict["type"] + class_attr_value_type = nested_class_dict.get("type", None) - self.service.update_DataService_attribute(parts[:-1], attr_name, value) + if class_attr_value_type == value_type: + self.set_service_attribute_value_by_path(path, value) else: logger.info( f"Attribute type of {path!r} changed from {value_type!r} to " - f"{class_value_type!r}. Ignoring value from JSON file..." + f"{class_attr_value_type!r}. Ignoring value from JSON file..." ) def _get_state_dict_from_JSON_file(self) -> dict[str, Any]: From 24f15741682e4fcb9909070a9c2563e7e6571cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Wed, 8 Nov 2023 17:08:31 +0100 Subject: [PATCH 04/17] web server now uses StateManager method to update DataService attributes --- src/pydase/server/web_server.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pydase/server/web_server.py b/src/pydase/server/web_server.py index bd9bc09..d93e467 100644 --- a/src/pydase/server/web_server.py +++ b/src/pydase/server/web_server.py @@ -81,10 +81,11 @@ class WebAPI: @sio.event # type: ignore def frontend_update(sid: str, data: UpdateDict) -> Any: logger.debug(f"Received frontend update: {data}") - path_list, attr_name = data["parent_path"].split("."), data["name"] + path_list = [*data["parent_path"].split("."), data["name"]] path_list.remove("DataService") # always at the start, does not do anything - return self.service.update_DataService_attribute( - path_list=path_list, attr_name=attr_name, value=data["value"] + path = ".".join(path_list) + return self.state_manager.set_service_attribute_value_by_path( + path=path, value=data["value"] ) self.__sio = sio From 6b643210d7ce427bee9b02435004226f954d0759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Wed, 8 Nov 2023 17:09:05 +0100 Subject: [PATCH 05/17] adds tests for StateManager --- tests/data_service/test_state_manager.py | 72 ++++++++++++++++-------- 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/tests/data_service/test_state_manager.py b/tests/data_service/test_state_manager.py index f5674df..ba593c0 100644 --- a/tests/data_service/test_state_manager.py +++ b/tests/data_service/test_state_manager.py @@ -9,8 +9,13 @@ import pydase.units as u from pydase.data_service.state_manager import StateManager +class SubService(pydase.DataService): + name = "SubService" + + class Service(pydase.DataService): def __init__(self, **kwargs: Any) -> None: + self.subservice = SubService() self.some_unit: u.Quantity = 1.2 * u.units.A self.some_float = 1.0 self._name = "Service" @@ -21,37 +26,18 @@ class Service(pydase.DataService): return self._name -CURRENT_STATE = { - "name": { - "type": "str", - "value": "Service", - "readonly": True, - "doc": None, - }, - "some_float": { - "type": "float", - "value": 1.0, - "readonly": False, - "doc": None, - }, - "some_unit": { - "type": "Quantity", - "value": {"magnitude": 1.2, "unit": "A"}, - "readonly": False, - "doc": None, - }, -} +CURRENT_STATE = Service().serialize() LOAD_STATE = { "name": { "type": "str", - "value": "Service", + "value": "Another name", "readonly": True, "doc": None, }, "some_float": { "type": "int", - "value": 1, + "value": 10, "readonly": False, "doc": None, }, @@ -61,6 +47,25 @@ LOAD_STATE = { "readonly": False, "doc": None, }, + "subservice": { + "type": "DataService", + "value": { + "name": { + "type": "str", + "value": "SubService", + "readonly": False, + "doc": None, + } + }, + "readonly": False, + "doc": None, + }, + "removed_attr": { + "type": "str", + "value": "removed", + "readonly": False, + "doc": None, + }, } @@ -76,7 +81,7 @@ def test_save_state(tmp_path: Path): assert file.read_text() == json.dumps(CURRENT_STATE, indent=4) -def test_load_state(tmp_path: Path): +def test_load_state(tmp_path: Path, caplog: LogCaptureFixture): # Create a StateManager instance with a temporary file file = tmp_path / "test_state.json" @@ -87,7 +92,26 @@ def test_load_state(tmp_path: Path): service = Service() manager = StateManager(service=service, filename=str(file)) manager.load_state() - assert service.some_unit == u.Quantity(12, "A") + + assert service.some_unit == u.Quantity(12, "A") # has changed + assert service.name == "Service" # has not changed as readonly + assert service.some_float == 1.0 # has not changed due to different type + assert service.some_float == 1.0 # has not changed due to different type + assert service.subservice.name == "SubService" # didn't change + + assert "Service.some_unit changed to 12.0 A!" in caplog.text + assert ( + "Attribute 'name' is read-only. Ignoring value from JSON file..." in caplog.text + ) + assert ( + "Attribute type of 'some_float' changed from 'int' to 'float'. " + "Ignoring value from JSON file..." + ) in caplog.text + assert ( + "Attribute type of 'removed_attr' changed from 'str' to None. " + "Ignoring value from JSON file..." in caplog.text + ) + assert "Value of attribute 'subservice.name' has not changed..." in caplog.text def test_filename_warning(tmp_path: Path, caplog: LogCaptureFixture): From 27bb73a2da574025891907dcbe2c4286ee508ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Thu, 9 Nov 2023 08:20:51 +0100 Subject: [PATCH 06/17] adds docstring --- src/pydase/data_service/state_manager.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/pydase/data_service/state_manager.py b/src/pydase/data_service/state_manager.py index c53c5ec..7134677 100644 --- a/src/pydase/data_service/state_manager.py +++ b/src/pydase/data_service/state_manager.py @@ -141,6 +141,20 @@ class StateManager: path: str, value: Any, ) -> None: + """ + Sets the value of an attribute in the service managed by the `StateManager` + given its path as a dot-separated string. + + This method updates the attribute specified by 'path' with 'value' only if the + attribute is not read-only and the new value differs from the current one. + It also handles type-specific conversions for the new value before setting it. + + Args: + path: A dot-separated string indicating the hierarchical path to the + attribute. + value: The new value to set for the attribute. + """ + current_value_dict = get_nested_dict_by_path(self.cache, path) if current_value_dict["readonly"]: From 8dd05ac5e39b9222d354fc7f3359bea12a4b7fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Thu, 9 Nov 2023 11:35:04 +0100 Subject: [PATCH 07/17] renames helper function --- src/pydase/data_service/data_service.py | 6 +++--- src/pydase/data_service/state_manager.py | 4 ++-- src/pydase/utils/helpers.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pydase/data_service/data_service.py b/src/pydase/data_service/data_service.py index bd4df93..445ac14 100644 --- a/src/pydase/data_service/data_service.py +++ b/src/pydase/data_service/data_service.py @@ -13,7 +13,7 @@ from pydase.data_service.task_manager import TaskManager from pydase.utils.helpers import ( convert_arguments_to_hinted_types, get_class_and_instance_attributes, - get_object_attr_from_path, + get_object_attr_from_path_list, is_property_attribute, parse_list_attr_and_index, update_value_if_changed, @@ -234,7 +234,7 @@ class DataService(rpyc.Service, AbstractDataService): # If attr_name corresponds to a list entry, extract the attr_name and the index attr_name, index = parse_list_attr_and_index(attr_name) # Traverse the object according to the path parts - target_obj = get_object_attr_from_path(self, path_list) + target_obj = get_object_attr_from_path_list(self, path_list) # If the attribute is a property, change it using the setter without getting the # property value (would otherwise be bad for expensive getter methods) @@ -242,7 +242,7 @@ class DataService(rpyc.Service, AbstractDataService): setattr(target_obj, attr_name, value) return - attr = get_object_attr_from_path(target_obj, [attr_name]) + attr = get_object_attr_from_path_list(target_obj, [attr_name]) if attr is None: return diff --git a/src/pydase/data_service/state_manager.py b/src/pydase/data_service/state_manager.py index 7134677..32f1644 100644 --- a/src/pydase/data_service/state_manager.py +++ b/src/pydase/data_service/state_manager.py @@ -9,7 +9,7 @@ import pydase.units as u from pydase.data_service.data_service import process_callable_attribute from pydase.data_service.data_service_cache import DataServiceCache from pydase.utils.helpers import ( - get_object_attr_from_path, + get_object_attr_from_path_list, is_property_attribute, parse_list_attr_and_index, ) @@ -195,7 +195,7 @@ class StateManager: attr_name, index = parse_list_attr_and_index(attr_name) # Traverse the object according to the path parts - target_obj = get_object_attr_from_path(self.service, parent_path_list) + target_obj = get_object_attr_from_path_list(self.service, parent_path_list) # If the attribute is a property, change it using the setter without getting # the property value (would otherwise be bad for expensive getter methods) diff --git a/src/pydase/utils/helpers.py b/src/pydase/utils/helpers.py index 9161013..3624b7a 100644 --- a/src/pydase/utils/helpers.py +++ b/src/pydase/utils/helpers.py @@ -31,7 +31,7 @@ def get_class_and_instance_attributes(obj: object) -> dict[str, Any]: return attrs -def get_object_attr_from_path(target_obj: Any, path: list[str]) -> Any: +def get_object_attr_from_path_list(target_obj: Any, path: list[str]) -> Any: """ Traverse the object tree according to the given path. From 784d49d90cc68fcf67a902a1676aba774061c572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Thu, 9 Nov 2023 11:36:45 +0100 Subject: [PATCH 08/17] refactores __update_attribute_by_path of StateManager --- src/pydase/data_service/state_manager.py | 49 +++++++++++------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/src/pydase/data_service/state_manager.py b/src/pydase/data_service/state_manager.py index 32f1644..d43d5e5 100644 --- a/src/pydase/data_service/state_manager.py +++ b/src/pydase/data_service/state_manager.py @@ -1,12 +1,10 @@ import json import logging import os -from enum import Enum from pathlib import Path from typing import TYPE_CHECKING, Any, Optional, cast import pydase.units as u -from pydase.data_service.data_service import process_callable_attribute from pydase.data_service.data_service_cache import DataServiceCache from pydase.utils.helpers import ( get_object_attr_from_path_list, @@ -110,10 +108,9 @@ class StateManager: logger.debug("Could not load the service state.") return - serialized_class = self.cache for path in generate_serialized_data_paths(json_dict): nested_json_dict = get_nested_dict_by_path(json_dict, path) - nested_class_dict = get_nested_dict_by_path(serialized_class, path) + nested_class_dict = get_nested_dict_by_path(self.cache, path) value, value_type = nested_json_dict["value"], nested_json_dict["type"] class_attr_value_type = nested_class_dict.get("type", None) @@ -157,10 +154,9 @@ class StateManager: current_value_dict = get_nested_dict_by_path(self.cache, path) + # This will also filter out methods as they are 'read-only' if current_value_dict["readonly"]: - logger.debug( - f"Attribute {path!r} is read-only. Ignoring value from JSON " "file..." - ) + logger.debug(f"Attribute {path!r} is read-only. Ignoring new value...") return converted_value = self.__convert_value_if_needed(value, current_value_dict) @@ -194,28 +190,27 @@ class StateManager: # index attr_name, index = parse_list_attr_and_index(attr_name) + # Update path to reflect the attribute without list indices + path = ".".join([*parent_path_list, attr_name]) + + attr_cache_type = get_nested_dict_by_path(self.cache, path)["type"] + # Traverse the object according to the path parts target_obj = get_object_attr_from_path_list(self.service, parent_path_list) + if self.__attr_value_should_change(target_obj, attr_name): + if attr_cache_type in ("ColouredEnum", "Enum"): + enum_attr = get_object_attr_from_path_list(target_obj, [attr_name]) + setattr(target_obj, attr_name, enum_attr.__class__[value]) + elif attr_cache_type == "list": + list_obj = get_object_attr_from_path_list(target_obj, [attr_name]) + list_obj[index] = value + else: + setattr(target_obj, attr_name, value) + + 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(target_obj, attr_name): - setattr(target_obj, attr_name, value) - return - - attr = get_object_attr_from_path(target_obj, [attr_name]) - if attr is None: - # If the attribute does not exist, abort setting the value. An error - # message has already been logged. - # This will never happen as this function is only called when the - # attribute exists in the cache. - return - - if isinstance(attr, Enum): - setattr(target_obj, attr_name, attr.__class__[value]) - elif isinstance(attr, list) and index is not None: - attr[index] = value - elif callable(attr): - process_callable_attribute(attr, value["args"]) - else: - setattr(target_obj, attr_name, value) + if is_property_attribute(parent_object, attr_name): + return True + return True From aed0dd9493160332868d13847f313c5736aed16f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Thu, 9 Nov 2023 11:37:07 +0100 Subject: [PATCH 09/17] updates StateManager tests --- tests/data_service/test_state_manager.py | 65 +++++++++++++++++++++--- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/tests/data_service/test_state_manager.py b/tests/data_service/test_state_manager.py index ba593c0..6aed30a 100644 --- a/tests/data_service/test_state_manager.py +++ b/tests/data_service/test_state_manager.py @@ -6,6 +6,7 @@ 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 @@ -13,22 +14,56 @@ class SubService(pydase.DataService): name = "SubService" +class State(ColouredEnum): + RUNNING = "#0000FF80" + COMPLETED = "hsl(120, 100%, 50%)" + FAILED = "hsla(0, 100%, 50%, 0.7)" + + class Service(pydase.DataService): def __init__(self, **kwargs: Any) -> None: self.subservice = SubService() self.some_unit: u.Quantity = 1.2 * u.units.A self.some_float = 1.0 + self.list_attr = [1.0, 2.0] + self._property_attr = 1337.0 self._name = "Service" + self._state = State.RUNNING super().__init__(**kwargs) @property def name(self) -> str: return self._name + @property + def property_attr(self) -> float: + return self._property_attr + + @property_attr.setter + 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() LOAD_STATE = { + "list_attr": { + "type": "list", + "value": [ + {"type": "float", "value": 1.4, "readonly": False, "doc": None}, + {"type": "float", "value": 2.0, "readonly": False, "doc": None}, + ], + "readonly": False, + "doc": None, + }, "name": { "type": "str", "value": "Another name", @@ -41,12 +76,29 @@ LOAD_STATE = { "readonly": False, "doc": None, }, + "property_attr": { + "type": "float", + "value": 1337.1, + "readonly": False, + "doc": None, + }, "some_unit": { "type": "Quantity", "value": {"magnitude": 12.0, "unit": "A"}, "readonly": False, "doc": None, }, + "state": { + "type": "ColouredEnum", + "value": "FAILED", + "readonly": True, + "doc": None, + "enum": { + "RUNNING": "#0000FF80", + "COMPLETED": "hsl(120, 100%, 50%)", + "FAILED": "hsla(0, 100%, 50%, 0.7)", + }, + }, "subservice": { "type": "DataService", "value": { @@ -94,15 +146,16 @@ def test_load_state(tmp_path: Path, caplog: LogCaptureFixture): manager.load_state() 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.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 - assert service.some_float == 1.0 # has not changed due to different type assert service.subservice.name == "SubService" # didn't change assert "Service.some_unit changed to 12.0 A!" in caplog.text - assert ( - "Attribute 'name' is read-only. Ignoring value from JSON file..." in caplog.text - ) + assert "Attribute 'name' is read-only. Ignoring new value..." in caplog.text assert ( "Attribute type of 'some_float' changed from 'int' to 'float'. " "Ignoring value from JSON file..." @@ -144,9 +197,7 @@ def test_readonly_attribute(tmp_path: Path, caplog: LogCaptureFixture): service = Service() manager = StateManager(service=service, filename=str(file)) manager.load_state() - assert ( - "Attribute 'name' is read-only. Ignoring value from JSON file..." in caplog.text - ) + assert "Attribute 'name' is read-only. Ignoring new value..." in caplog.text def test_changed_type(tmp_path: Path, caplog: LogCaptureFixture): From 1776fc86236ec9b587ca2a19d192224323872f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Thu, 9 Nov 2023 11:49:42 +0100 Subject: [PATCH 10/17] converts values (ints and quantities) when setting list entries --- src/pydase/data_service/data_service_list.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/pydase/data_service/data_service_list.py b/src/pydase/data_service/data_service_list.py index bbd7812..07b84d6 100644 --- a/src/pydase/data_service/data_service_list.py +++ b/src/pydase/data_service/data_service_list.py @@ -1,6 +1,7 @@ from collections.abc import Callable from typing import Any +import pydase.units as u from pydase.utils.warnings import ( warn_if_instance_class_does_not_inherit_from_DataService, ) @@ -47,6 +48,14 @@ class DataServiceList(list): super().__init__(*args, **kwargs) # type: ignore def __setitem__(self, key: int, value: Any) -> None: # type: ignore + current_value = self.__getitem__(key) + + # parse ints into floats if current value is a float + if isinstance(current_value, float) and isinstance(value, int): + value = float(value) + + if isinstance(current_value, u.Quantity): + value = u.convert_to_quantity(value, str(current_value.u)) super().__setitem__(key, value) # type: ignore for callback in self.callbacks: From 963e449adb1af62ca0670641b399284a182b3578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Thu, 9 Nov 2023 11:50:08 +0100 Subject: [PATCH 11/17] moves DataServiceList test file --- .../test_data_service_list.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_DataServiceList.py => data_service/test_data_service_list.py} (100%) diff --git a/tests/test_DataServiceList.py b/tests/data_service/test_data_service_list.py similarity index 100% rename from tests/test_DataServiceList.py rename to tests/data_service/test_data_service_list.py From a060836304d99cce8ab61efb8fdd58356c8a88da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Thu, 9 Nov 2023 11:59:48 +0100 Subject: [PATCH 12/17] updates DataServiceList tests --- tests/data_service/test_data_service_list.py | 32 ++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/data_service/test_data_service_list.py b/tests/data_service/test_data_service_list.py index fcd7530..a45864a 100644 --- a/tests/data_service/test_data_service_list.py +++ b/tests/data_service/test_data_service_list.py @@ -2,6 +2,7 @@ from typing import Any from pytest import LogCaptureFixture +import pydase.units as u from pydase import DataService @@ -84,8 +85,8 @@ def test_nested_reused_instance_list_attribute(caplog: LogCaptureFixture) -> Non def test_protected_list_attribute(caplog: LogCaptureFixture) -> None: - """Changing protected lists should not emit notifications for the lists themselves, but - still for all properties depending on them. + """Changing protected lists should not emit notifications for the lists themselves, + but still for all properties depending on them. """ class ServiceClass(DataService): @@ -99,3 +100,30 @@ def test_protected_list_attribute(caplog: LogCaptureFixture) -> None: service_instance._attr[0] = 1337 assert "ServiceClass.list_dependend_property changed to 1337" in caplog.text + + +def test_converting_int_to_float_entries(caplog: LogCaptureFixture) -> None: + class ServiceClass(DataService): + float_list = [0.0] + + service_instance = ServiceClass() + service_instance.float_list[0] = 1 + + assert isinstance(service_instance.float_list[0], float) + assert "ServiceClass.float_list[0] changed to 1.0" in caplog.text + + +def test_converting_number_to_quantity_entries(caplog: LogCaptureFixture) -> None: + class ServiceClass(DataService): + quantity_list: list[u.Quantity] = [1 * u.units.A] + + service_instance = ServiceClass() + service_instance.quantity_list[0] = 4 # type: ignore + + assert isinstance(service_instance.quantity_list[0], u.Quantity) + assert "ServiceClass.quantity_list[0] changed to 4.0 A" in caplog.text + caplog.clear() + + service_instance.quantity_list[0] = 3.1 * u.units.mA + assert isinstance(service_instance.quantity_list[0], u.Quantity) + assert "ServiceClass.quantity_list[0] changed to 3.1 mA" in caplog.text From 45ede860d910b9f01d9547af67fb87801f3432b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Thu, 9 Nov 2023 13:51:26 +0100 Subject: [PATCH 13/17] removes JSDoc types (already in typescript) --- frontend/src/App.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9a04631..80f1a74 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,12 +35,6 @@ type ExceptionMessage = { * * If the property to be updated is an object or an array, it is updated * recursively. - * - * @param {Array} path - An array where each element is a key in the object, - * forming a path to the property to be updated. - * @param {object} obj - The object to be updated. - * @param {object} value - The new value for the property specified by the path. - * @return {object} - A new object with the specified property updated. */ function updateNestedObject(path: Array, obj: object, value: ValueType) { // Base case: If the path is empty, return the new value. From a750644c2020dc281ea7764d3fe694c445d3c6b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Thu, 9 Nov 2023 13:52:00 +0100 Subject: [PATCH 14/17] updates socket.ts (renames and add method) --- frontend/src/socket.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/frontend/src/socket.ts b/frontend/src/socket.ts index 22f1486..9e9de6f 100644 --- a/frontend/src/socket.ts +++ b/frontend/src/socket.ts @@ -9,15 +9,28 @@ console.debug('Websocket: ', URL); export const socket = io(URL, { path: '/ws/socket.io', transports: ['websocket'] }); -export const emit_update = ( +export const setAttribute = ( name: string, parentPath: string, value: unknown, callback?: (ack: unknown) => void ) => { if (callback) { - socket.emit('frontend_update', { name, parent_path: parentPath, value }, callback); + socket.emit('set_attribute', { name, parent_path: parentPath, value }, callback); } else { - socket.emit('frontend_update', { name, parent_path: parentPath, value }); + socket.emit('set_attribute', { name, parent_path: parentPath, value }); + } +}; + +export const runMethod = ( + name: string, + parentPath: string, + kwargs: Record, + callback?: (ack: unknown) => void +) => { + if (callback) { + socket.emit('run_method', { name, parent_path: parentPath, kwargs }, callback); + } else { + socket.emit('run_method', { name, parent_path: parentPath, kwargs }); } }; From d18be5428436afce7182df85e7f34fc7523e78f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Thu, 9 Nov 2023 13:52:23 +0100 Subject: [PATCH 15/17] updates frontend components to use new methods from socket.ts --- frontend/src/components/AsyncMethodComponent.tsx | 8 ++++---- frontend/src/components/ButtonComponent.tsx | 4 ++-- frontend/src/components/ColouredEnumComponent.tsx | 4 ++-- frontend/src/components/EnumComponent.tsx | 4 ++-- frontend/src/components/MethodComponent.tsx | 15 +++++++++------ frontend/src/components/NumberComponent.tsx | 4 ++-- frontend/src/components/SliderComponent.tsx | 4 ++-- frontend/src/components/StringComponent.tsx | 8 ++++---- 8 files changed, 27 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/AsyncMethodComponent.tsx b/frontend/src/components/AsyncMethodComponent.tsx index 7189bdc..7a50a0e 100644 --- a/frontend/src/components/AsyncMethodComponent.tsx +++ b/frontend/src/components/AsyncMethodComponent.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react'; -import { emit_update } from '../socket'; +import { runMethod } from '../socket'; import { InputGroup, Form, Button } from 'react-bootstrap'; import { DocStringComponent } from './DocStringComponent'; import { getIdFromFullAccessPath } from '../utils/stringUtils'; @@ -56,18 +56,18 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => { const execute = async (event: React.FormEvent) => { event.preventDefault(); let method_name: string; - const args = {}; + const kwargs: Record = {}; if (runningTask !== undefined && runningTask !== null) { method_name = `stop_${name}`; } else { Object.keys(props.parameters).forEach( - (name) => (args[name] = event.target[name].value) + (name) => (kwargs[name] = event.target[name].value) ); method_name = `start_${name}`; } - emit_update(method_name, parentPath, { args: args }); + runMethod(method_name, parentPath, kwargs); }; const args = Object.entries(props.parameters).map(([name, type], index) => { diff --git a/frontend/src/components/ButtonComponent.tsx b/frontend/src/components/ButtonComponent.tsx index e6aae9e..4200915 100644 --- a/frontend/src/components/ButtonComponent.tsx +++ b/frontend/src/components/ButtonComponent.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from 'react'; import { ToggleButton } from 'react-bootstrap'; -import { emit_update } from '../socket'; +import { setAttribute } from '../socket'; import { DocStringComponent } from './DocStringComponent'; import { getIdFromFullAccessPath } from '../utils/stringUtils'; @@ -31,7 +31,7 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => { }, [props.value]); const setChecked = (checked: boolean) => { - emit_update(name, parentPath, checked); + setAttribute(name, parentPath, checked); }; return ( diff --git a/frontend/src/components/ColouredEnumComponent.tsx b/frontend/src/components/ColouredEnumComponent.tsx index 8d6f414..0e2c15e 100644 --- a/frontend/src/components/ColouredEnumComponent.tsx +++ b/frontend/src/components/ColouredEnumComponent.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from 'react'; import { InputGroup, Form, Row, Col } from 'react-bootstrap'; -import { emit_update } from '../socket'; +import { setAttribute } from '../socket'; import { DocStringComponent } from './DocStringComponent'; import { getIdFromFullAccessPath } from '../utils/stringUtils'; @@ -36,7 +36,7 @@ export const ColouredEnumComponent = React.memo((props: ColouredEnumComponentPro }, [props.value]); const handleValueChange = (newValue: string) => { - emit_update(name, parentPath, newValue); + setAttribute(name, parentPath, newValue); }; return ( diff --git a/frontend/src/components/EnumComponent.tsx b/frontend/src/components/EnumComponent.tsx index a173163..4899fbd 100644 --- a/frontend/src/components/EnumComponent.tsx +++ b/frontend/src/components/EnumComponent.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from 'react'; import { InputGroup, Form, Row, Col } from 'react-bootstrap'; -import { emit_update } from '../socket'; +import { setAttribute } from '../socket'; import { DocStringComponent } from './DocStringComponent'; interface EnumComponentProps { @@ -33,7 +33,7 @@ export const EnumComponent = React.memo((props: EnumComponentProps) => { }, [props.value]); const handleValueChange = (newValue: string) => { - emit_update(name, parentPath, newValue); + setAttribute(name, parentPath, newValue); }; return ( diff --git a/frontend/src/components/MethodComponent.tsx b/frontend/src/components/MethodComponent.tsx index 29678ed..5d4c429 100644 --- a/frontend/src/components/MethodComponent.tsx +++ b/frontend/src/components/MethodComponent.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import { emit_update } from '../socket'; +import { runMethod } from '../socket'; import { Button, InputGroup, Form, Collapse } from 'react-bootstrap'; import { DocStringComponent } from './DocStringComponent'; import { getIdFromFullAccessPath } from '../utils/stringUtils'; @@ -46,18 +46,21 @@ export const MethodComponent = React.memo((props: MethodProps) => { const execute = async (event: React.FormEvent) => { event.preventDefault(); - const args = {}; + const kwargs = {}; Object.keys(props.parameters).forEach( - (name) => (args[name] = event.target[name].value) + (name) => (kwargs[name] = event.target[name].value) ); - emit_update(name, parentPath, { args: args }, (ack) => { + runMethod(name, parentPath, kwargs, (ack) => { // Update the functionCalls state with the new call if we get an acknowledge msg if (ack !== undefined) { - setFunctionCalls((prevCalls) => [...prevCalls, { name, args, result: ack }]); + setFunctionCalls((prevCalls) => [ + ...prevCalls, + { name, args: kwargs, result: ack } + ]); } }); - triggerNotification(args); + triggerNotification(kwargs); }; const args = Object.entries(props.parameters).map(([name, type], index) => { diff --git a/frontend/src/components/NumberComponent.tsx b/frontend/src/components/NumberComponent.tsx index 9a91127..117cf40 100644 --- a/frontend/src/components/NumberComponent.tsx +++ b/frontend/src/components/NumberComponent.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { Form, InputGroup } from 'react-bootstrap'; -import { emit_update } from '../socket'; +import { setAttribute } from '../socket'; import { DocStringComponent } from './DocStringComponent'; import '../App.css'; import { getIdFromFullAccessPath } from '../utils/stringUtils'; @@ -125,7 +125,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => { // If emitUpdate is passed, use this instead of the emit_update from the socket // Also used when used with a slider const emitUpdate = - props.customEmitUpdate !== undefined ? props.customEmitUpdate : emit_update; + props.customEmitUpdate !== undefined ? props.customEmitUpdate : setAttribute; const renderCount = useRef(0); // Create a state for the cursor position diff --git a/frontend/src/components/SliderComponent.tsx b/frontend/src/components/SliderComponent.tsx index 73dc8c2..491f435 100644 --- a/frontend/src/components/SliderComponent.tsx +++ b/frontend/src/components/SliderComponent.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from 'react-bootstrap'; -import { emit_update } from '../socket'; +import { setAttribute } from '../socket'; import { DocStringComponent } from './DocStringComponent'; import { Slider } from '@mui/material'; import { NumberComponent } from './NumberComponent'; @@ -66,7 +66,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => { max: number = props.max, stepSize: number = props.stepSize ) => { - emit_update( + setAttribute( name, parentPath, { diff --git a/frontend/src/components/StringComponent.tsx b/frontend/src/components/StringComponent.tsx index 11db630..f4423f6 100644 --- a/frontend/src/components/StringComponent.tsx +++ b/frontend/src/components/StringComponent.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { Form, InputGroup } from 'react-bootstrap'; -import { emit_update } from '../socket'; +import { setAttribute } from '../socket'; import { DocStringComponent } from './DocStringComponent'; import '../App.css'; import { getIdFromFullAccessPath } from '../utils/stringUtils'; @@ -41,19 +41,19 @@ export const StringComponent = React.memo((props: StringComponentProps) => { const handleChange = (event) => { setInputString(event.target.value); if (isInstantUpdate) { - emit_update(name, parentPath, event.target.value); + setAttribute(name, parentPath, event.target.value); } }; const handleKeyDown = (event) => { if (event.key === 'Enter' && !isInstantUpdate) { - emit_update(name, parentPath, inputString); + setAttribute(name, parentPath, inputString); } }; const handleBlur = () => { if (!isInstantUpdate) { - emit_update(name, parentPath, inputString); + setAttribute(name, parentPath, inputString); } }; From a323ce169e8beda7b17f4b6e1e987e80974af11e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Thu, 9 Nov 2023 13:53:13 +0100 Subject: [PATCH 16/17] renames frontend_update socketio event to set_attribute --- src/pydase/server/web_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pydase/server/web_server.py b/src/pydase/server/web_server.py index d93e467..f60424c 100644 --- a/src/pydase/server/web_server.py +++ b/src/pydase/server/web_server.py @@ -79,7 +79,7 @@ class WebAPI: sio = socketio.AsyncServer(async_mode="asgi") @sio.event # type: ignore - def frontend_update(sid: str, data: UpdateDict) -> Any: + def set_attribute(sid: str, data: UpdateDict) -> Any: logger.debug(f"Received frontend update: {data}") path_list = [*data["parent_path"].split("."), data["name"]] path_list.remove("DataService") # always at the start, does not do anything From bdf5512bcc8c68cf1ce3a9ca48b537c31e8b98c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mose=20M=C3=BCller?= Date: Thu, 9 Nov 2023 13:53:27 +0100 Subject: [PATCH 17/17] adds run_method socketio event to web server --- src/pydase/server/web_server.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/pydase/server/web_server.py b/src/pydase/server/web_server.py index f60424c..c24ea75 100644 --- a/src/pydase/server/web_server.py +++ b/src/pydase/server/web_server.py @@ -9,7 +9,9 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from pydase import DataService +from pydase.data_service.data_service import process_callable_attribute from pydase.data_service.state_manager import StateManager +from pydase.utils.helpers import get_object_attr_from_path_list from pydase.version import __version__ logger = logging.getLogger(__name__) @@ -44,6 +46,25 @@ class UpdateDict(TypedDict): value: Any +class RunMethodDict(TypedDict): + """ + A TypedDict subclass representing a dictionary used for running methods from the + exposed DataService. + + Attributes: + name (str): The name of the method to be run. + parent_path (str): The access path for the parent object of the method to be + run. This is used to construct the full access path for the method. For + example, for an method with access path 'attr1.list_attr[0].method_name', + 'attr1.list_attr[0]' would be the parent_path. + kwargs (dict[str, Any]): The arguments passed to the method. + """ + + name: str + parent_path: str + kwargs: dict[str, Any] + + class WebAPI: __sio_app: socketio.ASGIApp __fastapi_app: FastAPI @@ -88,6 +109,14 @@ class WebAPI: path=path, value=data["value"] ) + @sio.event # type: ignore + def run_method(sid: str, data: RunMethodDict) -> Any: + logger.debug(f"Running method: {data}") + path_list = [*data["parent_path"].split("."), data["name"]] + path_list.remove("DataService") # always at the start, does not do anything + method = get_object_attr_from_path_list(self.service, path_list) + return process_callable_attribute(method, data["kwargs"]) + self.__sio = sio self.__sio_app = socketio.ASGIApp(self.__sio)