Merge pull request #148 from tiqi-group/124-adding-keys-to-dictionary-through-pydaseclient

feat: clients can add keys to dictionaries
This commit is contained in:
Mose Müller 2024-07-29 15:17:01 +02:00 committed by GitHub
commit fd73653433
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 112 additions and 81 deletions

View File

@ -2,8 +2,6 @@ import logging
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from pydase.utils.serialization.serializer import ( from pydase.utils.serialization.serializer import (
SerializationPathError,
SerializationValueError,
SerializedObject, SerializedObject,
get_nested_dict_by_path, get_nested_dict_by_path,
set_nested_value_by_path, set_nested_value_by_path,
@ -38,16 +36,7 @@ class DataServiceCache:
) )
def get_value_dict_from_cache(self, full_access_path: str) -> SerializedObject: def get_value_dict_from_cache(self, full_access_path: str) -> SerializedObject:
try:
return get_nested_dict_by_path( return get_nested_dict_by_path(
cast(dict[str, SerializedObject], self._cache["value"]), cast(dict[str, SerializedObject], self._cache["value"]),
full_access_path, full_access_path,
) )
except (SerializationPathError, SerializationValueError, KeyError):
return {
"full_access_path": full_access_path,
"value": None,
"type": "None",
"doc": None,
"readonly": False,
}

View File

@ -9,7 +9,11 @@ from pydase.observer_pattern.observer.property_observer import (
PropertyObserver, PropertyObserver,
) )
from pydase.utils.helpers import get_object_attr_from_path from pydase.utils.helpers import get_object_attr_from_path
from pydase.utils.serialization.serializer import SerializedObject, dump from pydase.utils.serialization.serializer import (
SerializationPathError,
SerializedObject,
dump,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -29,24 +33,34 @@ class DataServiceObserver(PropertyObserver):
for changing_attribute in self.changing_attributes for changing_attribute in self.changing_attributes
): ):
return return
cached_value_dict: SerializedObject
try:
cached_value_dict = deepcopy( cached_value_dict = deepcopy(
self.state_manager._data_service_cache.get_value_dict_from_cache( self.state_manager.cache_manager.get_value_dict_from_cache(
full_access_path full_access_path
) )
) )
except (SerializationPathError, KeyError):
cached_value_dict = {
"full_access_path": full_access_path,
"value": None,
"type": "None",
"doc": None,
"readonly": False,
}
cached_value = cached_value_dict.get("value") cached_value = cached_value_dict.get("value")
if ( if (
all(part[0] != "_" for part in full_access_path.split(".")) all(part[0] != "_" for part in full_access_path.split("."))
and cached_value != dump(value)["value"] and cached_value != value
): ):
logger.debug("'%s' changed to '%s'", full_access_path, value) logger.debug("'%s' changed to '%s'", full_access_path, value)
self._update_cache_value(full_access_path, value, cached_value_dict) self._update_cache_value(full_access_path, value, cached_value_dict)
cached_value_dict = deepcopy( cached_value_dict = deepcopy(
self.state_manager._data_service_cache.get_value_dict_from_cache( self.state_manager.cache_manager.get_value_dict_from_cache(
full_access_path full_access_path
) )
) )
@ -79,7 +93,7 @@ class DataServiceObserver(PropertyObserver):
value_dict["type"], value_dict["type"],
cached_value_dict["type"], cached_value_dict["type"],
) )
self.state_manager._data_service_cache.update_cache( self.state_manager.cache_manager.update_cache(
full_access_path, full_access_path,
value, value,
) )

View File

