Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![](https://img.shields.io/pypi/v/pytvpaint)](https://pypi.org/project/pytvpaint/)
[![Downloads](https://static.pepy.tech/badge/pytvpaint/month)](https://pepy.tech/project/pytvpaint)
[![](https://img.shields.io/pypi/pyversions/pytvpaint)](https://pypi.org/project/pytvpaint/)
[![](https://custom-icon-badges.demolab.com/badge/custom-11.5+-blue.svg?logo=butterfly_1f98b&label=TVPaint)](https://www.tvpaint.com/doc/tvp11/)
[![](https://custom-icon-badges.demolab.com/badge/custom-11.5+-blue.svg?logo=butterfly_1f98b&label=TVPaint)](https://doc.tvpaint.com/docs/)

<p align="center">
<img src="https://raw.githubusercontent.com/brunchstudio/pytvpaint/main/docs/assets/pytvpaint_code_banner.png" width=700 />
Expand Down
18 changes: 9 additions & 9 deletions docs/usage/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 11 additions & 3 deletions pytvpaint/george/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])

Expand Down
88 changes: 59 additions & 29 deletions pytvpaint/george/client/rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
Loading