hypha-rpc 0.20.86__tar.gz → 0.20.88__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/PKG-INFO +1 -1
- hypha_rpc-0.20.88/hypha_rpc/VERSION +3 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/__init__.py +4 -7
- hypha_rpc-0.20.88/hypha_rpc/client.py +123 -0
- hypha_rpc-0.20.88/hypha_rpc/http_client.py +555 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/rpc.py +5 -1
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/websocket_client.py +2 -63
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc.egg-info/PKG-INFO +1 -1
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc.egg-info/SOURCES.txt +2 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/pyproject.toml +1 -1
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/tests/test_websocket_rpc.py +19 -3
- hypha_rpc-0.20.86/hypha_rpc/VERSION +0 -3
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/MANIFEST.in +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/README.md +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/pyodide_sse.py +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/pyodide_websocket.py +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/sync.py +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/utils/__init__.py +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/utils/launch.py +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/utils/mcp.py +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/utils/pydantic.py +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/utils/schema.py +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/utils/serve.py +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/webrtc_client.py +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc.egg-info/dependency_links.txt +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc.egg-info/requires.txt +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc.egg-info/top_level.txt +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/setup.cfg +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/tests/test_mcp.py +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/tests/test_reconnection_runner.py +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/tests/test_reconnection_stability.py +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/tests/test_schema.py +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/tests/test_server_compatibility.py +0 -0
- {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/tests/test_utils.py +0 -0
|
@@ -15,13 +15,9 @@ from .sync import login as login_sync
|
|
|
15
15
|
from .sync import logout as logout_sync
|
|
16
16
|
from .sync import register_rtc_service as register_rtc_service_sync
|
|
17
17
|
from .webrtc_client import get_rtc_service, register_rtc_service
|
|
18
|
-
from .
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
login,
|
|
22
|
-
logout,
|
|
23
|
-
setup_local_client,
|
|
24
|
-
)
|
|
18
|
+
from .client import connect_to_server, get_remote_service
|
|
19
|
+
from .websocket_client import login, logout, setup_local_client
|
|
20
|
+
from .http_client import HTTPStreamingRPCConnection
|
|
25
21
|
|
|
26
22
|
# read the version from the VERSION file; but get the path from the __file__
|
|
27
23
|
with open(os.path.join(os.path.dirname(__file__), "VERSION"), "r") as f:
|
|
@@ -118,6 +114,7 @@ __all__ = [
|
|
|
118
114
|
"logout",
|
|
119
115
|
"connect_to_server",
|
|
120
116
|
"get_remote_service",
|
|
117
|
+
"HTTPStreamingRPCConnection",
|
|
121
118
|
"login_sync",
|
|
122
119
|
"logout_sync",
|
|
123
120
|
"connect_to_server_sync",
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Hypha RPC client - unified connection interface.
|
|
2
|
+
|
|
3
|
+
This module provides the main entry point for connecting to Hypha servers.
|
|
4
|
+
It supports multiple transport types:
|
|
5
|
+
- "websocket" (default): Traditional WebSocket connection
|
|
6
|
+
- "http": HTTP streaming connection (more resilient to network issues)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from .utils import parse_service_url
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ServerContextManager:
|
|
14
|
+
"""Server context manager.
|
|
15
|
+
|
|
16
|
+
Supports multiple transport types:
|
|
17
|
+
- "websocket" (default): Traditional WebSocket connection
|
|
18
|
+
- "http": HTTP streaming connection (more resilient to network issues)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, config=None, service_id=None, **kwargs):
|
|
22
|
+
self.config = config or {}
|
|
23
|
+
self.config.update(kwargs)
|
|
24
|
+
|
|
25
|
+
if not self.config:
|
|
26
|
+
# try to load from env
|
|
27
|
+
if not os.environ.get("HYPHA_SERVER_URL"):
|
|
28
|
+
try:
|
|
29
|
+
from dotenv import load_dotenv, find_dotenv
|
|
30
|
+
load_dotenv(dotenv_path=find_dotenv(usecwd=True))
|
|
31
|
+
# use info from .env file
|
|
32
|
+
print("✅ Loaded connection configuration from .env file.")
|
|
33
|
+
except ImportError:
|
|
34
|
+
pass
|
|
35
|
+
self.config = {
|
|
36
|
+
"server_url": os.getenv("HYPHA_SERVER_URL"),
|
|
37
|
+
"token": os.getenv("HYPHA_TOKEN"),
|
|
38
|
+
"client_id": os.getenv("HYPHA_CLIENT_ID"),
|
|
39
|
+
"workspace": os.getenv("HYPHA_WORKSPACE"),
|
|
40
|
+
}
|
|
41
|
+
if not self.config["server_url"]:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"Please set the HYPHA_SERVER_URL, HYPHA_TOKEN, "
|
|
44
|
+
"HYPHA_CLIENT_ID, and HYPHA_WORKSPACE environment variables"
|
|
45
|
+
)
|
|
46
|
+
self._service_id = service_id
|
|
47
|
+
self._transport = self.config.pop("transport", "websocket")
|
|
48
|
+
self.wm = None
|
|
49
|
+
|
|
50
|
+
async def __aenter__(self):
|
|
51
|
+
if self._transport == "http":
|
|
52
|
+
from .http_client import _connect_to_server_http
|
|
53
|
+
self.wm = await _connect_to_server_http(self.config)
|
|
54
|
+
else:
|
|
55
|
+
from .websocket_client import _connect_to_server
|
|
56
|
+
self.wm = await _connect_to_server(self.config)
|
|
57
|
+
if self._service_id:
|
|
58
|
+
return await self.wm.get_service(
|
|
59
|
+
self._service_id,
|
|
60
|
+
{"case_conversion": self.config.get("case_conversion")},
|
|
61
|
+
)
|
|
62
|
+
return self.wm
|
|
63
|
+
|
|
64
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
65
|
+
await self.wm.disconnect()
|
|
66
|
+
|
|
67
|
+
def __await__(self):
|
|
68
|
+
return self.__aenter__().__await__()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def connect_to_server(config=None, **kwargs):
|
|
72
|
+
"""Connect to a Hypha server.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
config: Configuration dict with connection options
|
|
76
|
+
**kwargs: Additional configuration options
|
|
77
|
+
|
|
78
|
+
Configuration options:
|
|
79
|
+
server_url: The server URL (required)
|
|
80
|
+
workspace: Target workspace (optional)
|
|
81
|
+
token: Authentication token (optional)
|
|
82
|
+
client_id: Unique client identifier (optional, auto-generated if not provided)
|
|
83
|
+
transport: Transport type - "websocket" (default) or "http"
|
|
84
|
+
method_timeout: Timeout for RPC method calls
|
|
85
|
+
ssl: SSL configuration (True/False/SSLContext)
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
ServerContextManager that can be used as async context manager
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
async with connect_to_server({"server_url": "https://hypha.aicell.io"}) as server:
|
|
92
|
+
await server.register_service({"id": "my-service", ...})
|
|
93
|
+
"""
|
|
94
|
+
return ServerContextManager(config=config, **kwargs)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_remote_service(service_uri, config=None, **kwargs):
|
|
98
|
+
"""Get a remote service by URI.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
service_uri: Service URI in format "server_url/workspace/client_id:service_id"
|
|
102
|
+
config: Additional configuration options
|
|
103
|
+
**kwargs: Additional configuration options
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
ServerContextManager that resolves to the service when awaited
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
async with get_remote_service("https://hypha.aicell.io/public/client:service") as svc:
|
|
110
|
+
result = await svc.some_method()
|
|
111
|
+
"""
|
|
112
|
+
server_url, workspace, client_id, service_id, app_id = parse_service_url(
|
|
113
|
+
service_uri
|
|
114
|
+
)
|
|
115
|
+
full_service_id = f"{workspace}/{client_id}:{service_id}@{app_id}"
|
|
116
|
+
config = config or {}
|
|
117
|
+
config.update(kwargs)
|
|
118
|
+
if "server_url" in config:
|
|
119
|
+
assert (
|
|
120
|
+
config["server_url"] == server_url
|
|
121
|
+
), "server_url in config does not match the server_url in the url"
|
|
122
|
+
config["server_url"] = server_url
|
|
123
|
+
return ServerContextManager(config, service_id=full_service_id)
|
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
"""HTTP Streaming RPC Client.
|
|
2
|
+
|
|
3
|
+
This module provides HTTP-based RPC transport as an alternative to WebSocket.
|
|
4
|
+
It uses:
|
|
5
|
+
- HTTP GET with streaming (NDJSON) for server-to-client messages
|
|
6
|
+
- HTTP POST for client-to-server messages
|
|
7
|
+
|
|
8
|
+
Benefits:
|
|
9
|
+
- More resilient to network issues (each POST is independent)
|
|
10
|
+
- Automatic reconnection for the stream
|
|
11
|
+
- Works through more proxies and firewalls
|
|
12
|
+
- Supports callbacks through the streaming channel
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import inspect
|
|
17
|
+
import io
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Callable, Optional
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
import msgpack
|
|
26
|
+
import shortuuid
|
|
27
|
+
|
|
28
|
+
from .rpc import RPC
|
|
29
|
+
from .utils import ObjectProxy, parse_service_url
|
|
30
|
+
from .utils.schema import schema_function
|
|
31
|
+
|
|
32
|
+
LOGLEVEL = os.environ.get("HYPHA_LOGLEVEL", "WARNING").upper()
|
|
33
|
+
logging.basicConfig(level=LOGLEVEL, stream=sys.stdout)
|
|
34
|
+
logger = logging.getLogger("http-client")
|
|
35
|
+
logger.setLevel(LOGLEVEL)
|
|
36
|
+
|
|
37
|
+
MAX_RETRY = 1000000
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HTTPStreamingRPCConnection:
|
|
41
|
+
"""HTTP Streaming RPC Connection.
|
|
42
|
+
|
|
43
|
+
Uses HTTP GET with streaming for receiving messages and HTTP POST for sending messages.
|
|
44
|
+
Supports two formats:
|
|
45
|
+
- NDJSON (default): JSON lines for text-based messages
|
|
46
|
+
- msgpack: Binary format with length-prefixed frames for binary data support
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
server_url: str,
|
|
52
|
+
client_id: str,
|
|
53
|
+
workspace: Optional[str] = None,
|
|
54
|
+
token: Optional[str] = None,
|
|
55
|
+
reconnection_token: Optional[str] = None,
|
|
56
|
+
timeout: float = 60,
|
|
57
|
+
ssl=None,
|
|
58
|
+
token_refresh_interval: float = 2 * 60 * 60,
|
|
59
|
+
format: str = "json", # "json" or "msgpack"
|
|
60
|
+
):
|
|
61
|
+
"""Initialize HTTP streaming connection.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
server_url: The server URL (http:// or https://)
|
|
65
|
+
client_id: Unique client identifier
|
|
66
|
+
workspace: Target workspace
|
|
67
|
+
token: Authentication token
|
|
68
|
+
reconnection_token: Token for reconnection
|
|
69
|
+
timeout: Request timeout in seconds
|
|
70
|
+
ssl: SSL configuration (True/False/SSLContext)
|
|
71
|
+
token_refresh_interval: Interval for token refresh
|
|
72
|
+
format: Stream format - "json" (NDJSON) or "msgpack" (binary with length prefix)
|
|
73
|
+
"""
|
|
74
|
+
self._server_url = server_url.rstrip("/")
|
|
75
|
+
self._client_id = client_id
|
|
76
|
+
self._workspace = workspace
|
|
77
|
+
self._token = token
|
|
78
|
+
self._reconnection_token = reconnection_token
|
|
79
|
+
self._timeout = timeout
|
|
80
|
+
self._ssl = ssl
|
|
81
|
+
self._token_refresh_interval = token_refresh_interval
|
|
82
|
+
self._format = format
|
|
83
|
+
|
|
84
|
+
self._handle_message: Optional[Callable] = None
|
|
85
|
+
self._handle_disconnected: Optional[Callable] = None
|
|
86
|
+
self._handle_connected: Optional[Callable] = None
|
|
87
|
+
self._is_async = False
|
|
88
|
+
|
|
89
|
+
self._closed = False
|
|
90
|
+
self._enable_reconnect = False
|
|
91
|
+
self._stream_task: Optional[asyncio.Task] = None
|
|
92
|
+
self._http_client: Optional[httpx.AsyncClient] = None
|
|
93
|
+
|
|
94
|
+
self.connection_info = None
|
|
95
|
+
self.manager_id = None
|
|
96
|
+
|
|
97
|
+
def on_message(self, handler: Callable):
|
|
98
|
+
"""Register message handler."""
|
|
99
|
+
self._handle_message = handler
|
|
100
|
+
self._is_async = inspect.iscoroutinefunction(handler)
|
|
101
|
+
|
|
102
|
+
def on_disconnected(self, handler: Callable):
|
|
103
|
+
"""Register disconnection handler."""
|
|
104
|
+
self._handle_disconnected = handler
|
|
105
|
+
|
|
106
|
+
def on_connected(self, handler: Callable):
|
|
107
|
+
"""Register connection handler."""
|
|
108
|
+
self._handle_connected = handler
|
|
109
|
+
assert inspect.iscoroutinefunction(handler), "Handler must be async"
|
|
110
|
+
|
|
111
|
+
def _get_headers(self, for_stream: bool = False) -> dict:
|
|
112
|
+
"""Get HTTP headers with authentication.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
for_stream: If True, set Accept header based on format preference
|
|
116
|
+
"""
|
|
117
|
+
headers = {
|
|
118
|
+
"Content-Type": "application/msgpack",
|
|
119
|
+
}
|
|
120
|
+
if for_stream:
|
|
121
|
+
if self._format == "msgpack":
|
|
122
|
+
headers["Accept"] = "application/x-msgpack-stream"
|
|
123
|
+
else:
|
|
124
|
+
headers["Accept"] = "application/x-ndjson"
|
|
125
|
+
if self._token:
|
|
126
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
127
|
+
return headers
|
|
128
|
+
|
|
129
|
+
async def _create_http_client(self) -> httpx.AsyncClient:
|
|
130
|
+
"""Create configured HTTP client."""
|
|
131
|
+
verify = True
|
|
132
|
+
if self._ssl is False:
|
|
133
|
+
verify = False
|
|
134
|
+
elif self._ssl is not None:
|
|
135
|
+
verify = self._ssl
|
|
136
|
+
|
|
137
|
+
return httpx.AsyncClient(
|
|
138
|
+
timeout=httpx.Timeout(self._timeout, connect=30.0),
|
|
139
|
+
verify=verify,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def open(self):
|
|
143
|
+
"""Open the streaming connection."""
|
|
144
|
+
logger.info(f"Opening HTTP streaming connection to {self._server_url} (format={self._format})")
|
|
145
|
+
|
|
146
|
+
if self._http_client is None:
|
|
147
|
+
self._http_client = await self._create_http_client()
|
|
148
|
+
|
|
149
|
+
# Build stream URL
|
|
150
|
+
workspace = self._workspace or "public"
|
|
151
|
+
stream_url = f"{self._server_url}/{workspace}/rpc"
|
|
152
|
+
params = {"client_id": self._client_id}
|
|
153
|
+
if self._format == "msgpack":
|
|
154
|
+
params["format"] = "msgpack"
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
# Start streaming in background task
|
|
158
|
+
self._stream_task = asyncio.create_task(
|
|
159
|
+
self._stream_loop(stream_url, params)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Wait for connection info (first message)
|
|
163
|
+
wait_start = asyncio.get_event_loop().time()
|
|
164
|
+
while self.connection_info is None:
|
|
165
|
+
await asyncio.sleep(0.1)
|
|
166
|
+
if asyncio.get_event_loop().time() - wait_start > self._timeout:
|
|
167
|
+
raise TimeoutError("Timeout waiting for connection info")
|
|
168
|
+
if self._closed:
|
|
169
|
+
raise ConnectionError("Connection closed during setup")
|
|
170
|
+
|
|
171
|
+
self.manager_id = self.connection_info.get("manager_id")
|
|
172
|
+
if self._workspace:
|
|
173
|
+
actual_ws = self.connection_info.get("workspace")
|
|
174
|
+
if actual_ws != self._workspace:
|
|
175
|
+
raise ConnectionError(
|
|
176
|
+
f"Connected to wrong workspace: {actual_ws}, expected: {self._workspace}"
|
|
177
|
+
)
|
|
178
|
+
self._workspace = self.connection_info.get("workspace")
|
|
179
|
+
|
|
180
|
+
if "reconnection_token" in self.connection_info:
|
|
181
|
+
self._reconnection_token = self.connection_info["reconnection_token"]
|
|
182
|
+
|
|
183
|
+
logger.info(
|
|
184
|
+
f"HTTP streaming connected to workspace: {self._workspace}, "
|
|
185
|
+
f"manager_id: {self.manager_id}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if self._handle_connected:
|
|
189
|
+
await self._handle_connected(self.connection_info)
|
|
190
|
+
|
|
191
|
+
return self.connection_info
|
|
192
|
+
|
|
193
|
+
except Exception as e:
|
|
194
|
+
logger.error(f"Failed to connect: {e}")
|
|
195
|
+
await self._cleanup()
|
|
196
|
+
raise
|
|
197
|
+
|
|
198
|
+
async def _stream_loop(self, url: str, params: dict):
|
|
199
|
+
"""Main loop for receiving streaming messages."""
|
|
200
|
+
self._enable_reconnect = True
|
|
201
|
+
self._closed = False
|
|
202
|
+
retry = 0
|
|
203
|
+
|
|
204
|
+
while not self._closed and retry < MAX_RETRY:
|
|
205
|
+
try:
|
|
206
|
+
async with self._http_client.stream(
|
|
207
|
+
"GET",
|
|
208
|
+
url,
|
|
209
|
+
params=params,
|
|
210
|
+
headers=self._get_headers(for_stream=True),
|
|
211
|
+
) as response:
|
|
212
|
+
if response.status_code != 200:
|
|
213
|
+
error_text = await response.aread()
|
|
214
|
+
raise ConnectionError(
|
|
215
|
+
f"Stream failed with status {response.status_code}: {error_text}"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
retry = 0 # Reset retry counter on successful connection
|
|
219
|
+
|
|
220
|
+
if self._format == "msgpack":
|
|
221
|
+
# Binary msgpack stream with 4-byte length prefix
|
|
222
|
+
await self._process_msgpack_stream(response)
|
|
223
|
+
else:
|
|
224
|
+
# NDJSON stream (line-based)
|
|
225
|
+
await self._process_ndjson_stream(response)
|
|
226
|
+
|
|
227
|
+
except httpx.ReadTimeout:
|
|
228
|
+
logger.warning("Stream read timeout, reconnecting...")
|
|
229
|
+
except httpx.ConnectError as e:
|
|
230
|
+
logger.error(f"Connection error: {e}")
|
|
231
|
+
except ConnectionError as e:
|
|
232
|
+
logger.error(f"Connection error: {e}")
|
|
233
|
+
if not self._enable_reconnect:
|
|
234
|
+
break
|
|
235
|
+
except asyncio.CancelledError:
|
|
236
|
+
logger.info("Stream task cancelled")
|
|
237
|
+
break
|
|
238
|
+
except Exception as e:
|
|
239
|
+
logger.error(f"Stream error: {e}")
|
|
240
|
+
|
|
241
|
+
# Reconnection logic
|
|
242
|
+
if not self._closed and self._enable_reconnect:
|
|
243
|
+
retry += 1
|
|
244
|
+
delay = min(1.0 * (2 ** min(retry, 6)), 60.0) # Max 60s
|
|
245
|
+
logger.info(f"Reconnecting in {delay:.1f}s (attempt {retry})")
|
|
246
|
+
await asyncio.sleep(delay)
|
|
247
|
+
else:
|
|
248
|
+
break
|
|
249
|
+
|
|
250
|
+
if not self._closed and self._handle_disconnected:
|
|
251
|
+
self._handle_disconnected("Stream ended")
|
|
252
|
+
|
|
253
|
+
async def _process_ndjson_stream(self, response):
|
|
254
|
+
"""Process NDJSON (line-based JSON) stream."""
|
|
255
|
+
async for line in response.aiter_lines():
|
|
256
|
+
if self._closed:
|
|
257
|
+
break
|
|
258
|
+
|
|
259
|
+
if not line.strip():
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
message = json.loads(line)
|
|
264
|
+
await self._handle_stream_message(message)
|
|
265
|
+
except json.JSONDecodeError as e:
|
|
266
|
+
logger.warning(f"Failed to parse JSON message: {e}")
|
|
267
|
+
except Exception as e:
|
|
268
|
+
logger.error(f"Error handling message: {e}")
|
|
269
|
+
|
|
270
|
+
async def _process_msgpack_stream(self, response):
|
|
271
|
+
"""Process msgpack stream with 4-byte length prefix."""
|
|
272
|
+
buffer = b""
|
|
273
|
+
async for chunk in response.aiter_bytes():
|
|
274
|
+
if self._closed:
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
buffer += chunk
|
|
278
|
+
|
|
279
|
+
# Process complete frames from buffer
|
|
280
|
+
while len(buffer) >= 4:
|
|
281
|
+
# Read 4-byte length prefix (big-endian)
|
|
282
|
+
length = int.from_bytes(buffer[:4], 'big')
|
|
283
|
+
|
|
284
|
+
if len(buffer) < 4 + length:
|
|
285
|
+
# Incomplete frame, wait for more data
|
|
286
|
+
break
|
|
287
|
+
|
|
288
|
+
# Extract the frame
|
|
289
|
+
frame_data = buffer[4:4 + length]
|
|
290
|
+
buffer = buffer[4 + length:]
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
# For msgpack, first check if it's a control message
|
|
294
|
+
# Control messages have a "type" field we need to check
|
|
295
|
+
unpacker = msgpack.Unpacker(io.BytesIO(frame_data))
|
|
296
|
+
message = unpacker.unpack()
|
|
297
|
+
|
|
298
|
+
# Check for control messages
|
|
299
|
+
if isinstance(message, dict):
|
|
300
|
+
msg_type = message.get("type")
|
|
301
|
+
if msg_type == "connection_info":
|
|
302
|
+
self.connection_info = message
|
|
303
|
+
continue
|
|
304
|
+
elif msg_type == "ping":
|
|
305
|
+
continue
|
|
306
|
+
elif msg_type == "reconnection_token":
|
|
307
|
+
self._reconnection_token = message.get("reconnection_token")
|
|
308
|
+
continue
|
|
309
|
+
elif msg_type == "error":
|
|
310
|
+
logger.error(f"Server error: {message.get('message')}")
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
# For RPC messages, pass the raw frame data to the handler
|
|
314
|
+
# The RPC layer expects raw msgpack bytes
|
|
315
|
+
if self._handle_message:
|
|
316
|
+
if self._is_async:
|
|
317
|
+
await self._handle_message(frame_data)
|
|
318
|
+
else:
|
|
319
|
+
self._handle_message(frame_data)
|
|
320
|
+
except Exception as e:
|
|
321
|
+
logger.error(f"Error handling msgpack message: {e}")
|
|
322
|
+
|
|
323
|
+
async def _handle_stream_message(self, message: dict):
|
|
324
|
+
"""Handle a decoded stream message."""
|
|
325
|
+
# Handle connection info
|
|
326
|
+
if message.get("type") == "connection_info":
|
|
327
|
+
self.connection_info = message
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
# Handle ping (keep-alive)
|
|
331
|
+
if message.get("type") == "ping":
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
# Handle reconnection token refresh
|
|
335
|
+
if message.get("type") == "reconnection_token":
|
|
336
|
+
self._reconnection_token = message.get("reconnection_token")
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
# Handle errors
|
|
340
|
+
if message.get("type") == "error":
|
|
341
|
+
logger.error(f"Server error: {message.get('message')}")
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
# Pass to message handler (convert to msgpack for RPC)
|
|
345
|
+
if self._handle_message:
|
|
346
|
+
# Convert to msgpack bytes for RPC layer
|
|
347
|
+
data = msgpack.packb(message)
|
|
348
|
+
if self._is_async:
|
|
349
|
+
await self._handle_message(data)
|
|
350
|
+
else:
|
|
351
|
+
self._handle_message(data)
|
|
352
|
+
|
|
353
|
+
async def emit_message(self, data: bytes):
|
|
354
|
+
"""Send a message to the server via HTTP POST."""
|
|
355
|
+
if self._closed:
|
|
356
|
+
raise ConnectionError("Connection is closed")
|
|
357
|
+
|
|
358
|
+
if self._http_client is None:
|
|
359
|
+
self._http_client = await self._create_http_client()
|
|
360
|
+
|
|
361
|
+
workspace = self._workspace or "public"
|
|
362
|
+
url = f"{self._server_url}/{workspace}/rpc"
|
|
363
|
+
params = {"client_id": self._client_id}
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
response = await self._http_client.post(
|
|
367
|
+
url,
|
|
368
|
+
content=data,
|
|
369
|
+
params=params,
|
|
370
|
+
headers=self._get_headers(),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if response.status_code != 200:
|
|
374
|
+
error = response.json() if response.content else {"detail": "Unknown error"}
|
|
375
|
+
raise ConnectionError(f"POST failed: {error.get('detail', error)}")
|
|
376
|
+
|
|
377
|
+
except httpx.TimeoutException:
|
|
378
|
+
logger.error("Request timeout")
|
|
379
|
+
raise TimeoutError("Request timeout")
|
|
380
|
+
except Exception as e:
|
|
381
|
+
logger.error(f"Failed to send message: {e}")
|
|
382
|
+
raise
|
|
383
|
+
|
|
384
|
+
async def disconnect(self, reason: Optional[str] = None):
|
|
385
|
+
"""Disconnect and cleanup."""
|
|
386
|
+
self._closed = True
|
|
387
|
+
self._enable_reconnect = False
|
|
388
|
+
await self._cleanup()
|
|
389
|
+
logger.info(f"HTTP streaming connection disconnected ({reason})")
|
|
390
|
+
|
|
391
|
+
async def _cleanup(self):
|
|
392
|
+
"""Cleanup resources."""
|
|
393
|
+
if self._stream_task and not self._stream_task.done():
|
|
394
|
+
self._stream_task.cancel()
|
|
395
|
+
try:
|
|
396
|
+
await asyncio.wait_for(self._stream_task, timeout=1.0)
|
|
397
|
+
except (asyncio.CancelledError, asyncio.TimeoutError):
|
|
398
|
+
pass
|
|
399
|
+
self._stream_task = None
|
|
400
|
+
|
|
401
|
+
if self._http_client:
|
|
402
|
+
await self._http_client.aclose()
|
|
403
|
+
self._http_client = None
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def normalize_server_url(server_url: str) -> str:
|
|
407
|
+
"""Normalize server URL for HTTP transport."""
|
|
408
|
+
if not server_url:
|
|
409
|
+
raise ValueError("server_url is required")
|
|
410
|
+
|
|
411
|
+
# Convert ws:// to http://
|
|
412
|
+
if server_url.startswith("ws://"):
|
|
413
|
+
server_url = server_url.replace("ws://", "http://")
|
|
414
|
+
elif server_url.startswith("wss://"):
|
|
415
|
+
server_url = server_url.replace("wss://", "https://")
|
|
416
|
+
|
|
417
|
+
# Remove /ws suffix if present (WebSocket endpoint)
|
|
418
|
+
if server_url.endswith("/ws"):
|
|
419
|
+
server_url = server_url[:-3]
|
|
420
|
+
|
|
421
|
+
return server_url.rstrip("/")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def connect_to_server_http(config=None, **kwargs):
|
|
425
|
+
"""Connect to server using HTTP streaming transport.
|
|
426
|
+
|
|
427
|
+
This is a convenience function that sets transport="http" automatically.
|
|
428
|
+
For a unified interface, use connect_to_server(transport="http") instead.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
config: Configuration dict with server_url, token, workspace, etc.
|
|
432
|
+
**kwargs: Additional configuration options
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
ServerContextManager that can be used as async context manager
|
|
436
|
+
"""
|
|
437
|
+
from .client import connect_to_server
|
|
438
|
+
config = config or {}
|
|
439
|
+
config.update(kwargs)
|
|
440
|
+
config["transport"] = "http"
|
|
441
|
+
return connect_to_server(config)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
async def _connect_to_server_http(config: dict):
|
|
445
|
+
"""Internal function to establish HTTP streaming connection."""
|
|
446
|
+
client_id = config.get("client_id")
|
|
447
|
+
if client_id is None:
|
|
448
|
+
client_id = shortuuid.uuid()
|
|
449
|
+
|
|
450
|
+
server_url = normalize_server_url(config["server_url"])
|
|
451
|
+
|
|
452
|
+
connection = HTTPStreamingRPCConnection(
|
|
453
|
+
server_url,
|
|
454
|
+
client_id,
|
|
455
|
+
workspace=config.get("workspace"),
|
|
456
|
+
token=config.get("token"),
|
|
457
|
+
reconnection_token=config.get("reconnection_token"),
|
|
458
|
+
timeout=config.get("method_timeout", 30),
|
|
459
|
+
ssl=config.get("ssl"),
|
|
460
|
+
token_refresh_interval=config.get("token_refresh_interval", 2 * 60 * 60),
|
|
461
|
+
# Default to msgpack for full binary support and proper RPC message handling
|
|
462
|
+
format=config.get("format", "msgpack"),
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
connection_info = await connection.open()
|
|
466
|
+
assert connection_info, "Failed to connect to server"
|
|
467
|
+
|
|
468
|
+
await asyncio.sleep(0.1)
|
|
469
|
+
|
|
470
|
+
workspace = connection_info["workspace"]
|
|
471
|
+
|
|
472
|
+
rpc = RPC(
|
|
473
|
+
connection,
|
|
474
|
+
client_id=client_id,
|
|
475
|
+
workspace=workspace,
|
|
476
|
+
default_context={"connection_type": "http_streaming"},
|
|
477
|
+
name=config.get("name"),
|
|
478
|
+
method_timeout=config.get("method_timeout"),
|
|
479
|
+
loop=config.get("loop"),
|
|
480
|
+
app_id=config.get("app_id"),
|
|
481
|
+
server_base_url=connection_info.get("public_base_url"),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
await rpc.wait_for("services_registered", timeout=config.get("method_timeout", 120))
|
|
485
|
+
|
|
486
|
+
wm = await rpc.get_manager_service(
|
|
487
|
+
{"timeout": config.get("method_timeout", 30), "case_conversion": "snake"}
|
|
488
|
+
)
|
|
489
|
+
wm.rpc = rpc
|
|
490
|
+
|
|
491
|
+
# Add standard methods
|
|
492
|
+
wm.disconnect = schema_function(
|
|
493
|
+
rpc.disconnect,
|
|
494
|
+
name="disconnect",
|
|
495
|
+
description="Disconnect from server",
|
|
496
|
+
parameters={"properties": {}, "type": "object"},
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
wm.register_service = schema_function(
|
|
500
|
+
rpc.register_service,
|
|
501
|
+
name="register_service",
|
|
502
|
+
description="Register a service",
|
|
503
|
+
parameters={
|
|
504
|
+
"properties": {
|
|
505
|
+
"service": {"description": "Service to register", "type": "object"},
|
|
506
|
+
},
|
|
507
|
+
"required": ["service"],
|
|
508
|
+
"type": "object",
|
|
509
|
+
},
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
_get_service = wm.get_service
|
|
513
|
+
|
|
514
|
+
async def get_service(query, config=None, **kwargs):
|
|
515
|
+
config = config or {}
|
|
516
|
+
config.update(kwargs)
|
|
517
|
+
return await _get_service(query, config=config)
|
|
518
|
+
|
|
519
|
+
if hasattr(wm.get_service, "__schema__"):
|
|
520
|
+
get_service.__schema__ = wm.get_service.__schema__
|
|
521
|
+
wm.get_service = get_service
|
|
522
|
+
|
|
523
|
+
async def serve():
|
|
524
|
+
await asyncio.Event().wait()
|
|
525
|
+
|
|
526
|
+
wm.serve = schema_function(
|
|
527
|
+
serve, name="serve", description="Run event loop forever", parameters={}
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
if connection_info:
|
|
531
|
+
wm.config.update(connection_info)
|
|
532
|
+
|
|
533
|
+
# Handle force-exit from manager
|
|
534
|
+
if connection.manager_id:
|
|
535
|
+
async def handle_disconnect(message):
|
|
536
|
+
if message.get("from") == "*/" + connection.manager_id:
|
|
537
|
+
logger.info(f"Disconnecting from server: {message.get('reason')}")
|
|
538
|
+
await rpc.disconnect()
|
|
539
|
+
|
|
540
|
+
rpc.on("force-exit", handle_disconnect)
|
|
541
|
+
|
|
542
|
+
return wm
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def get_remote_service_http(service_uri: str, config=None, **kwargs):
|
|
546
|
+
"""Get a remote service using HTTP transport.
|
|
547
|
+
|
|
548
|
+
This is a convenience function that sets transport="http" automatically.
|
|
549
|
+
For a unified interface, use get_remote_service with transport="http" instead.
|
|
550
|
+
"""
|
|
551
|
+
from .client import get_remote_service
|
|
552
|
+
config = config or {}
|
|
553
|
+
config.update(kwargs)
|
|
554
|
+
config["transport"] = "http"
|
|
555
|
+
return get_remote_service(service_uri, config)
|
|
@@ -1237,7 +1237,11 @@ class RPC(MessageEmitter):
|
|
|
1237
1237
|
service = self._services.get(service_id)
|
|
1238
1238
|
if not service:
|
|
1239
1239
|
raise KeyError("Service not found: %s", service_id)
|
|
1240
|
-
service["config"]["workspace"]
|
|
1240
|
+
# Note: Do NOT mutate service["config"]["workspace"] here!
|
|
1241
|
+
# Doing so would corrupt the stored service config when called from
|
|
1242
|
+
# a different workspace (e.g., "public"), causing reconnection to fail
|
|
1243
|
+
# because _extract_service_info would use the wrong workspace value.
|
|
1244
|
+
|
|
1241
1245
|
# allow access for the same workspace
|
|
1242
1246
|
if service["config"].get("visibility", "protected") in ["public", "unlisted"]:
|
|
1243
1247
|
return service
|
|
@@ -738,69 +738,8 @@ async def logout(config):
|
|
|
738
738
|
await server.disconnect()
|
|
739
739
|
|
|
740
740
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
def __init__(self, config=None, service_id=None, **kwargs):
|
|
745
|
-
self.config = config or {}
|
|
746
|
-
self.config.update(kwargs)
|
|
747
|
-
|
|
748
|
-
if not self.config:
|
|
749
|
-
# try to load from env
|
|
750
|
-
if not os.environ.get("HYPHA_SERVER_URL"):
|
|
751
|
-
try:
|
|
752
|
-
from dotenv import load_dotenv, find_dotenv
|
|
753
|
-
load_dotenv(dotenv_path=find_dotenv(usecwd=True))
|
|
754
|
-
# use info from .env file
|
|
755
|
-
print("✅ Loaded connection configuration from .env file.")
|
|
756
|
-
except ImportError:
|
|
757
|
-
pass
|
|
758
|
-
self.config = {
|
|
759
|
-
"server_url": os.getenv("HYPHA_SERVER_URL"),
|
|
760
|
-
"token": os.getenv("HYPHA_TOKEN"),
|
|
761
|
-
"client_id": os.getenv("HYPHA_CLIENT_ID"),
|
|
762
|
-
"workspace": os.getenv("HYPHA_WORKSPACE"),
|
|
763
|
-
}
|
|
764
|
-
if not self.config["server_url"]:
|
|
765
|
-
raise ValueError("Please set the HYPHA_SERVER_URL, HYPHA_TOKEN, HYPHA_CLIENT_ID, and HYPHA_WORKSPACE environment variables")
|
|
766
|
-
self._service_id = service_id
|
|
767
|
-
self.wm = None
|
|
768
|
-
|
|
769
|
-
async def __aenter__(self):
|
|
770
|
-
self.wm = await _connect_to_server(self.config)
|
|
771
|
-
if self._service_id:
|
|
772
|
-
return await self.wm.get_service(
|
|
773
|
-
self._service_id,
|
|
774
|
-
{"case_conversion": self.config.get("case_conversion")},
|
|
775
|
-
)
|
|
776
|
-
return self.wm
|
|
777
|
-
|
|
778
|
-
async def __aexit__(self, exc_type, exc, tb):
|
|
779
|
-
await self.wm.disconnect()
|
|
780
|
-
|
|
781
|
-
def __await__(self):
|
|
782
|
-
return self.__aenter__().__await__()
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
def connect_to_server(config=None, **kwargs):
|
|
786
|
-
"""Connect to the server."""
|
|
787
|
-
return ServerContextManager(config=config, **kwargs)
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
def get_remote_service(service_uri, config=None, **kwargs):
|
|
791
|
-
"""Get a remote service."""
|
|
792
|
-
server_url, workspace, client_id, service_id, app_id = parse_service_url(
|
|
793
|
-
service_uri
|
|
794
|
-
)
|
|
795
|
-
full_service_id = f"{workspace}/{client_id}:{service_id}@{app_id}"
|
|
796
|
-
config = config or {}
|
|
797
|
-
config.update(kwargs)
|
|
798
|
-
if "server_url" in config:
|
|
799
|
-
assert (
|
|
800
|
-
config["server_url"] == server_url
|
|
801
|
-
), "server_url in config does not match the server_url in the url"
|
|
802
|
-
config["server_url"] = server_url
|
|
803
|
-
return ServerContextManager(config, service_id=full_service_id)
|
|
741
|
+
# Re-export from client.py for backwards compatibility
|
|
742
|
+
from .client import ServerContextManager, connect_to_server, get_remote_service
|
|
804
743
|
|
|
805
744
|
|
|
806
745
|
async def webrtc_get_service(wm, rtc_service_id, query, config=None, **kwargs):
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hypha_rpc"
|
|
7
|
-
version = "0.20.
|
|
7
|
+
version = "0.20.88"
|
|
8
8
|
description = "Hypha RPC client for connecting to Hypha server for data management and AI model serving"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -143,7 +143,12 @@ async def test_service_with_builtin_key(websocket_server):
|
|
|
143
143
|
@pytest.mark.asyncio
|
|
144
144
|
async def test_login(websocket_server):
|
|
145
145
|
"""Test login to the server."""
|
|
146
|
-
|
|
146
|
+
# First connect to server to generate a valid JWT token
|
|
147
|
+
api = await connect_to_server(
|
|
148
|
+
{"server_url": WS_SERVER_URL, "client_id": "login-test-client"}
|
|
149
|
+
)
|
|
150
|
+
TOKEN = await api.generate_token()
|
|
151
|
+
await api.disconnect()
|
|
147
152
|
|
|
148
153
|
async def callback(context):
|
|
149
154
|
print(f"By passing login: {context['login_url']}")
|
|
@@ -167,7 +172,12 @@ async def test_login(websocket_server):
|
|
|
167
172
|
|
|
168
173
|
def test_login_sync(websocket_server):
|
|
169
174
|
"""Test login to the server."""
|
|
170
|
-
|
|
175
|
+
# First connect to server to generate a valid JWT token
|
|
176
|
+
api = connect_to_server_sync(
|
|
177
|
+
{"server_url": WS_SERVER_URL, "client_id": "login-sync-test-client"}
|
|
178
|
+
)
|
|
179
|
+
TOKEN = api.generate_token()
|
|
180
|
+
api.disconnect()
|
|
171
181
|
|
|
172
182
|
def callback(context):
|
|
173
183
|
print(f"By passing login: {context['login_url']}")
|
|
@@ -189,7 +199,13 @@ def test_login_sync(websocket_server):
|
|
|
189
199
|
@pytest.mark.asyncio
|
|
190
200
|
async def test_login_with_additional_headers(websocket_server):
|
|
191
201
|
"""Test login with additional headers."""
|
|
192
|
-
|
|
202
|
+
# First connect to server to generate a valid JWT token
|
|
203
|
+
api = await connect_to_server(
|
|
204
|
+
{"server_url": WS_SERVER_URL, "client_id": "login-headers-test-client"}
|
|
205
|
+
)
|
|
206
|
+
TOKEN = await api.generate_token()
|
|
207
|
+
await api.disconnect()
|
|
208
|
+
|
|
193
209
|
additional_headers = {"X-Custom-Header": "test-value"}
|
|
194
210
|
|
|
195
211
|
async def callback(context):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|