@ -1,3 +1,4 @@
import contextlib
import json import json
import logging import logging
import os import os
@ -113,19 +114,12 @@ class StateManager:
self.filename = filename self.filename = filename
self.service = service self.service = service
self._data_service_cache = DataServiceCache(self.service) self.cache_manager = DataServiceCache(self.service)
@property
def cache(self) -> SerializedObject:
"""Returns the cached DataService state."""
return self._data_service_cache.cache
@property @property
def cache_value(self) -> dict[str, SerializedObject]: def cache_value(self) -> dict[str, SerializedObject]:
"""Returns the "value" value of the DataService serialization.""" """Returns the "value" value of the DataService serialization."""
return cast( return cast(dict[str, SerializedObject], self.cache_manager.cache["value"])
dict[str, SerializedObject], self._data_service_cache.cache["value"]
)
def save_state(self) -> None: def save_state(self) -> None:
""" """
@ -157,9 +151,18 @@ class StateManager:
for path in generate_serialized_data_paths(json_dict): for path in generate_serialized_data_paths(json_dict):
if self.__is_loadable_state_attribute(path): if self.__is_loadable_state_attribute(path):
nested_json_dict = get_nested_dict_by_path(json_dict, path) nested_json_dict = get_nested_dict_by_path(json_dict, path)
nested_class_dict = self._data_service_cache.get_value_dict_from_cache( try:
nested_class_dict = self.cache_manager.get_value_dict_from_cache(
path path
) )
except (SerializationPathError, KeyError):
nested_class_dict = {
"full_access_path": path,
"value": None,
"type": "None",
"doc": None,
"readonly": False,
}
value_type = nested_json_dict["type"] value_type = nested_json_dict["type"]
class_attr_value_type = nested_class_dict.get("type", None) class_attr_value_type = nested_class_dict.get("type", None)
@ -202,7 +205,16 @@ class StateManager:
value: The new value to set for the attribute. value: The new value to set for the attribute.
""" """
current_value_dict = get_nested_dict_by_path(self.cache_value, path) try:
current_value_dict = self.cache_manager.get_value_dict_from_cache(path)
except (SerializationPathError, KeyError):
current_value_dict = {
"full_access_path": path,
"value": None,
"type": "None",
"doc": None,
"readonly": False,
}
# This will also filter out methods as they are 'read-only' # This will also filter out methods as they are 'read-only'
if current_value_dict["readonly"]: if current_value_dict["readonly"]:
@ -237,24 +249,31 @@ class StateManager:
def __update_attribute_by_path( def __update_attribute_by_path(
self, path: str, serialized_value: SerializedObject self, path: str, serialized_value: SerializedObject
) -> None: ) -> None:
is_value_set = False
path_parts = parse_full_access_path(path) path_parts = parse_full_access_path(path)
target_obj = get_object_by_path_parts(self.service, path_parts[:-1]) target_obj = get_object_by_path_parts(self.service, path_parts[:-1])
attr_cache_type = get_nested_dict_by_path(self.cache_value, path)["type"] def cached_value_is_enum(path: str) -> bool:
try:
attr_cache_type = self.cache_manager.get_value_dict_from_cache(path)[
"type"
]
# De-serialize the value return attr_cache_type in ("ColouredEnum", "Enum")
if attr_cache_type in ("ColouredEnum", "Enum"): except Exception:
return False
if cached_value_is_enum(path):
enum_attr = get_object_by_path_parts(target_obj, [path_parts[-1]]) enum_attr = get_object_by_path_parts(target_obj, [path_parts[-1]])
# take the value of the existing enum class # take the value of the existing enum class
if serialized_value["type"] in ("ColouredEnum", "Enum"): if serialized_value["type"] in ("ColouredEnum", "Enum"):
try: # This error will arise when setting an enum from another enum class.
# In this case, we resort to loading the enum and setting it directly.
with contextlib.suppress(KeyError):
value = enum_attr.__class__[serialized_value["value"]] value = enum_attr.__class__[serialized_value["value"]]
except KeyError: is_value_set = True
# This error will arise when setting an enum from another enum class
# In this case, we resort to loading the enum and setting it if not is_value_set:
# directly
value = loads(serialized_value)
else:
value = loads(serialized_value) value = loads(serialized_value)
# set the value # set the value
@ -287,8 +306,8 @@ class StateManager:
return has_decorator return has_decorator
try: try:
cached_serialization_dict = get_nested_dict_by_path( cached_serialization_dict = self.cache_manager.get_value_dict_from_cache(
self.cache_value, full_access_path full_access_path
) )
if cached_serialization_dict["value"] == "method": if cached_serialization_dict["value"] == "method":

View File

