mirror of
https://github.com/tiqi-group/pydase.git
synced 2025-04-20 00:10:03 +02:00
Merge pull request #169 from tiqi-group/fix/proxy_class_representation
Fix: proxy class representation
This commit is contained in:
commit
d8685fe9a0
@ -2,12 +2,12 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from typing import TypedDict, cast
|
from typing import TYPE_CHECKING, TypedDict, cast
|
||||||
|
|
||||||
import socketio # type: ignore
|
import socketio # type: ignore
|
||||||
|
|
||||||
import pydase.components
|
from pydase.client.proxy_class import ProxyClass
|
||||||
from pydase.client.proxy_loader import ProxyClassMixin, ProxyLoader
|
from pydase.client.proxy_loader import ProxyLoader
|
||||||
from pydase.utils.serialization.deserializer import loads
|
from pydase.utils.serialization.deserializer import loads
|
||||||
from pydase.utils.serialization.types import SerializedDataService, SerializedObject
|
from pydase.utils.serialization.types import SerializedDataService, SerializedObject
|
||||||
|
|
||||||
@ -34,47 +34,6 @@ def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
|
|||||||
loop.run_forever()
|
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:
|
class Client:
|
||||||
"""
|
"""
|
||||||
A client for connecting to a remote pydase service using socket.io. This 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
|
self.proxy, serialized_object=serialized_object
|
||||||
)
|
)
|
||||||
serialized_object["type"] = "DeviceConnection"
|
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
|
self.proxy._connected = True
|
||||||
|
|
||||||
async def _handle_disconnect(self) -> None:
|
async def _handle_disconnect(self) -> None:
|
||||||
|
101
src/pydase/client/proxy_class.py
Normal file
101
src/pydase/client/proxy_class.py
Normal file
@ -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,
|
||||||
|
}
|
@ -42,6 +42,8 @@ from pydase.utils.serialization.types import (
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from pydase.client.proxy_class import ProxyClass
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -74,6 +76,7 @@ class Serializer:
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary representation of `obj`.
|
Dictionary representation of `obj`.
|
||||||
"""
|
"""
|
||||||
|
from pydase.client.client import ProxyClass
|
||||||
|
|
||||||
result: SerializedObject
|
result: SerializedObject
|
||||||
|
|
||||||
@ -83,6 +86,9 @@ class Serializer:
|
|||||||
elif isinstance(obj, datetime):
|
elif isinstance(obj, datetime):
|
||||||
result = cls._serialize_datetime(obj, access_path=access_path)
|
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):
|
elif isinstance(obj, AbstractDataService):
|
||||||
result = cls._serialize_data_service(obj, access_path=access_path)
|
result = cls._serialize_data_service(obj, access_path=access_path)
|
||||||
|
|
||||||
@ -322,6 +328,13 @@ class Serializer:
|
|||||||
"doc": doc,
|
"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:
|
def dump(obj: Any) -> SerializedObject:
|
||||||
"""Serialize `obj` to a
|
"""Serialize `obj` to a
|
||||||
@ -572,6 +585,62 @@ def generate_serialized_data_paths(
|
|||||||
return 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:
|
def serialized_dict_is_nested_object(serialized_dict: SerializedObject) -> bool:
|
||||||
value = serialized_dict["value"]
|
value = serialized_dict["value"]
|
||||||
# We are excluding Quantity here as the value corresponding to the "value" key is
|
# We are excluding Quantity here as the value corresponding to the "value" key is
|
||||||
|
@ -12,6 +12,7 @@ from pydase.utils.decorators import frontend
|
|||||||
from pydase.utils.serialization.serializer import (
|
from pydase.utils.serialization.serializer import (
|
||||||
SerializationPathError,
|
SerializationPathError,
|
||||||
SerializedObject,
|
SerializedObject,
|
||||||
|
add_prefix_to_full_access_path,
|
||||||
dump,
|
dump,
|
||||||
generate_serialized_data_paths,
|
generate_serialized_data_paths,
|
||||||
get_container_item_by_key,
|
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:
|
def test_generate_serialized_data_paths(obj: Any, expected: list[str]) -> None:
|
||||||
assert generate_serialized_data_paths(dump(obj=obj)["value"]) == expected
|
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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user