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.
Files changed (34) hide show
  1. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/PKG-INFO +1 -1
  2. hypha_rpc-0.20.88/hypha_rpc/VERSION +3 -0
  3. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/__init__.py +4 -7
  4. hypha_rpc-0.20.88/hypha_rpc/client.py +123 -0
  5. hypha_rpc-0.20.88/hypha_rpc/http_client.py +555 -0
  6. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/rpc.py +5 -1
  7. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/websocket_client.py +2 -63
  8. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc.egg-info/PKG-INFO +1 -1
  9. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc.egg-info/SOURCES.txt +2 -0
  10. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/pyproject.toml +1 -1
  11. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/tests/test_websocket_rpc.py +19 -3
  12. hypha_rpc-0.20.86/hypha_rpc/VERSION +0 -3
  13. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/MANIFEST.in +0 -0
  14. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/README.md +0 -0
  15. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/pyodide_sse.py +0 -0
  16. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/pyodide_websocket.py +0 -0
  17. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/sync.py +0 -0
  18. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/utils/__init__.py +0 -0
  19. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/utils/launch.py +0 -0
  20. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/utils/mcp.py +0 -0
  21. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/utils/pydantic.py +0 -0
  22. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/utils/schema.py +0 -0
  23. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/utils/serve.py +0 -0
  24. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc/webrtc_client.py +0 -0
  25. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc.egg-info/dependency_links.txt +0 -0
  26. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc.egg-info/requires.txt +0 -0
  27. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/hypha_rpc.egg-info/top_level.txt +0 -0
  28. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/setup.cfg +0 -0
  29. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/tests/test_mcp.py +0 -0
  30. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/tests/test_reconnection_runner.py +0 -0
  31. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/tests/test_reconnection_stability.py +0 -0
  32. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/tests/test_schema.py +0 -0
  33. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/tests/test_server_compatibility.py +0 -0
  34. {hypha_rpc-0.20.86 → hypha_rpc-0.20.88}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypha_rpc
3
- Version: 0.20.86
3
+ Version: 0.20.88
4
4
  Summary: Hypha RPC client for connecting to Hypha server for data management and AI model serving
5
5
  Author-email: Wei Ouyang <oeway007@gmail.com>
6
6
  Requires-Python: >=3.9
@@ -0,0 +1,3 @@
1
+ {
2
+ "version": "0.20.88"
3
+ }
@@ -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 .websocket_client import (
19
- connect_to_server,
20
- get_remote_service,
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"] = context["ws"]
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
- class ServerContextManager:
742
- """Server context manager."""
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):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypha_rpc
3
- Version: 0.20.86
3
+ Version: 0.20.88
4
4
  Summary: Hypha RPC client for connecting to Hypha server for data management and AI model serving
5
5
  Author-email: Wei Ouyang <oeway007@gmail.com>
6
6
  Requires-Python: >=3.9
@@ -4,6 +4,8 @@ pyproject.toml
4
4
  setup.cfg
5
5
  hypha_rpc/VERSION
6
6
  hypha_rpc/__init__.py
7
+ hypha_rpc/client.py
8
+ hypha_rpc/http_client.py
7
9
  hypha_rpc/pyodide_sse.py
8
10
  hypha_rpc/pyodide_websocket.py
9
11
  hypha_rpc/rpc.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hypha_rpc"
7
- version = "0.20.86"
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
- TOKEN = "sf31df234"
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
- TOKEN = "sf31df234"
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
- TOKEN = "sf31df234"
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):
@@ -1,3 +0,0 @@
1
- {
2
- "version": "0.20.86"
3
- }
File without changes
File without changes
File without changes