diff --git a/src/pydase/client/client.py b/src/pydase/client/client.py index 61e3291..29f0d68 100644 --- a/src/pydase/client/client.py +++ b/src/pydase/client/client.py @@ -86,7 +86,9 @@ class Client: self._url = url self._sio = socketio.AsyncClient(**sio_client_kwargs) self._loop = asyncio.new_event_loop() - self.proxy = ProxyClass(sio_client=self._sio, loop=self._loop) + self.proxy = ProxyClass( + sio_client=self._sio, loop=self._loop, reconnect=self.connect + ) """A proxy object representing the remote service, facilitating interaction as if it were local.""" self._thread = threading.Thread( diff --git a/src/pydase/client/proxy_class.py b/src/pydase/client/proxy_class.py index 5295823..422dd2d 100644 --- a/src/pydase/client/proxy_class.py +++ b/src/pydase/client/proxy_class.py @@ -1,5 +1,6 @@ import asyncio import logging +from collections.abc import Callable from copy import deepcopy from typing import TYPE_CHECKING, cast @@ -24,6 +25,8 @@ class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection): pydase service server. loop: The event loop in which the client operations are managed and executed. + reconnect: + The method that is called periodically when the client is not connected. 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 @@ -47,7 +50,10 @@ class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection): """ def __init__( - self, sio_client: socketio.AsyncClient, loop: asyncio.AbstractEventLoop + self, + sio_client: socketio.AsyncClient, + loop: asyncio.AbstractEventLoop, + reconnect: Callable[..., None], ) -> None: if TYPE_CHECKING: self._service_representation: None | SerializedObject = None @@ -56,6 +62,7 @@ class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection): pydase.components.DeviceConnection.__init__(self) self._initialise(sio_client=sio_client, loop=loop) object.__setattr__(self, "_service_representation", None) + self.reconnect = reconnect def serialize(self) -> SerializedObject: if self._service_representation is None: @@ -99,3 +106,7 @@ class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection): "readonly": readonly, "doc": doc, } + + def connect(self) -> None: + if not self._sio.reconnection or self._sio.reconnection_attempts > 0: + self.reconnect(block_until_connected=False) diff --git a/tests/client/test_reconnection.py b/tests/client/test_reconnection.py new file mode 100644 index 0000000..3ecce91 --- /dev/null +++ b/tests/client/test_reconnection.py @@ -0,0 +1,107 @@ +import threading +from collections.abc import Callable, Generator +from typing import Any + +import pydase +import pytest +import socketio.exceptions + + +@pytest.fixture(scope="function") +def pydase_restartable_server() -> ( + Generator[ + tuple[ + pydase.Server, + threading.Thread, + pydase.DataService, + Callable[ + [pydase.Server, threading.Thread, pydase.DataService], + tuple[pydase.Server, threading.Thread], + ], + ], + None, + Any, + ] +): + class MyService(pydase.DataService): + def __init__(self) -> None: + super().__init__() + self._name = "MyService" + self._my_property = 12.1 + + @property + def my_property(self) -> float: + return self._my_property + + @my_property.setter + def my_property(self, value: float) -> None: + self._my_property = value + + @property + def name(self) -> str: + return self._name + + service_instance = MyService() + server = pydase.Server(service_instance, web_port=9999) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + + def restart( + server: pydase.Server, + thread: threading.Thread, + service_instance: pydase.DataService, + ) -> tuple[pydase.Server, threading.Thread]: + server.handle_exit() + thread.join() + + server = pydase.Server(service_instance, web_port=9999) + new_thread = threading.Thread(target=server.run, daemon=True) + new_thread.start() + + return server, new_thread + + yield server, thread, service_instance, restart + + server.handle_exit() + thread.join() + + +def test_reconnection( + pydase_restartable_server: tuple[ + pydase.Server, + threading.Thread, + pydase.DataService, + Callable[ + [pydase.Server, threading.Thread, pydase.DataService], + tuple[pydase.Server, threading.Thread], + ], + ], +) -> None: + client = pydase.Client( + url="ws://localhost:9999", + sio_client_kwargs={ + "reconnection": False, + }, + ) + client_2 = pydase.Client( + url="ws://localhost:9999", + sio_client_kwargs={ + "reconnection_attempts": 1, + }, + ) + + server, thread, service_instance, restart = pydase_restartable_server + service_instance._name = "New service name" + + server, thread = restart(server, thread, service_instance) + + with pytest.raises(socketio.exceptions.BadNamespaceError): + client.proxy.name + client_2.proxy.name + + client.proxy.reconnect() + client_2.proxy.reconnect() + + # the service proxies successfully reconnect and get the new service name + assert client.proxy.name == "New service name" + assert client_2.proxy.name == "New service name"