hypha-rpc 0.20.87__py3-none-any.whl → 0.20.89__py3-none-any.whl

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/VERSION CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.20.87"
2
+ "version": "0.20.89"
3
3
  }
hypha_rpc/__init__.py CHANGED
@@ -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",
hypha_rpc/client.py ADDED
@@ -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,565 @@
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 with connection pooling.
131
+
132
+ Connection pooling improves performance by reusing TCP connections
133
+ for multiple requests, reducing connection overhead.
134
+ """
135
+ verify = True
136
+ if self._ssl is False:
137
+ verify = False
138
+ elif self._ssl is not None:
139
+ verify = self._ssl
140
+
141
+ return httpx.AsyncClient(
142
+ timeout=httpx.Timeout(self._timeout, connect=30.0),
143
+ verify=verify,
144
+ # Connection pooling for better performance with many requests
145
+ limits=httpx.Limits(
146
+ max_connections=100, # Max total connections
147
+ max_keepalive_connections=20, # Keep-alive connections for reuse
148
+ keepalive_expiry=30.0, # Keep connections alive for 30 seconds
149
+ ),
150
+ )
151
+
152
+ async def open(self):
153
+ """Open the streaming connection."""
154
+ logger.info(f"Opening HTTP streaming connection to {self._server_url} (format={self._format})")
155
+
156
+ if self._http_client is None:
157
+ self._http_client = await self._create_http_client()
158
+
159
+ # Build stream URL
160
+ workspace = self._workspace or "public"
161
+ stream_url = f"{self._server_url}/{workspace}/rpc"
162
+ params = {"client_id": self._client_id}
163
+ if self._format == "msgpack":
164
+ params["format"] = "msgpack"
165
+
166
+ try:
167
+ # Start streaming in background task
168
+ self._stream_task = asyncio.create_task(
169
+ self._stream_loop(stream_url, params)
170
+ )
171
+
172
+ # Wait for connection info (first message)
173
+ wait_start = asyncio.get_event_loop().time()
174
+ while self.connection_info is None:
175
+ await asyncio.sleep(0.1)
176
+ if asyncio.get_event_loop().time() - wait_start > self._timeout:
177
+ raise TimeoutError("Timeout waiting for connection info")
178
+ if self._closed:
179
+ raise ConnectionError("Connection closed during setup")
180
+
181
+ self.manager_id = self.connection_info.get("manager_id")
182
+ if self._workspace:
183
+ actual_ws = self.connection_info.get("workspace")
184
+ if actual_ws != self._workspace:
185
+ raise ConnectionError(
186
+ f"Connected to wrong workspace: {actual_ws}, expected: {self._workspace}"
187
+ )
188
+ self._workspace = self.connection_info.get("workspace")
189
+
190
+ if "reconnection_token" in self.connection_info:
191
+ self._reconnection_token = self.connection_info["reconnection_token"]
192
+
193
+ logger.info(
194
+ f"HTTP streaming connected to workspace: {self._workspace}, "
195
+ f"manager_id: {self.manager_id}"
196
+ )
197
+
198
+ if self._handle_connected:
199
+ await self._handle_connected(self.connection_info)
200
+
201
+ return self.connection_info
202
+
203
+ except Exception as e:
204
+ logger.error(f"Failed to connect: {e}")
205
+ await self._cleanup()
206
+ raise
207
+
208
+ async def _stream_loop(self, url: str, params: dict):
209
+ """Main loop for receiving streaming messages."""
210
+ self._enable_reconnect = True
211
+ self._closed = False
212
+ retry = 0
213
+
214
+ while not self._closed and retry < MAX_RETRY:
215
+ try:
216
+ async with self._http_client.stream(
217
+ "GET",
218
+ url,
219
+ params=params,
220
+ headers=self._get_headers(for_stream=True),
221
+ ) as response:
222
+ if response.status_code != 200:
223
+ error_text = await response.aread()
224
+ raise ConnectionError(
225
+ f"Stream failed with status {response.status_code}: {error_text}"
226
+ )
227
+
228
+ retry = 0 # Reset retry counter on successful connection
229
+
230
+ if self._format == "msgpack":
231
+ # Binary msgpack stream with 4-byte length prefix
232
+ await self._process_msgpack_stream(response)
233
+ else:
234
+ # NDJSON stream (line-based)
235
+ await self._process_ndjson_stream(response)
236
+
237
+ except httpx.ReadTimeout:
238
+ logger.warning("Stream read timeout, reconnecting...")
239
+ except httpx.ConnectError as e:
240
+ logger.error(f"Connection error: {e}")
241
+ except ConnectionError as e:
242
+ logger.error(f"Connection error: {e}")
243
+ if not self._enable_reconnect:
244
+ break
245
+ except asyncio.CancelledError:
246
+ logger.info("Stream task cancelled")
247
+ break
248
+ except Exception as e:
249
+ logger.error(f"Stream error: {e}")
250
+
251
+ # Reconnection logic
252
+ if not self._closed and self._enable_reconnect:
253
+ retry += 1
254
+ delay = min(1.0 * (2 ** min(retry, 6)), 60.0) # Max 60s
255
+ logger.info(f"Reconnecting in {delay:.1f}s (attempt {retry})")
256
+ await asyncio.sleep(delay)
257
+ else:
258
+ break
259
+
260
+ if not self._closed and self._handle_disconnected:
261
+ self._handle_disconnected("Stream ended")
262
+
263
+ async def _process_ndjson_stream(self, response):
264
+ """Process NDJSON (line-based JSON) stream."""
265
+ async for line in response.aiter_lines():
266
+ if self._closed:
267
+ break
268
+
269
+ if not line.strip():
270
+ continue
271
+
272
+ try:
273
+ message = json.loads(line)
274
+ await self._handle_stream_message(message)
275
+ except json.JSONDecodeError as e:
276
+ logger.warning(f"Failed to parse JSON message: {e}")
277
+ except Exception as e:
278
+ logger.error(f"Error handling message: {e}")
279
+
280
+ async def _process_msgpack_stream(self, response):
281
+ """Process msgpack stream with 4-byte length prefix."""
282
+ buffer = b""
283
+ async for chunk in response.aiter_bytes():
284
+ if self._closed:
285
+ break
286
+
287
+ buffer += chunk
288
+
289
+ # Process complete frames from buffer
290
+ while len(buffer) >= 4:
291
+ # Read 4-byte length prefix (big-endian)
292
+ length = int.from_bytes(buffer[:4], 'big')
293
+
294
+ if len(buffer) < 4 + length:
295
+ # Incomplete frame, wait for more data
296
+ break
297
+
298
+ # Extract the frame
299
+ frame_data = buffer[4:4 + length]
300
+ buffer = buffer[4 + length:]
301
+
302
+ try:
303
+ # For msgpack, first check if it's a control message
304
+ # Control messages have a "type" field we need to check
305
+ unpacker = msgpack.Unpacker(io.BytesIO(frame_data))
306
+ message = unpacker.unpack()
307
+
308
+ # Check for control messages
309
+ if isinstance(message, dict):
310
+ msg_type = message.get("type")
311
+ if msg_type == "connection_info":
312
+ self.connection_info = message
313
+ continue
314
+ elif msg_type == "ping":
315
+ continue
316
+ elif msg_type == "reconnection_token":
317
+ self._reconnection_token = message.get("reconnection_token")
318
+ continue
319
+ elif msg_type == "error":
320
+ logger.error(f"Server error: {message.get('message')}")
321
+ continue
322
+
323
+ # For RPC messages, pass the raw frame data to the handler
324
+ # The RPC layer expects raw msgpack bytes
325
+ if self._handle_message:
326
+ if self._is_async:
327
+ await self._handle_message(frame_data)
328
+ else:
329
+ self._handle_message(frame_data)
330
+ except Exception as e:
331
+ logger.error(f"Error handling msgpack message: {e}")
332
+
333
+ async def _handle_stream_message(self, message: dict):
334
+ """Handle a decoded stream message."""
335
+ # Handle connection info
336
+ if message.get("type") == "connection_info":
337
+ self.connection_info = message
338
+ return
339
+
340
+ # Handle ping (keep-alive)
341
+ if message.get("type") == "ping":
342
+ return
343
+
344
+ # Handle reconnection token refresh
345
+ if message.get("type") == "reconnection_token":
346
+ self._reconnection_token = message.get("reconnection_token")
347
+ return
348
+
349
+ # Handle errors
350
+ if message.get("type") == "error":
351
+ logger.error(f"Server error: {message.get('message')}")
352
+ return
353
+
354
+ # Pass to message handler (convert to msgpack for RPC)
355
+ if self._handle_message:
356
+ # Convert to msgpack bytes for RPC layer
357
+ data = msgpack.packb(message)
358
+ if self._is_async:
359
+ await self._handle_message(data)
360
+ else:
361
+ self._handle_message(data)
362
+
363
+ async def emit_message(self, data: bytes):
364
+ """Send a message to the server via HTTP POST."""
365
+ if self._closed:
366
+ raise ConnectionError("Connection is closed")
367
+
368
+ if self._http_client is None:
369
+ self._http_client = await self._create_http_client()
370
+
371
+ workspace = self._workspace or "public"
372
+ url = f"{self._server_url}/{workspace}/rpc"
373
+ params = {"client_id": self._client_id}
374
+
375
+ try:
376
+ response = await self._http_client.post(
377
+ url,
378
+ content=data,
379
+ params=params,
380
+ headers=self._get_headers(),
381
+ )
382
+
383
+ if response.status_code != 200:
384
+ error = response.json() if response.content else {"detail": "Unknown error"}
385
+ raise ConnectionError(f"POST failed: {error.get('detail', error)}")
386
+
387
+ except httpx.TimeoutException:
388
+ logger.error("Request timeout")
389
+ raise TimeoutError("Request timeout")
390
+ except Exception as e:
391
+ logger.error(f"Failed to send message: {e}")
392
+ raise
393
+
394
+ async def disconnect(self, reason: Optional[str] = None):
395
+ """Disconnect and cleanup."""
396
+ self._closed = True
397
+ self._enable_reconnect = False
398
+ await self._cleanup()
399
+ logger.info(f"HTTP streaming connection disconnected ({reason})")
400
+
401
+ async def _cleanup(self):
402
+ """Cleanup resources."""
403
+ if self._stream_task and not self._stream_task.done():
404
+ self._stream_task.cancel()
405
+ try:
406
+ await asyncio.wait_for(self._stream_task, timeout=1.0)
407
+ except (asyncio.CancelledError, asyncio.TimeoutError):
408
+ pass
409
+ self._stream_task = None
410
+
411
+ if self._http_client:
412
+ await self._http_client.aclose()
413
+ self._http_client = None
414
+
415
+
416
+ def normalize_server_url(server_url: str) -> str:
417
+ """Normalize server URL for HTTP transport."""
418
+ if not server_url:
419
+ raise ValueError("server_url is required")
420
+
421
+ # Convert ws:// to http://
422
+ if server_url.startswith("ws://"):
423
+ server_url = server_url.replace("ws://", "http://")
424
+ elif server_url.startswith("wss://"):
425
+ server_url = server_url.replace("wss://", "https://")
426
+
427
+ # Remove /ws suffix if present (WebSocket endpoint)
428
+ if server_url.endswith("/ws"):
429
+ server_url = server_url[:-3]
430
+
431
+ return server_url.rstrip("/")
432
+
433
+
434
+ def connect_to_server_http(config=None, **kwargs):
435
+ """Connect to server using HTTP streaming transport.
436
+
437
+ This is a convenience function that sets transport="http" automatically.
438
+ For a unified interface, use connect_to_server(transport="http") instead.
439
+
440
+ Args:
441
+ config: Configuration dict with server_url, token, workspace, etc.
442
+ **kwargs: Additional configuration options
443
+
444
+ Returns:
445
+ ServerContextManager that can be used as async context manager
446
+ """
447
+ from .client import connect_to_server
448
+ config = config or {}
449
+ config.update(kwargs)
450
+ config["transport"] = "http"
451
+ return connect_to_server(config)
452
+
453
+
454
+ async def _connect_to_server_http(config: dict):
455
+ """Internal function to establish HTTP streaming connection."""
456
+ client_id = config.get("client_id")
457
+ if client_id is None:
458
+ client_id = shortuuid.uuid()
459
+
460
+ server_url = normalize_server_url(config["server_url"])
461
+
462
+ connection = HTTPStreamingRPCConnection(
463
+ server_url,
464
+ client_id,
465
+ workspace=config.get("workspace"),
466
+ token=config.get("token"),
467
+ reconnection_token=config.get("reconnection_token"),
468
+ timeout=config.get("method_timeout", 30),
469
+ ssl=config.get("ssl"),
470
+ token_refresh_interval=config.get("token_refresh_interval", 2 * 60 * 60),
471
+ # Default to msgpack for full binary support and proper RPC message handling
472
+ format=config.get("format", "msgpack"),
473
+ )
474
+
475
+ connection_info = await connection.open()
476
+ assert connection_info, "Failed to connect to server"
477
+
478
+ await asyncio.sleep(0.1)
479
+
480
+ workspace = connection_info["workspace"]
481
+
482
+ rpc = RPC(
483
+ connection,
484
+ client_id=client_id,
485
+ workspace=workspace,
486
+ default_context={"connection_type": "http_streaming"},
487
+ name=config.get("name"),
488
+ method_timeout=config.get("method_timeout"),
489
+ loop=config.get("loop"),
490
+ app_id=config.get("app_id"),
491
+ server_base_url=connection_info.get("public_base_url"),
492
+ )
493
+
494
+ await rpc.wait_for("services_registered", timeout=config.get("method_timeout", 120))
495
+
496
+ wm = await rpc.get_manager_service(
497
+ {"timeout": config.get("method_timeout", 30), "case_conversion": "snake"}
498
+ )
499
+ wm.rpc = rpc
500
+
501
+ # Add standard methods
502
+ wm.disconnect = schema_function(
503
+ rpc.disconnect,
504
+ name="disconnect",
505
+ description="Disconnect from server",
506
+ parameters={"properties": {}, "type": "object"},
507
+ )
508
+
509
+ wm.register_service = schema_function(
510
+ rpc.register_service,
511
+ name="register_service",
512
+ description="Register a service",
513
+ parameters={
514
+ "properties": {
515
+ "service": {"description": "Service to register", "type": "object"},
516
+ },
517
+ "required": ["service"],
518
+ "type": "object",
519
+ },
520
+ )
521
+
522
+ _get_service = wm.get_service
523
+
524
+ async def get_service(query, config=None, **kwargs):
525
+ config = config or {}
526
+ config.update(kwargs)
527
+ return await _get_service(query, config=config)
528
+
529
+ if hasattr(wm.get_service, "__schema__"):
530
+ get_service.__schema__ = wm.get_service.__schema__
531
+ wm.get_service = get_service
532
+
533
+ async def serve():
534
+ await asyncio.Event().wait()
535
+
536
+ wm.serve = schema_function(
537
+ serve, name="serve", description="Run event loop forever", parameters={}
538
+ )
539
+
540
+ if connection_info:
541
+ wm.config.update(connection_info)
542
+
543
+ # Handle force-exit from manager
544
+ if connection.manager_id:
545
+ async def handle_disconnect(message):
546
+ if message.get("from") == "*/" + connection.manager_id:
547
+ logger.info(f"Disconnecting from server: {message.get('reason')}")
548
+ await rpc.disconnect()
549
+
550
+ rpc.on("force-exit", handle_disconnect)
551
+
552
+ return wm
553
+
554
+
555
+ def get_remote_service_http(service_uri: str, config=None, **kwargs):
556
+ """Get a remote service using HTTP transport.
557
+
558
+ This is a convenience function that sets transport="http" automatically.
559
+ For a unified interface, use get_remote_service with transport="http" instead.
560
+ """
561
+ from .client import get_remote_service
562
+ config = config or {}
563
+ config.update(kwargs)
564
+ config["transport"] = "http"
565
+ return get_remote_service(service_uri, config)
hypha_rpc/rpc.py CHANGED
@@ -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.87
3
+ Version: 0.20.89
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
@@ -1,18 +1,20 @@
1
- hypha_rpc/VERSION,sha256=t0-iGHxhjY_DwlQzE8653E1Bi0iVQX8Fua4G775GVSw,26
2
- hypha_rpc/__init__.py,sha256=l4WUo9cku9B5Hx_vMvgmFwQ3eOIsuoSzLy4Z-Amqf6I,4641
1
+ hypha_rpc/VERSION,sha256=vpnrMBAiB3l6wEQDy9_zHUIqw7t3BxjiItBOfzE1WXU,26
2
+ hypha_rpc/__init__.py,sha256=Nm01vho0uIMXET8-lqxaWTRgoKQD-0SEhpmS9odRed0,4721
3
+ hypha_rpc/client.py,sha256=9c3Qd1bboNiEc_5P4jRLi233VFHyyME8alZYZ5ZZq6s,4551
4
+ hypha_rpc/http_client.py,sha256=xdMhU64kte2T7uPVtwzRRWKOnYB_wRTCVg0qKzLllh8,20285
3
5
  hypha_rpc/pyodide_sse.py,sha256=o1-6Bqb7bcplSy7pwkmtQb6vKeJsyxex_RebqNd3wX8,2960
4
6
  hypha_rpc/pyodide_websocket.py,sha256=XjrgKYySUSNYma-rXjHrSv08YCxj5t4hYEQnK15D6cE,18749
5
- hypha_rpc/rpc.py,sha256=KIq1KiYqnDBi9iVmN9lvmWucyIpkG_fmyflmnBi9MZI,115334
7
+ hypha_rpc/rpc.py,sha256=lnNiPAm_BNT5JjDhAPFRlxs6HioXw77TelUtYd_blYk,115579
6
8
  hypha_rpc/sync.py,sha256=HcQwpGHsZjDNcSnDRuyxGu7bquOi5_jWrVL5vTwraZY,12268
7
9
  hypha_rpc/webrtc_client.py,sha256=JVbSTWr6Y6vMaeoAPsfecD2SuCtXOuoBVuhpwG5-Qm0,11944
8
- hypha_rpc/websocket_client.py,sha256=are48UpC4WUva3y8DhrD10gPcVeOfXE-9B6I9Zu-4wA,51166
10
+ hypha_rpc/websocket_client.py,sha256=haH-SKxaAWNnest3doBVhp7H5XlgqVFjFOW-KnFqxPo,48914
9
11
  hypha_rpc/utils/__init__.py,sha256=1UWExsUWzNRFkuYa7RSDXH3welrelIxOmGLtzdJ2oIA,20042
10
12
  hypha_rpc/utils/launch.py,sha256=GB1Ranb5E_oNFBLw2ARfT78SbqGEwUmWwfMo3E82kAM,3976
11
13
  hypha_rpc/utils/mcp.py,sha256=AW48yjCovc0jyekRLeD_1U8mRaA8-nEqh4DotSE_s3Y,17348
12
14
  hypha_rpc/utils/pydantic.py,sha256=a09_ys4BSXc4Yi6OgZjdspbtLvQVoRCChr6uInY4fN4,5144
13
15
  hypha_rpc/utils/schema.py,sha256=WabBJiDheMKRXUroVe9JRlI5P4Wlv6kc0roxVNQZHH8,22110
14
16
  hypha_rpc/utils/serve.py,sha256=xr_3oAQDyignQbz1fcm4kuRMBOb52-i0VSYCjZou51c,11882
15
- hypha_rpc-0.20.87.dist-info/METADATA,sha256=QVo6wQUTWi8bqEMU698IJ0A0FqhSYUWQq24qeOnA47I,924
16
- hypha_rpc-0.20.87.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
17
- hypha_rpc-0.20.87.dist-info/top_level.txt,sha256=uShPbaPGP-Ig8OVnQcT6sEzV0Qhb6wfxSJ3uCmYaB58,10
18
- hypha_rpc-0.20.87.dist-info/RECORD,,
17
+ hypha_rpc-0.20.89.dist-info/METADATA,sha256=Kybx_aqgy8mSAGioAlrqRJHmnjJwn29rcer8V5lBpV0,924
18
+ hypha_rpc-0.20.89.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
19
+ hypha_rpc-0.20.89.dist-info/top_level.txt,sha256=uShPbaPGP-Ig8OVnQcT6sEzV0Qhb6wfxSJ3uCmYaB58,10
20
+ hypha_rpc-0.20.89.dist-info/RECORD,,