diff --git a/README.md b/README.md index b170bca..53fa467 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [](https://pypi.org/project/pytvpaint/) [](https://pepy.tech/project/pytvpaint) [](https://pypi.org/project/pytvpaint/) -[](https://www.tvpaint.com/doc/tvp11/) +[](https://doc.tvpaint.com/docs/)
diff --git a/docs/usage/usage.md b/docs/usage/usage.md
index b6c9fd5..551a424 100644
--- a/docs/usage/usage.md
+++ b/docs/usage/usage.md
@@ -12,15 +12,15 @@ their George counterparts. These can be imported from `pytvpaint.george.*`.
PyTVPaint can be configured using these variables:
-| Name | Default value | Description |
-|:-------------------------------|:-----------------|:-------------------------------------------------------------------------------------------------------------------------------|
-| `PYTVPAINT_LOG_LEVEL` | `INFO` | Changes the log level of PyTVPaint. Use the `DEBUG` value to see the RPC requests and responses for debugging George commands. |
-| `PYTVPAINT_LOG_PATH` | `` | Log file output path, default is `` |
-| `PYTVPAINT_WS_HOST` | `ws://localhost` | The hostname of the RPC over WebSocket server ([tvpaint-rpc](https://github.com/brunchstudio/tvpaint-rpc) plugin). |
-| `PYTVPAINT_WS_PORT` | `3000` | The port of the RPC over WebSocket server ([tvpaint-rpc](https://github.com/brunchstudio/tvpaint-rpc) plugin). |
-| `PYTVPAINT_WS_STARTUP_CONNECT` | `1` | Whether or not PyTVPaint should automatically connect to the WebSocket server at startup (module import). Accepts 0 or 1. |
-| `PYTVPAINT_WS_TIMEOUT` | `60` seconds | The timeout after which we stop reconnecting at startup or if the connection was lost. |
-| `TVP_RPC_LOG_PATH` | `` | C++ plugin log file output path, default is ``
+| Name | Default value | Description |
+|:----------------------------------|:-----------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `PYTVPAINT_LOG_LEVEL` | `INFO` | Changes the log level of PyTVPaint. Use the `DEBUG` value to see the RPC requests and responses for debugging George commands. |
+| `PYTVPAINT_LOG_PATH` | | Log file output path, if none provided no file log will be written. |
+| `PYTVPAINT_WS_HOST` | `ws://localhost` | The hostname of the RPC server ([tvpaint-rpc](https://github.com/brunchstudio/tvpaint-rpc) plugin). |
+| `PYTVPAINT_WS_PORT` | `3000` | The port of the RPC server ([tvpaint-rpc](https://github.com/brunchstudio/tvpaint-rpc) plugin). |
+| `PYTVPAINT_WS_TIMEOUT` | `60` seconds | The timeout in seconds before the client considers the connection lost. |
+| `PYTVPAINT_WS_STARTUP_CONNECT` | `1` | Whether or not PyTVPaint should automatically connect to the WebSocket server at startup (module import). Accepts 0 or 1. |
+| `TVP_RPC_LOG_PATH` | | C++ plugin log file output path, if none provided no file log will be written.
## Automatic client connection
diff --git a/pyproject.toml b/pyproject.toml
index 2dcac97..30b5e07 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,12 +8,18 @@ dynamic = ["version"]
description = "Python scripting for TVPaint"
readme = "README.md"
license = { text = "MIT" }
-requires-python = ">=3.9"
authors = [
{ name = "Brunch Studio Developers", email = "dev@brunchstudio.tv" },
{ name = "Radouane Lahmidi", email = "rlahmidi@brunchstudio.tv" },
{ name = "Joseph Henry", email = "jhenry@brunchstudio.tv" },
]
+requires-python = ">=3.9"
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11"
+]
keywords = ["tvpaint", "brunch", "tvp", "george"]
dependencies = [
"websocket-client>=1.7.0",
diff --git a/pytvpaint/george/client/__init__.py b/pytvpaint/george/client/__init__.py
index b722738..a76d074 100644
--- a/pytvpaint/george/client/__init__.py
+++ b/pytvpaint/george/client/__init__.py
@@ -24,10 +24,13 @@
def _connect_client(
- host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, timeout: int = DEFAULT_TIMEOUT, startup_connect: bool = True
+ host: str = DEFAULT_HOST,
+ port: int = DEFAULT_PORT,
+ timeout: int | None = DEFAULT_TIMEOUT,
+ startup_connect: bool = True,
) -> JSONRPCClient:
- _rpc_client = JSONRPCClient(f"{host}:{port}", timeout)
+ _rpc_client = JSONRPCClient(url=f"{host}:{port}", timeout=timeout)
if not startup_connect:
log.debug("Auto Connect Disabled, RPC client is not connected")
return _rpc_client
@@ -64,7 +67,12 @@ def _connect_client(
_rpc_port = int(os.getenv("PYTVPAINT_WS_PORT", DEFAULT_PORT))
_rpc_timeout = int(os.getenv("PYTVPAINT_WS_TIMEOUT", DEFAULT_TIMEOUT))
_rpc_startup_connect = bool(int(os.getenv("PYTVPAINT_WS_STARTUP_CONNECT", 1)))
-rpc_client = _connect_client(_rpc_host, _rpc_port, _rpc_timeout, _rpc_startup_connect)
+rpc_client = _connect_client(
+ host=_rpc_host,
+ port=_rpc_port,
+ timeout=_rpc_timeout,
+ startup_connect=_rpc_startup_connect,
+)
T = TypeVar("T", bound=Callable[..., Any])
diff --git a/pytvpaint/george/client/rpc.py b/pytvpaint/george/client/rpc.py
index 0c6693c..1aba36d 100644
--- a/pytvpaint/george/client/rpc.py
+++ b/pytvpaint/george/client/rpc.py
@@ -2,14 +2,19 @@
from __future__ import annotations
+import contextlib
import json
import select
import socket
import sys
+import threading
+from time import time
from typing import Any, Union, cast
from typing_extensions import NotRequired, TypedDict
-from websocket import WebSocket
+from websocket import WebSocket, WebSocketException
+
+from pytvpaint import log
JSONValueType = Union[str, int, float, bool, None]
@@ -57,72 +62,97 @@ def __init__(self, error: JSONRPCError) -> None:
class JSONRPCClient:
- """Simple JSON-RPC 2.0 client over websockets.
+ """Simple JSON-RPC 2.0 client over websockets with thread-safe automatic reconnection.
See: https://www.jsonrpc.org/specification#notification
"""
- def __init__(self, url: str, timeout: int = 60, max_retries: int = 5, version: str = "2.0") -> None:
+ def __init__(self, url: str, timeout: int | None = 60, version: str = "2.0") -> None:
"""Initialize a new JSON-RPC client with a WebSocket url endpoint.
Args:
url: the WebSocket url endpoint
- timeout: the socket operation timeout
- max_retries: the maximum socket connection retries
+ timeout: the continuous reconnection timeout threshold
version: The JSON-RPC version. Defaults to "2.0".
"""
self.ws_handle = WebSocket()
self.url = url
self.rpc_id = 0
self.timeout = timeout
- self.max_retries = max_retries
self.jsonrpc_version = version
+ self.stop_ping = threading.Event()
+ self.run_forever = False
+ self.ping_thread: threading.Thread | None = None
+
+ def _auto_reconnect(self) -> None:
+ """Automatic WebSocket reconnection in a background thread."""
+ disconnect_time: float = 0
+
+ while self.run_forever and not self.stop_ping.wait(1):
+ if self.is_connected:
+ disconnect_time = 0
+ with contextlib.suppress(WebSocketException, OSError):
+ self.ws_handle.ping()
+ continue
+
+ if disconnect_time == 0:
+ disconnect_time = time()
+
+ if self.timeout and (time() - disconnect_time) > self.timeout:
+ log.error("WebSocket automatic reconnection timeout exceeded.")
+ self.run_forever = False
+ break
+
+ with contextlib.suppress(Exception):
+ self.ws_handle.close()
+ self.ws_handle.connect(self.url, timeout=self.timeout)
+ log.info(f"Reconnected automatically to endpoint: {self.url}")
+
def __del__(self) -> None:
"""Called when the client goes out of scope."""
self.disconnect()
@property
def is_connected(self) -> bool:
- """Returns True if the client is connected and the socket is active."""
+ """Returns True if the client is connected and the socket is physically active."""
if not self.ws_handle.connected or self.ws_handle.sock is None:
return False
try:
- # check if the socket is readable.
+ # Non-blocking check to determine if the socket has received a FIN packet
readable_sockets, _, _ = select.select([self.ws_handle.sock], [], [], 0.0)
if readable_sockets:
- # MSG_PEEK reads data without consuming it from the buffer.
- # If recv returns 0 bytes on a readable socket, the peer has disconnected.
data = self.ws_handle.sock.recv(1, socket.MSG_PEEK)
if not data:
return False
except (BlockingIOError, InterruptedError):
- pass # Normal non-blocking behavior
+ pass
except Exception:
- return False # Socket error indicates disconnection
+ return False
return True
- def connect(self, timeout: int = 0) -> None:
- """Connects to the WebSocket endpoint and configures TCP keepalive."""
- self.ws_handle.connect(self.url, timeout=timeout)
-
- if self.ws_handle.sock:
- sock = self.ws_handle.sock
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
+ def connect(self) -> None:
+ """Connects to the WebSocket endpoint and initializes the monitoring thread."""
+ if self.timeout == 0:
+ raise ValueError("Timeout cannot be 0. Use None for infinite blocking or > 0 for a timed block.")
+ self.ws_handle.connect(self.url, timeout=self.timeout)
- # Apply OS-specific keepalive configurations if available
- if hasattr(socket, "TCP_KEEPIDLE"):
- sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, self.timeout)
- if hasattr(socket, "TCP_KEEPINTVL"):
- probe_interval = max(1, max(self.timeout, 1) // 6)
- sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, probe_interval)
- if hasattr(socket, "TCP_KEEPCNT"):
- sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, self.max_retries)
+ if not self.ping_thread or not self.ping_thread.is_alive():
+ self.stop_ping.clear()
+ self.run_forever = True
+ self.ping_thread = threading.Thread(target=self._auto_reconnect, daemon=True)
+ self.ping_thread.start()
def disconnect(self) -> None:
- """Disconnects from the server."""
+ """Disconnects from the server and halts the monitoring thread."""
+ self.run_forever = False
+ self.stop_ping.set()
+
+ if self.ping_thread and self.ping_thread.is_alive():
+ self.ping_thread.join(timeout=2)
+
self.ws_handle.close()
def increment_rpc_id(self) -> None:
@@ -134,7 +164,7 @@ def execute_remote(
method: str,
params: list[JSONValueType] | None = None,
) -> JSONRPCResponse:
- """Executes a remote procedure call.
+ """Executes a remote procedure call synchronously.
Args:
method: the name of the method to be invoked