diff --git a/src/pydase/client/client.py b/src/pydase/client/client.py index 39543d1..6827edc 100644 --- a/src/pydase/client/client.py +++ b/src/pydase/client/client.py @@ -2,12 +2,12 @@ import asyncio import logging import sys import threading -from typing import TypedDict, cast +from typing import TYPE_CHECKING, TypedDict, cast import socketio # type: ignore -import pydase.components -from pydase.client.proxy_loader import ProxyClassMixin, ProxyLoader +from pydase.client.proxy_class import ProxyClass +from pydase.client.proxy_loader import ProxyLoader from pydase.utils.serialization.deserializer import loads from pydase.utils.serialization.types import SerializedDataService, SerializedObject @@ -34,47 +34,6 @@ def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None: loop.run_forever() -class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection): - """ - A proxy class that serves as the interface for interacting with device connections - via a socket.io client in an asyncio environment. - - Args: - sio_client: - The socket.io client instance used for asynchronous communication with the - pydase service server. - loop: - The event loop in which the client operations are managed and executed. - - This class is used to create a proxy object that behaves like a local representation - of a remote pydase service, facilitating direct interaction as if it were local - while actually communicating over network protocols. - It can also be used as an attribute of a pydase service itself, e.g. - - ```python - import pydase - - - class MyService(pydase.DataService): - proxy = pydase.Client( - hostname="...", port=8001, block_until_connected=False - ).proxy - - - if __name__ == "__main__": - service = MyService() - server = pydase.Server(service, web_port=8002).run() - ``` - """ - - def __init__( - self, sio_client: socketio.AsyncClient, loop: asyncio.AbstractEventLoop - ) -> None: - super().__init__() - pydase.components.DeviceConnection.__init__(self) - self._initialise(sio_client=sio_client, loop=loop) - - class Client: """ A client for connecting to a remote pydase service using socket.io. This client @@ -162,7 +121,13 @@ class Client: self.proxy, serialized_object=serialized_object ) serialized_object["type"] = "DeviceConnection" - self.proxy._notify_changed("", loads(serialized_object)) + if self.proxy._service_representation is not None: + # need to use object.__setattr__ to not trigger an observer notification + object.__setattr__(self.proxy, "_service_representation", serialized_object) + + if TYPE_CHECKING: + self.proxy._service_representation = serialized_object # type: ignore + self.proxy._notify_changed("", self.proxy) self.proxy._connected = True async def _handle_disconnect(self) -> None: diff --git a/src/pydase/client/proxy_class.py b/src/pydase/client/proxy_class.py new file mode 100644 index 0000000..5295823 --- /dev/null +++ b/src/pydase/client/proxy_class.py @@ -0,0 +1,101 @@ +import asyncio +import logging +from copy import deepcopy +from typing import TYPE_CHECKING, cast + +import socketio # type: ignore + +import pydase.components +from pydase.client.proxy_loader import ProxyClassMixin +from pydase.utils.helpers import get_attribute_doc +from pydase.utils.serialization.types import SerializedDataService, SerializedObject + +logger = logging.getLogger(__name__) + + +class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection): + """ + A proxy class that serves as the interface for interacting with device connections + via a socket.io client in an asyncio environment. + + Args: + sio_client: + The socket.io client instance used for asynchronous communication with the + pydase service server. + loop: + The event loop in which the client operations are managed and executed. + + This class is used to create a proxy object that behaves like a local representation + of a remote pydase service, facilitating direct interaction as if it were local + while actually communicating over network protocols. + It can also be used as an attribute of a pydase service itself, e.g. + + ```python + import pydase + + + class MyService(pydase.DataService): + proxy = pydase.Client( + hostname="...", port=8001, block_until_connected=False + ).proxy + + + if __name__ == "__main__": + service = MyService() + server = pydase.Server(service, web_port=8002).run() + ``` + """ + + def __init__( + self, sio_client: socketio.AsyncClient, loop: asyncio.AbstractEventLoop + ) -> None: + if TYPE_CHECKING: + self._service_representation: None | SerializedObject = None + + super().__init__() + pydase.components.DeviceConnection.__init__(self) + self._initialise(sio_client=sio_client, loop=loop) + object.__setattr__(self, "_service_representation", None) + + def serialize(self) -> SerializedObject: + if self._service_representation is None: + serialization_future = cast( + asyncio.Future[SerializedDataService], + asyncio.run_coroutine_threadsafe( + self._sio.call("service_serialization"), self._loop + ), + ) + # need to use object.__setattr__ to not trigger an observer notification + object.__setattr__( + self, "_service_representation", serialization_future.result() + ) + if TYPE_CHECKING: + self._service_representation = serialization_future.result() + + device_connection_value = cast( + dict[str, SerializedObject], + pydase.components.DeviceConnection().serialize()["value"], + ) + + readonly = False + doc = get_attribute_doc(self) + obj_name = self.__class__.__name__ + + value = { + **cast( + dict[str, SerializedObject], + # need to deepcopy to not overwrite the _service_representation dict + # when adding a prefix with add_prefix_to_full_access_path + deepcopy(self._service_representation["value"]), + ), + **device_connection_value, + } + + return { + "full_access_path": "", + "name": obj_name, + "type": "DeviceConnection", + "value": value, + "readonly": readonly, + "doc": doc, + } diff --git a/src/pydase/utils/serialization/serializer.py b/src/pydase/utils/serialization/serializer.py index b3d5028..c945f88 100644 --- a/src/pydase/utils/serialization/serializer.py +++ b/src/pydase/utils/serialization/serializer.py @@ -42,6 +42,8 @@ from pydase.utils.serialization.types import ( if TYPE_CHECKING: from collections.abc import Callable + from pydase.client.proxy_class import ProxyClass + logger = logging.getLogger(__name__) @@ -74,6 +76,7 @@ class Serializer: Returns: Dictionary representation of `obj`. """ + from pydase.client.client import ProxyClass result: SerializedObject @@ -83,6 +86,9 @@ class Serializer: elif isinstance(obj, datetime): result = cls._serialize_datetime(obj, access_path=access_path) + elif isinstance(obj, ProxyClass): + result = cls._serialize_proxy_class(obj, access_path=access_path) + elif isinstance(obj, AbstractDataService): result = cls._serialize_data_service(obj, access_path=access_path) @@ -322,6 +328,13 @@ class Serializer: "doc": doc, } + @classmethod + def _serialize_proxy_class( + cls, obj: ProxyClass, access_path: str = "" + ) -> SerializedDataService: + # Get serialization value from the remote service and adapt the full_access_path + return add_prefix_to_full_access_path(obj.serialize(), access_path) + def dump(obj: Any) -> SerializedObject: """Serialize `obj` to a @@ -572,6 +585,62 @@ def generate_serialized_data_paths( return paths +def add_prefix_to_full_access_path( + serialized_obj: SerializedObject, prefix: str +) -> Any: + """Recursively adds a specified prefix to all full access paths of the serialized + object. + + Args: + data: + The serialized object to process. + prefix: + The prefix string to prepend to each full access path. + + Returns: + The modified serialized object with the prefix added to all full access paths. + + Example: + ```python + >>> data = { + ... "full_access_path": "", + ... "value": { + ... "item": { + ... "full_access_path": "some_item_path", + ... "value": 1.0 + ... } + ... } + ... } + ... + ... modified_data = add_prefix_to_full_access_path(data, 'prefix') + {"full_access_path": "prefix", "value": {"item": {"full_access_path": + "prefix.some_item_path", "value": 1.0}}} + ``` + """ + + try: + if serialized_obj.get("full_access_path", None) is not None: + serialized_obj["full_access_path"] = ( + prefix + "." + serialized_obj["full_access_path"] + if serialized_obj["full_access_path"] != "" + else prefix + ) + + if isinstance(serialized_obj["value"], list): + for value in serialized_obj["value"]: + add_prefix_to_full_access_path(cast(SerializedObject, value), prefix) + + elif isinstance(serialized_obj["value"], dict): + for value in cast( + dict[str, SerializedObject], serialized_obj["value"] + ).values(): + add_prefix_to_full_access_path(cast(SerializedObject, value), prefix) + except (TypeError, KeyError, AttributeError): + # passed dictionary is not a serialized object + pass + return serialized_obj + + def serialized_dict_is_nested_object(serialized_dict: SerializedObject) -> bool: value = serialized_dict["value"] # We are excluding Quantity here as the value corresponding to the "value" key is diff --git a/tests/utils/serialization/test_serializer.py b/tests/utils/serialization/test_serializer.py index 6c1ab81..dd0fb0b 100644 --- a/tests/utils/serialization/test_serializer.py +++ b/tests/utils/serialization/test_serializer.py @@ -12,6 +12,7 @@ from pydase.utils.decorators import frontend from pydase.utils.serialization.serializer import ( SerializationPathError, SerializedObject, + add_prefix_to_full_access_path, dump, generate_serialized_data_paths, get_container_item_by_key, @@ -1070,3 +1071,156 @@ def test_get_data_paths_from_serialized_object(obj: Any, expected: list[str]) -> ) def test_generate_serialized_data_paths(obj: Any, expected: list[str]) -> None: assert generate_serialized_data_paths(dump(obj=obj)["value"]) == expected + + +@pytest.mark.parametrize( + "serialized_obj, prefix, expected", + [ + ( + { + "full_access_path": "new_attr", + "value": { + "name": { + "full_access_path": "new_attr.name", + "value": "MyService", + } + }, + }, + "prefix", + { + "full_access_path": "prefix.new_attr", + "value": { + "name": { + "full_access_path": "prefix.new_attr.name", + "value": "MyService", + } + }, + }, + ), + ( + { + "full_access_path": "new_attr", + "value": [ + { + "full_access_path": "new_attr[0]", + "value": 1.0, + } + ], + }, + "prefix", + { + "full_access_path": "prefix.new_attr", + "value": [ + { + "full_access_path": "prefix.new_attr[0]", + "value": 1.0, + } + ], + }, + ), + ( + { + "full_access_path": "new_attr", + "value": { + "key": { + "full_access_path": 'new_attr["key"]', + "value": 1.0, + } + }, + }, + "prefix", + { + "full_access_path": "prefix.new_attr", + "value": { + "key": { + "full_access_path": 'prefix.new_attr["key"]', + "value": 1.0, + } + }, + }, + ), + ( + { + "full_access_path": "new_attr", + "value": {"magnitude": 10, "unit": "meter"}, + }, + "prefix", + { + "full_access_path": "prefix.new_attr", + "value": {"magnitude": 10, "unit": "meter"}, + }, + ), + ( + { + "full_access_path": "quantity_list", + "value": [ + { + "full_access_path": "quantity_list[0]", + "value": {"magnitude": 1.0, "unit": "A"}, + } + ], + }, + "prefix", + { + "full_access_path": "prefix.quantity_list", + "value": [ + { + "full_access_path": "prefix.quantity_list[0]", + "value": {"magnitude": 1.0, "unit": "A"}, + } + ], + }, + ), + ( + { + "full_access_path": "", + "value": { + "dict_attr": { + "type": "dict", + "full_access_path": "dict_attr", + "value": { + "foo": { + "full_access_path": 'dict_attr["foo"]', + "type": "dict", + "value": { + "some_int": { + "full_access_path": 'dict_attr["foo"].some_int', + "type": "int", + "value": 1, + }, + }, + }, + }, + } + }, + }, + "prefix", + { + "full_access_path": "prefix", + "value": { + "dict_attr": { + "type": "dict", + "full_access_path": "prefix.dict_attr", + "value": { + "foo": { + "full_access_path": 'prefix.dict_attr["foo"]', + "type": "dict", + "value": { + "some_int": { + "full_access_path": 'prefix.dict_attr["foo"].some_int', + "type": "int", + "value": 1, + }, + }, + }, + }, + } + }, + }, + ), + ], +) +def test_add_prefix_to_full_access_path( + serialized_obj: SerializedObject, prefix: str, expected: SerializedObject +) -> None: + assert add_prefix_to_full_access_path(serialized_obj, prefix) == expected