mirror of
https://github.com/tiqi-group/pydase.git
synced 2025-06-06 13:30:41 +02:00
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:
commit
fd73653433
@ -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,
|
|
||||||
}
|
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
@ -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":
|
||||||
|
@ -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)
|
||||||
|
@ -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,
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
@ -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"
|
||||||
)
|
)
|
||||||
|
@ -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(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user