Merge pull request #172 from tiqi-group/feat/update_client_reconnection

Feat: update client reconnection
This commit is contained in:
Mose Müller 2024-10-02 09:29:39 +02:00 committed by GitHub
commit eb32b34b59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 198 additions and 30 deletions

View File

@ -1,60 +1,86 @@
# Python RPC Client # Python RPC Client
You can connect to the service using the `pydase.Client`. Below is an example of how to establish a connection to a service and interact with it: The [`pydase.Client`][pydase.Client] allows you to connect to a remote `pydase` service using socket.io, facilitating interaction with the service as though it were running locally.
## Basic Usage
```python ```python
import pydase import pydase
# Replace the hostname and port with the IP address and the port of the machine where # Replace <ip_addr> and <service_port> with the appropriate values for your service
# the service is running, respectively
client_proxy = pydase.Client(url="ws://<ip_addr>:<service_port>").proxy client_proxy = pydase.Client(url="ws://<ip_addr>:<service_port>").proxy
# client_proxy = pydase.Client(url="wss://your-domain.ch").proxy # if your service uses ssl-encryption # For SSL-encrypted services, use the wss protocol
# client_proxy = pydase.Client(url="wss://your-domain.ch").proxy
# Interact with the service attributes as if they were local # Interact with the service attributes as if they were local
client_proxy.voltage = 5.0 client_proxy.voltage = 5.0
print(client_proxy.voltage) # Expected output: 5.0 print(client_proxy.voltage) # Expected output: 5.0
``` ```
This example demonstrates setting and retrieving the `voltage` attribute through the client proxy. This example shows how to set and retrieve the `voltage` attribute through the client proxy.
The proxy acts as a local representative of the remote service, enabling straightforward interaction. The proxy acts as a local representation of the remote service, enabling intuitive interaction.
The proxy class dynamically synchronizes with the server's exposed attributes. This synchronization allows the proxy to be automatically updated with any attributes or methods that the server exposes, essentially mirroring the server's API. This dynamic updating enables users to interact with the remote service as if they were working with a local object. The proxy class automatically synchronizes with the server's attributes and methods, keeping itself up-to-date with any changes. This dynamic synchronization essentially mirrors the server's API, making it feel like you're working with a local object.
## Context Manager ## Context Manager Support
You can also use the client as a context manager which automatically opens and closes the connection again: You can also use the client within a context manager, which automatically handles connection management (i.e., opening and closing the connection):
```python ```python
import pydase import pydase
with pydase.Client(url="ws://localhost:8001") as client: with pydase.Client(url="ws://localhost:8001") as client:
client.proxy.<my_method>() client.proxy.my_method()
``` ```
Using the context manager ensures that connections are cleanly closed once the block of code finishes executing.
## Tab Completion Support ## Tab Completion Support
In interactive environments such as Python interpreters and Jupyter notebooks, the proxy class supports tab completion, which allows users to explore available methods and attributes. In interactive environments like Python interpreters or Jupyter notebooks, the proxy supports tab completion. This allows users to explore available methods and attributes.
## Integration within Other Services ## Integrating the Client into Another Service
You can also integrate a client proxy within another service. Here's how you can set it up: You can integrate a `pydase` client proxy within another service. Here's an example of how to set this up:
```python ```python
import pydase import pydase
class MyService(pydase.DataService): class MyService(pydase.DataService):
# Initialize the client without blocking the constructor proxy = pydase.Client(
proxy = pydase.Client(url="ws://<ip_addr>:<service_port>", block_until_connected=False).proxy url="ws://<ip_addr>:<service_port>",
# proxy = pydase.Client(url="wss://your-domain.ch", block_until_connected=False).proxy # communicating with ssl-encrypted service block_until_connected=False
).proxy
# For SSL-encrypted services, use the wss protocol
# proxy = pydase.Client(
# url="wss://your-domain.ch",
# block_until_connected=False
# ).proxy
if __name__ == "__main__": if __name__ == "__main__":
service = MyService() service = MyService()
# Create a server that exposes this service; adjust the web_port as needed # Create a server that exposes this service
server = pydase.Server(service, web_port=8002). run() server = pydase.Server(service, web_port=8002).run()
``` ```
In this setup, the `MyService` class has a `proxy` attribute that connects to a `pydase` service located at `<ip_addr>:8001`. In this example:
The `block_until_connected=False` argument allows the service to start up even if the initial connection attempt fails. - The `MyService` class has a `proxy` attribute that connects to a `pydase` service at `<ip_addr>:<service_port>`.
This configuration is particularly useful in distributed systems where services may start in any order. - By setting `block_until_connected=False`, the service can start without waiting for the connection to succeed, which is particularly useful in distributed systems where services may initialize in any order.
## Custom `socketio.AsyncClient` Connection Parameters
You can also configure advanced connection options by passing additional arguments to the underlying [`AsyncClient`][socketio.AsyncClient] via `sio_client_kwargs`. This allows you to fine-tune reconnection behaviour, delays, and other settings:
```python
client = pydase.Client(
url="ws://localhost:8001",
sio_client_kwargs={
"reconnection_attempts": 3,
"reconnection_delay": 2,
"reconnection_delay_max": 10,
}
).proxy
```
In this setup, the client will attempt to reconnect three times, with an initial delay of 2 seconds (each successive attempt doubles this delay) and a maximum delay of 10 seconds between attempts.