@ -134,7 +134,7 @@ def setup_sio_events(sio: socketio.AsyncServer, state_manager: StateManager) ->
"Client [%s] requested service serialization", "Client [%s] requested service serialization",
click.style(str(sid), fg="cyan"), click.style(str(sid), fg="cyan"),
) )
return state_manager.cache return state_manager.cache_manager.cache
@sio.event @sio.event
async def update_value(sid: str, data: UpdateDict) -> SerializedObject | None: # type: ignore async def update_value(sid: str, data: UpdateDict) -> SerializedObject | None: # type: ignore
@ -151,9 +151,7 @@ def setup_sio_events(sio: socketio.AsyncServer, state_manager: StateManager) ->
@sio.event @sio.event
async def get_value(sid: str, access_path: str) -> SerializedObject: async def get_value(sid: str, access_path: str) -> SerializedObject:
try: try:
return state_manager._data_service_cache.get_value_dict_from_cache( return state_manager.cache_manager.get_value_dict_from_cache(access_path)
access_path
)
except Exception as e: except Exception as e:
logger.exception(e) logger.exception(e)
return dump(e) return dump(e)

View File

@ -123,7 +123,7 @@ class WebServer:
self, self,
request: aiohttp.web.Request, request: aiohttp.web.Request,
) -> aiohttp.web.Response: ) -> aiohttp.web.Response:
return aiohttp.web.json_response(self.state_manager.cache) return aiohttp.web.json_response(self.state_manager.cache_manager.cache)
async def _web_settings_route( async def _web_settings_route(
self, self,

View File

@ -122,16 +122,20 @@ def get_class_and_instance_attributes(obj: object) -> dict[str, Any]:
def get_object_by_path_parts(target_obj: Any, path_parts: list[str]) -> Any: def get_object_by_path_parts(target_obj: Any, path_parts: list[str]) -> Any:
"""Gets nested attribute of `target_object` specified by `path_parts`.
Raises:
AttributeError: Attribute does not exist.
KeyError: Key in dict does not exist.
IndexError: Index out of list range.
TypeError: List index in the path is not a valid integer.
"""
for part in path_parts: for part in path_parts:
if part.startswith("["): if part.startswith("["):
deserialized_part = parse_serialized_key(part) deserialized_part = parse_serialized_key(part)
target_obj = target_obj[deserialized_part] target_obj = target_obj[deserialized_part]
else: else:
try:
target_obj = getattr(target_obj, part) target_obj = getattr(target_obj, part)
except AttributeError:
logger.debug("Attribute %a does not exist in the object.", part)
return None
return target_obj return target_obj
@ -149,7 +153,10 @@ def get_object_attr_from_path(target_obj: Any, path: str) -> Any:
the path does not exist, the function logs a debug message and returns None. the path does not exist, the function logs a debug message and returns None.
Raises: Raises:
ValueError: If a list index in the path is not a valid integer. AttributeError: Attribute does not exist.
KeyError: Key in dict does not exist.
IndexError: Index out of list range.
TypeError: List index in the path is not a valid integer.
""" """
path_parts = parse_full_access_path(path) path_parts = parse_full_access_path(path)
return get_object_by_path_parts(target_obj, path_parts) return get_object_by_path_parts(target_obj, path_parts)

View File

@ -51,10 +51,6 @@ class SerializationPathError(Exception):
pass pass
class SerializationValueError(Exception):
pass
class Serializer: class Serializer:
@classmethod @classmethod
def serialize_object(cls, obj: Any, access_path: str = "") -> SerializedObject: # noqa: C901 def serialize_object(cls, obj: Any, access_path: str = "") -> SerializedObject: # noqa: C901
@ -358,7 +354,7 @@ def set_nested_value_by_path(
next_level_serialized_object = get_container_item_by_key( next_level_serialized_object = get_container_item_by_key(
current_dict, path_parts[-1], allow_append=True current_dict, path_parts[-1], allow_append=True
) )
except (SerializationPathError, SerializationValueError, KeyError) as e: except (SerializationPathError, KeyError) as e:
logger.error("Error occured trying to change %a: %s", path, e) logger.error("Error occured trying to change %a: %s", path, e)
return return
@ -468,10 +464,6 @@ def get_container_item_by_key(
If the path composed of `attr_name` and any specified index is invalid, or If the path composed of `attr_name` and any specified index is invalid, or
leads to an IndexError or KeyError. This error is also raised if an attempt leads to an IndexError or KeyError. This error is also raised if an attempt
to access a nonexistent key or index occurs without permission to append. to access a nonexistent key or index occurs without permission to append.
SerializationValueError:
If the retrieval results in an object that is expected to be a dictionary
but is not, indicating a mismatch between expected and actual serialized
data structure.
""" """
processed_key = parse_serialized_key(key) processed_key = parse_serialized_key(key)

View File

@ -121,12 +121,16 @@ def test_dict(pydase_client: pydase.Client) -> None:
# pop will remove the dictionary entry on the server # pop will remove the dictionary entry on the server
assert list(pydase_client.proxy.dict_attr.keys()) == ["foo"] assert list(pydase_client.proxy.dict_attr.keys()) == ["foo"]
pydase_client.proxy.dict_attr["non_existent_key"] = "Hello"
assert pydase_client.proxy.dict_attr["non_existent_key"] == "Hello"
def test_tab_completion(pydase_client: pydase.Client) -> None: def test_tab_completion(pydase_client: pydase.Client) -> None:
# Tab completion gets its suggestions from the __dir__ class method # Tab completion gets its suggestions from the __dir__ class method
assert all( assert all(
x in pydase_client.proxy.__dir__() x in pydase_client.proxy.__dir__()
for x in [ for x in [
"dict_attr",
"list_attr", "list_attr",
"my_method", "my_method",
"my_property", "my_property",

View File

@ -22,13 +22,13 @@ def test_nested_attributes_cache_callback() -> None:
service_instance.name = "Peepz" service_instance.name = "Peepz"
assert ( assert (
state_manager._data_service_cache.get_value_dict_from_cache("name")["value"] state_manager.cache_manager.get_value_dict_from_cache("name")["value"]
== "Peepz" == "Peepz"
) )
service_instance.class_attr.name = "Ciao" service_instance.class_attr.name = "Ciao"
assert ( assert (
state_manager._data_service_cache.get_value_dict_from_cache("class_attr.name")[ state_manager.cache_manager.get_value_dict_from_cache("class_attr.name")[
"value" "value"
] ]
== "Ciao" == "Ciao"
@ -48,24 +48,20 @@ async def test_task_status_update() -> None:
DataServiceObserver(state_manager) DataServiceObserver(state_manager)
assert ( assert (
state_manager._data_service_cache.get_value_dict_from_cache("my_method")["type"] state_manager.cache_manager.get_value_dict_from_cache("my_method")["type"]
== "method" == "method"
) )
assert ( assert (
state_manager._data_service_cache.get_value_dict_from_cache("my_method")[ state_manager.cache_manager.get_value_dict_from_cache("my_method")["value"]
"value"
]
is None is None
) )
service_instance.start_my_method() # type: ignore service_instance.start_my_method() # type: ignore
assert ( assert (
state_manager._data_service_cache.get_value_dict_from_cache("my_method")["type"] state_manager.cache_manager.get_value_dict_from_cache("my_method")["type"]
== "method" == "method"
) )
assert ( assert (
state_manager._data_service_cache.get_value_dict_from_cache("my_method")[ state_manager.cache_manager.get_value_dict_from_cache("my_method")["value"]
"value"
]
== "RUNNING" == "RUNNING"
) )

View File

@ -103,9 +103,21 @@ def test_get_object_by_path_parts(path_parts: list[str], expected: Any) -> None:
assert get_object_by_path_parts(service_instance, path_parts) == expected assert get_object_by_path_parts(service_instance, path_parts) == expected
def test_get_object_by_path_parts_error(caplog: pytest.LogCaptureFixture) -> None: @pytest.mark.parametrize(
assert get_object_by_path_parts(service_instance, ["non_existent_attr"]) is None "path_parts, expected_exception",
assert "Attribute 'non_existent_attr' does not exist in the object." in caplog.text [
(["non_existent_attr"], AttributeError),
(["dict_attr", '["non_existent_key"]'], KeyError),
(["list_attr", "[2]"], IndexError),
(["list_attr", "[1.0]"], TypeError),
(["list_attr", '["string_key"]'], TypeError),
],
)
def test_get_object_by_path_parts_exception(
path_parts: list[str], expected_exception: type[Exception]
) -> None:
with pytest.raises(expected_exception):
get_object_by_path_parts(service_instance, path_parts)
@pytest.mark.parametrize( @pytest.mark.parametrize(