View File

@ -54,6 +54,7 @@ plugins:
- https://docs.python.org/3/objects.inv - https://docs.python.org/3/objects.inv
- https://docs.pydantic.dev/latest/objects.inv - https://docs.pydantic.dev/latest/objects.inv
- https://confz.readthedocs.io/en/latest/objects.inv - https://confz.readthedocs.io/en/latest/objects.inv
- https://python-socketio.readthedocs.io/en/stable/objects.inv
options: options:
show_source: true show_source: true
inherited_members: true inherited_members: true

View File

@ -2,7 +2,7 @@ import asyncio
import logging import logging
import sys import sys
import threading import threading
from typing import TYPE_CHECKING, TypedDict, cast from typing import TYPE_CHECKING, Any, TypedDict, cast
import socketio # type: ignore import socketio # type: ignore
@ -45,15 +45,35 @@ class Client:
url: url:
The URL of the pydase Socket.IO server. This should always contain the The URL of the pydase Socket.IO server. This should always contain the
protocol and the hostname. protocol and the hostname.
Examples:
- `wss://my-service.example.com` # for secure connections, use wss
- `ws://localhost:8001`
block_until_connected: block_until_connected:
If set to True, the constructor will block until the connection to the If set to True, the constructor will block until the connection to the
service has been established. This is useful for ensuring the client is service has been established. This is useful for ensuring the client is
ready to use immediately after instantiation. Default is True. ready to use immediately after instantiation. Default is True.
sio_client_kwargs:
Additional keyword arguments passed to the underlying
[`AsyncClient`][socketio.AsyncClient]. This allows fine-tuning of the
client's behaviour (e.g., reconnection attempts or reconnection delay).
Default is an empty dictionary.
Example:
The following example demonstrates a `Client` instance that connects to another
pydase service, while customising some of the connection settings for the
underlying [`AsyncClient`][socketio.AsyncClient].
```python
pydase.Client(url="ws://localhost:8001", sio_client_kwargs={
"reconnection_attempts": 2,
"reconnection_delay": 2,
"reconnection_delay_max": 8,
})
```
When connecting to a server over a secure connection (i.e., the server is using
SSL/TLS encryption), make sure that the `wss` protocol is used instead of `ws`:
```python
pydase.Client(url="wss://my-service.example.com")
```
""" """
def __init__( def __init__(
@ -61,11 +81,14 @@ class Client:
*, *,
url: str, url: str,
block_until_connected: bool = True, block_until_connected: bool = True,
sio_client_kwargs: dict[str, Any] = {},
): ):
self._url = url self._url = url
self._sio = socketio.AsyncClient() self._sio = socketio.AsyncClient(**sio_client_kwargs)
self._loop = asyncio.new_event_loop() 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 """A proxy object representing the remote service, facilitating interaction as
if it were local.""" if it were local."""
self._thread = threading.Thread( self._thread = threading.Thread(

View File

@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
from collections.abc import Callable
from copy import deepcopy from copy import deepcopy
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
@ -24,6 +25,8 @@ class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection):
pydase service server. pydase service server.
loop: loop:
The event loop in which the client operations are managed and executed. 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 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 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__( def __init__(
self, sio_client: socketio.AsyncClient, loop: asyncio.AbstractEventLoop self,
sio_client: socketio.AsyncClient,
loop: asyncio.AbstractEventLoop,
reconnect: Callable[..., None],
) -> None: ) -> None:
if TYPE_CHECKING: if TYPE_CHECKING:
self._service_representation: None | SerializedObject = None self._service_representation: None | SerializedObject = None
@ -56,6 +62,7 @@ class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection):
pydase.components.DeviceConnection.__init__(self) pydase.components.DeviceConnection.__init__(self)
self._initialise(sio_client=sio_client, loop=loop) self._initialise(sio_client=sio_client, loop=loop)
object.__setattr__(self, "_service_representation", None) object.__setattr__(self, "_service_representation", None)
self.reconnect = reconnect
def serialize(self) -> SerializedObject: def serialize(self) -> SerializedObject:
if self._service_representation is None: if self._service_representation is None:
@ -99,3 +106,7 @@ class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection):
"readonly": readonly, "readonly": readonly,
"doc": doc, "doc": doc,
} }
def connect(self) -> None:
if not self._sio.reconnection or self._sio.reconnection_attempts > 0:
self.reconnect(block_until_connected=False)

View File

@ -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"