hypha-rpc 0.20.91__tar.gz → 0.20.93__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.91 → hypha_rpc-0.20.93}/PKG-INFO +1 -1
- hypha_rpc-0.20.93/hypha_rpc/VERSION +3 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc/__init__.py +7 -2
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc/http_client.py +23 -7
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc/websocket_client.py +112 -4
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc.egg-info/PKG-INFO +1 -1
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc.egg-info/SOURCES.txt +1 -1
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/pyproject.toml +1 -1
- hypha_rpc-0.20.93/tests/test_http_rpc.py +422 -0
- hypha_rpc-0.20.91/hypha_rpc/VERSION +0 -3
- hypha_rpc-0.20.91/hypha_rpc/client.py +0 -123
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/MANIFEST.in +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/README.md +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc/pyodide_sse.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc/pyodide_websocket.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc/rpc.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc/sync.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc/utils/__init__.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc/utils/launch.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc/utils/mcp.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc/utils/pydantic.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc/utils/schema.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc/utils/serve.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc/webrtc_client.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc.egg-info/dependency_links.txt +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc.egg-info/requires.txt +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/hypha_rpc.egg-info/top_level.txt +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/setup.cfg +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/tests/test_mcp.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/tests/test_reconnection_runner.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/tests/test_reconnection_stability.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/tests/test_schema.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/tests/test_server_compatibility.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/tests/test_utils.py +0 -0
- {hypha_rpc-0.20.91 → hypha_rpc-0.20.93}/tests/test_websocket_rpc.py +0 -0
|
@@ -15,8 +15,13 @@ 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
|
-
|
|
18
|
+
from .websocket_client import (
|
|
19
|
+
connect_to_server,
|
|
20
|
+
get_remote_service,
|
|
21
|
+
login,
|
|
22
|
+
logout,
|
|
23
|
+
setup_local_client,
|
|
24
|
+
)
|
|
20
25
|
from .http_client import HTTPStreamingRPCConnection
|
|
21
26
|
|
|
22
27
|
# read the version from the VERSION file; but get the path from the __file__
|
|
@@ -174,15 +174,26 @@ class HTTPStreamingRPCConnection:
|
|
|
174
174
|
elif self._ssl is not None:
|
|
175
175
|
verify = self._ssl
|
|
176
176
|
|
|
177
|
+
# Try to enable HTTP/2 if h2 is available
|
|
178
|
+
try:
|
|
179
|
+
import h2 # noqa
|
|
180
|
+
http2_enabled = True
|
|
181
|
+
logger.info("HTTP/2 enabled for improved performance")
|
|
182
|
+
except ImportError:
|
|
183
|
+
http2_enabled = False
|
|
184
|
+
logger.debug("HTTP/2 not available (install httpx[http2] for better performance)")
|
|
185
|
+
|
|
177
186
|
return httpx.AsyncClient(
|
|
178
187
|
timeout=httpx.Timeout(self._timeout, connect=30.0),
|
|
179
188
|
verify=verify,
|
|
180
|
-
#
|
|
189
|
+
# Optimized connection pooling for high-performance RPC
|
|
181
190
|
limits=httpx.Limits(
|
|
182
|
-
max_connections=
|
|
183
|
-
max_keepalive_connections=
|
|
184
|
-
keepalive_expiry=
|
|
191
|
+
max_connections=200, # Max total connections (increased for parallel requests)
|
|
192
|
+
max_keepalive_connections=50, # More reusable connections (up from 20)
|
|
193
|
+
keepalive_expiry=300.0, # Keep connections alive longer (5 minutes)
|
|
185
194
|
),
|
|
195
|
+
# Enable HTTP/2 for better multiplexing if available
|
|
196
|
+
http2=http2_enabled,
|
|
186
197
|
)
|
|
187
198
|
|
|
188
199
|
async def open(self):
|
|
@@ -418,7 +429,11 @@ class HTTPStreamingRPCConnection:
|
|
|
418
429
|
self._handle_message(data)
|
|
419
430
|
|
|
420
431
|
async def emit_message(self, data: bytes):
|
|
421
|
-
"""Send a message to the server via HTTP POST.
|
|
432
|
+
"""Send a message to the server via HTTP POST.
|
|
433
|
+
|
|
434
|
+
Uses optimized connection pooling with keep-alive for better performance.
|
|
435
|
+
HTTP client automatically handles efficient transfer for all payload sizes.
|
|
436
|
+
"""
|
|
422
437
|
if self._closed:
|
|
423
438
|
raise ConnectionError("Connection is closed")
|
|
424
439
|
|
|
@@ -430,6 +445,7 @@ class HTTPStreamingRPCConnection:
|
|
|
430
445
|
params = {"client_id": self._client_id}
|
|
431
446
|
|
|
432
447
|
try:
|
|
448
|
+
# httpx handles large payloads efficiently with connection pooling
|
|
433
449
|
response = await self._http_client.post(
|
|
434
450
|
url,
|
|
435
451
|
content=data,
|
|
@@ -511,7 +527,7 @@ def connect_to_server_http(config=None, **kwargs):
|
|
|
511
527
|
Returns:
|
|
512
528
|
ServerContextManager that can be used as async context manager
|
|
513
529
|
"""
|
|
514
|
-
from .
|
|
530
|
+
from .websocket_client import connect_to_server
|
|
515
531
|
config = config or {}
|
|
516
532
|
config.update(kwargs)
|
|
517
533
|
config["transport"] = "http"
|
|
@@ -625,7 +641,7 @@ def get_remote_service_http(service_uri: str, config=None, **kwargs):
|
|
|
625
641
|
This is a convenience function that sets transport="http" automatically.
|
|
626
642
|
For a unified interface, use get_remote_service with transport="http" instead.
|
|
627
643
|
"""
|
|
628
|
-
from .
|
|
644
|
+
from .websocket_client import get_remote_service
|
|
629
645
|
config = config or {}
|
|
630
646
|
config.update(kwargs)
|
|
631
647
|
config["transport"] = "http"
|
|
@@ -738,10 +738,6 @@ async def logout(config):
|
|
|
738
738
|
await server.disconnect()
|
|
739
739
|
|
|
740
740
|
|
|
741
|
-
# Re-export from client.py for backwards compatibility
|
|
742
|
-
from .client import ServerContextManager, connect_to_server, get_remote_service
|
|
743
|
-
|
|
744
|
-
|
|
745
741
|
async def webrtc_get_service(wm, rtc_service_id, query, config=None, **kwargs):
|
|
746
742
|
config = config or {}
|
|
747
743
|
config.update(kwargs)
|
|
@@ -1151,6 +1147,118 @@ async def _connect_to_server(config):
|
|
|
1151
1147
|
return wm
|
|
1152
1148
|
|
|
1153
1149
|
|
|
1150
|
+
class ServerContextManager:
|
|
1151
|
+
"""Server context manager.
|
|
1152
|
+
|
|
1153
|
+
Supports multiple transport types:
|
|
1154
|
+
- "websocket" (default): Traditional WebSocket connection
|
|
1155
|
+
- "http": HTTP streaming connection (more resilient to network issues)
|
|
1156
|
+
"""
|
|
1157
|
+
|
|
1158
|
+
def __init__(self, config=None, service_id=None, **kwargs):
|
|
1159
|
+
self.config = config or {}
|
|
1160
|
+
self.config.update(kwargs)
|
|
1161
|
+
|
|
1162
|
+
if not self.config:
|
|
1163
|
+
# try to load from env
|
|
1164
|
+
if not os.environ.get("HYPHA_SERVER_URL"):
|
|
1165
|
+
try:
|
|
1166
|
+
from dotenv import load_dotenv, find_dotenv
|
|
1167
|
+
load_dotenv(dotenv_path=find_dotenv(usecwd=True))
|
|
1168
|
+
# use info from .env file
|
|
1169
|
+
print("✅ Loaded connection configuration from .env file.")
|
|
1170
|
+
except ImportError:
|
|
1171
|
+
pass
|
|
1172
|
+
self.config = {
|
|
1173
|
+
"server_url": os.getenv("HYPHA_SERVER_URL"),
|
|
1174
|
+
"token": os.getenv("HYPHA_TOKEN"),
|
|
1175
|
+
"client_id": os.getenv("HYPHA_CLIENT_ID"),
|
|
1176
|
+
"workspace": os.getenv("HYPHA_WORKSPACE"),
|
|
1177
|
+
}
|
|
1178
|
+
if not self.config["server_url"]:
|
|
1179
|
+
raise ValueError(
|
|
1180
|
+
"Please set the HYPHA_SERVER_URL, HYPHA_TOKEN, "
|
|
1181
|
+
"HYPHA_CLIENT_ID, and HYPHA_WORKSPACE environment variables"
|
|
1182
|
+
)
|
|
1183
|
+
self._service_id = service_id
|
|
1184
|
+
self._transport = self.config.pop("transport", "websocket")
|
|
1185
|
+
self.wm = None
|
|
1186
|
+
|
|
1187
|
+
async def __aenter__(self):
|
|
1188
|
+
if self._transport == "http":
|
|
1189
|
+
from .http_client import _connect_to_server_http
|
|
1190
|
+
self.wm = await _connect_to_server_http(self.config)
|
|
1191
|
+
else:
|
|
1192
|
+
self.wm = await _connect_to_server(self.config)
|
|
1193
|
+
if self._service_id:
|
|
1194
|
+
return await self.wm.get_service(
|
|
1195
|
+
self._service_id,
|
|
1196
|
+
{"case_conversion": self.config.get("case_conversion")},
|
|
1197
|
+
)
|
|
1198
|
+
return self.wm
|
|
1199
|
+
|
|
1200
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
1201
|
+
await self.wm.disconnect()
|
|
1202
|
+
|
|
1203
|
+
def __await__(self):
|
|
1204
|
+
return self.__aenter__().__await__()
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def connect_to_server(config=None, **kwargs):
|
|
1208
|
+
"""Connect to a Hypha server.
|
|
1209
|
+
|
|
1210
|
+
Args:
|
|
1211
|
+
config: Configuration dict with connection options
|
|
1212
|
+
**kwargs: Additional configuration options
|
|
1213
|
+
|
|
1214
|
+
Configuration options:
|
|
1215
|
+
server_url: The server URL (required)
|
|
1216
|
+
workspace: Target workspace (optional)
|
|
1217
|
+
token: Authentication token (optional)
|
|
1218
|
+
client_id: Unique client identifier (optional, auto-generated if not provided)
|
|
1219
|
+
transport: Transport type - "websocket" (default) or "http"
|
|
1220
|
+
method_timeout: Timeout for RPC method calls
|
|
1221
|
+
ssl: SSL configuration (True/False/SSLContext)
|
|
1222
|
+
|
|
1223
|
+
Returns:
|
|
1224
|
+
ServerContextManager that can be used as async context manager
|
|
1225
|
+
|
|
1226
|
+
Example:
|
|
1227
|
+
async with connect_to_server({"server_url": "https://hypha.aicell.io"}) as server:
|
|
1228
|
+
await server.register_service({"id": "my-service", ...})
|
|
1229
|
+
"""
|
|
1230
|
+
return ServerContextManager(config=config, **kwargs)
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def get_remote_service(service_uri, config=None, **kwargs):
|
|
1234
|
+
"""Get a remote service by URI.
|
|
1235
|
+
|
|
1236
|
+
Args:
|
|
1237
|
+
service_uri: Service URI in format "server_url/workspace/client_id:service_id"
|
|
1238
|
+
config: Additional configuration options
|
|
1239
|
+
**kwargs: Additional configuration options
|
|
1240
|
+
|
|
1241
|
+
Returns:
|
|
1242
|
+
ServerContextManager that resolves to the service when awaited
|
|
1243
|
+
|
|
1244
|
+
Example:
|
|
1245
|
+
async with get_remote_service("https://hypha.aicell.io/public/client:service") as svc:
|
|
1246
|
+
result = await svc.some_method()
|
|
1247
|
+
"""
|
|
1248
|
+
server_url, workspace, client_id, service_id, app_id = parse_service_url(
|
|
1249
|
+
service_uri
|
|
1250
|
+
)
|
|
1251
|
+
full_service_id = f"{workspace}/{client_id}:{service_id}@{app_id}"
|
|
1252
|
+
config = config or {}
|
|
1253
|
+
config.update(kwargs)
|
|
1254
|
+
if "server_url" in config:
|
|
1255
|
+
assert (
|
|
1256
|
+
config["server_url"] == server_url
|
|
1257
|
+
), "server_url in config does not match the server_url in the url"
|
|
1258
|
+
config["server_url"] = server_url
|
|
1259
|
+
return ServerContextManager(config, service_id=full_service_id)
|
|
1260
|
+
|
|
1261
|
+
|
|
1154
1262
|
def setup_local_client(enable_execution=False, on_ready=None):
|
|
1155
1263
|
"""Set up a local client."""
|
|
1156
1264
|
fut = safe_create_future()
|
|
@@ -4,7 +4,6 @@ pyproject.toml
|
|
|
4
4
|
setup.cfg
|
|
5
5
|
hypha_rpc/VERSION
|
|
6
6
|
hypha_rpc/__init__.py
|
|
7
|
-
hypha_rpc/client.py
|
|
8
7
|
hypha_rpc/http_client.py
|
|
9
8
|
hypha_rpc/pyodide_sse.py
|
|
10
9
|
hypha_rpc/pyodide_websocket.py
|
|
@@ -23,6 +22,7 @@ hypha_rpc/utils/mcp.py
|
|
|
23
22
|
hypha_rpc/utils/pydantic.py
|
|
24
23
|
hypha_rpc/utils/schema.py
|
|
25
24
|
hypha_rpc/utils/serve.py
|
|
25
|
+
tests/test_http_rpc.py
|
|
26
26
|
tests/test_mcp.py
|
|
27
27
|
tests/test_reconnection_runner.py
|
|
28
28
|
tests/test_reconnection_stability.py
|
|
@@ -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.93"
|
|
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"
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Test HTTP RPC transport for hypha-rpc standalone tests.
|
|
2
|
+
|
|
3
|
+
These tests connect to a remote Hypha server to test HTTP transport functionality.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
import numpy as np
|
|
8
|
+
import asyncio
|
|
9
|
+
from hypha_rpc import connect_to_server
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Use public test server - these tests require a running Hypha server
|
|
13
|
+
SERVER_URL = "https://hypha.aicell.io"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestHTTPObjectTransmission:
|
|
17
|
+
"""Test HTTP transport with complex objects and callbacks."""
|
|
18
|
+
|
|
19
|
+
@pytest.mark.asyncio
|
|
20
|
+
async def test_http_numpy_array_transmission(self):
|
|
21
|
+
"""Test transmitting numpy arrays over HTTP transport."""
|
|
22
|
+
# Service provider via WebSocket
|
|
23
|
+
ws_server = await connect_to_server({
|
|
24
|
+
"server_url": SERVER_URL,
|
|
25
|
+
"client_id": "numpy-provider-ws-test",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
workspace = ws_server.config["workspace"]
|
|
30
|
+
|
|
31
|
+
# Register service that works with numpy arrays
|
|
32
|
+
await ws_server.register_service({
|
|
33
|
+
"id": "numpy-service",
|
|
34
|
+
"name": "Numpy Service",
|
|
35
|
+
"config": {"visibility": "public"},
|
|
36
|
+
"process_array": lambda arr: {
|
|
37
|
+
"shape": list(arr.shape), # Convert to list for JSON
|
|
38
|
+
"dtype": str(arr.dtype),
|
|
39
|
+
"sum": float(np.sum(arr)),
|
|
40
|
+
"mean": float(np.mean(arr)),
|
|
41
|
+
"result_array": arr * 2, # Return modified array
|
|
42
|
+
},
|
|
43
|
+
"reshape": lambda arr, shape: np.reshape(arr, shape),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
token = await ws_server.generate_token()
|
|
47
|
+
|
|
48
|
+
# HTTP client connects
|
|
49
|
+
http_server = await connect_to_server({
|
|
50
|
+
"server_url": SERVER_URL,
|
|
51
|
+
"workspace": workspace,
|
|
52
|
+
"client_id": "numpy-consumer-http-test",
|
|
53
|
+
"transport": "http",
|
|
54
|
+
"token": token,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
service = await http_server.get_service(f"{ws_server.config['client_id']}:numpy-service")
|
|
59
|
+
|
|
60
|
+
# Test 1: Send and receive numpy array
|
|
61
|
+
test_array = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32)
|
|
62
|
+
result = await service.process_array(test_array)
|
|
63
|
+
|
|
64
|
+
assert result["shape"] == [2, 3]
|
|
65
|
+
assert result["dtype"] == "float32"
|
|
66
|
+
assert result["sum"] == 21.0
|
|
67
|
+
assert result["mean"] == 3.5
|
|
68
|
+
|
|
69
|
+
result_array = result["result_array"]
|
|
70
|
+
assert isinstance(result_array, np.ndarray)
|
|
71
|
+
assert np.array_equal(result_array, test_array * 2)
|
|
72
|
+
|
|
73
|
+
# Test 2: Large array
|
|
74
|
+
large_array = np.random.rand(100, 100)
|
|
75
|
+
result2 = await service.process_array(large_array)
|
|
76
|
+
assert result2["shape"] == [100, 100]
|
|
77
|
+
|
|
78
|
+
# Test 3: Reshape operation
|
|
79
|
+
flat_array = np.arange(12)
|
|
80
|
+
reshaped = await service.reshape(flat_array, (3, 4))
|
|
81
|
+
assert reshaped.shape == (3, 4)
|
|
82
|
+
assert np.array_equal(reshaped, np.arange(12).reshape(3, 4))
|
|
83
|
+
|
|
84
|
+
finally:
|
|
85
|
+
await http_server.disconnect()
|
|
86
|
+
|
|
87
|
+
finally:
|
|
88
|
+
await ws_server.disconnect()
|
|
89
|
+
|
|
90
|
+
@pytest.mark.asyncio
|
|
91
|
+
async def test_http_nested_objects_transmission(self):
|
|
92
|
+
"""Test transmitting nested complex objects over HTTP."""
|
|
93
|
+
ws_server = await connect_to_server({
|
|
94
|
+
"server_url": SERVER_URL,
|
|
95
|
+
"client_id": "nested-provider-ws-test",
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
workspace = ws_server.config["workspace"]
|
|
100
|
+
|
|
101
|
+
# Service that handles nested objects
|
|
102
|
+
await ws_server.register_service({
|
|
103
|
+
"id": "nested-service",
|
|
104
|
+
"name": "Nested Object Service",
|
|
105
|
+
"config": {"visibility": "public"},
|
|
106
|
+
"process_nested": lambda data: {
|
|
107
|
+
"received_keys": list(data.keys()),
|
|
108
|
+
"array_sum": float(np.sum(data["array"])) if "array" in data else 0,
|
|
109
|
+
"nested_count": len(data.get("nested", {}).get("items", [])),
|
|
110
|
+
"echo": data,
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
token = await ws_server.generate_token()
|
|
115
|
+
|
|
116
|
+
http_server = await connect_to_server({
|
|
117
|
+
"server_url": SERVER_URL,
|
|
118
|
+
"workspace": workspace,
|
|
119
|
+
"client_id": "nested-consumer-http-test",
|
|
120
|
+
"transport": "http",
|
|
121
|
+
"token": token,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
service = await http_server.get_service(f"{ws_server.config['client_id']}:nested-service")
|
|
126
|
+
|
|
127
|
+
# Complex nested structure
|
|
128
|
+
test_data = {
|
|
129
|
+
"string": "test",
|
|
130
|
+
"number": 42,
|
|
131
|
+
"array": np.array([1, 2, 3, 4, 5]),
|
|
132
|
+
"nested": {
|
|
133
|
+
"items": [1, 2, 3],
|
|
134
|
+
"metadata": {
|
|
135
|
+
"name": "test_item",
|
|
136
|
+
"values": [10, 20, 30],
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
"list_of_arrays": [
|
|
140
|
+
np.array([1, 2]),
|
|
141
|
+
np.array([3, 4]),
|
|
142
|
+
],
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
result = await service.process_nested(test_data)
|
|
146
|
+
|
|
147
|
+
assert set(result["received_keys"]) == set(test_data.keys())
|
|
148
|
+
assert result["array_sum"] == 15.0
|
|
149
|
+
assert result["nested_count"] == 3
|
|
150
|
+
|
|
151
|
+
# Verify echo preserves structure
|
|
152
|
+
echo = result["echo"]
|
|
153
|
+
assert echo["string"] == "test"
|
|
154
|
+
assert echo["number"] == 42
|
|
155
|
+
assert np.array_equal(echo["array"], test_data["array"])
|
|
156
|
+
assert echo["nested"]["metadata"]["name"] == "test_item"
|
|
157
|
+
|
|
158
|
+
finally:
|
|
159
|
+
await http_server.disconnect()
|
|
160
|
+
|
|
161
|
+
finally:
|
|
162
|
+
await ws_server.disconnect()
|
|
163
|
+
|
|
164
|
+
@pytest.mark.asyncio
|
|
165
|
+
async def test_http_callbacks_basic(self):
|
|
166
|
+
"""Test basic callback functionality over HTTP transport."""
|
|
167
|
+
ws_server = await connect_to_server({
|
|
168
|
+
"server_url": SERVER_URL,
|
|
169
|
+
"client_id": "callback-provider-ws-test",
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
workspace = ws_server.config["workspace"]
|
|
174
|
+
|
|
175
|
+
# Service that uses callbacks
|
|
176
|
+
async def call_multiple_times(callback, count):
|
|
177
|
+
results = []
|
|
178
|
+
for i in range(count):
|
|
179
|
+
result = await callback(i)
|
|
180
|
+
results.append(result)
|
|
181
|
+
return results
|
|
182
|
+
|
|
183
|
+
async def process_with_progress(data, progress_callback):
|
|
184
|
+
for i in range(len(data)):
|
|
185
|
+
await progress_callback({"step": i, "total": len(data)})
|
|
186
|
+
return sum(data)
|
|
187
|
+
|
|
188
|
+
await ws_server.register_service({
|
|
189
|
+
"id": "callback-service",
|
|
190
|
+
"name": "Callback Service",
|
|
191
|
+
"config": {"visibility": "public"},
|
|
192
|
+
"call_multiple_times": call_multiple_times,
|
|
193
|
+
"process_with_progress": process_with_progress,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
token = await ws_server.generate_token()
|
|
197
|
+
|
|
198
|
+
http_server = await connect_to_server({
|
|
199
|
+
"server_url": SERVER_URL,
|
|
200
|
+
"workspace": workspace,
|
|
201
|
+
"client_id": "callback-consumer-http-test",
|
|
202
|
+
"transport": "http",
|
|
203
|
+
"token": token,
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
service = await http_server.get_service(f"{ws_server.config['client_id']}:callback-service")
|
|
208
|
+
|
|
209
|
+
# Test 1: Simple callback
|
|
210
|
+
callback_results = []
|
|
211
|
+
|
|
212
|
+
def test_callback(value):
|
|
213
|
+
callback_results.append(value)
|
|
214
|
+
return value * 2
|
|
215
|
+
|
|
216
|
+
results = await service.call_multiple_times(test_callback, 5)
|
|
217
|
+
assert len(callback_results) == 5
|
|
218
|
+
assert callback_results == [0, 1, 2, 3, 4]
|
|
219
|
+
assert results == [0, 2, 4, 6, 8]
|
|
220
|
+
|
|
221
|
+
# Test 2: Progress callback
|
|
222
|
+
progress_updates = []
|
|
223
|
+
|
|
224
|
+
def progress_callback(info):
|
|
225
|
+
progress_updates.append(info)
|
|
226
|
+
|
|
227
|
+
test_data = [10, 20, 30, 40]
|
|
228
|
+
result = await service.process_with_progress(test_data, progress_callback)
|
|
229
|
+
assert result == 100
|
|
230
|
+
assert len(progress_updates) == 4
|
|
231
|
+
assert progress_updates[0] == {"step": 0, "total": 4}
|
|
232
|
+
assert progress_updates[-1] == {"step": 3, "total": 4}
|
|
233
|
+
|
|
234
|
+
finally:
|
|
235
|
+
await http_server.disconnect()
|
|
236
|
+
|
|
237
|
+
finally:
|
|
238
|
+
await ws_server.disconnect()
|
|
239
|
+
|
|
240
|
+
@pytest.mark.asyncio
|
|
241
|
+
async def test_http_async_callbacks(self):
|
|
242
|
+
"""Test async callback functionality over HTTP transport."""
|
|
243
|
+
ws_server = await connect_to_server({
|
|
244
|
+
"server_url": SERVER_URL,
|
|
245
|
+
"client_id": "async-callback-provider-ws-test",
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
workspace = ws_server.config["workspace"]
|
|
250
|
+
|
|
251
|
+
# Service with async callback support
|
|
252
|
+
async def process_async_callback(items, async_callback):
|
|
253
|
+
results = []
|
|
254
|
+
for item in items:
|
|
255
|
+
result = await async_callback(item)
|
|
256
|
+
results.append(result)
|
|
257
|
+
return results
|
|
258
|
+
|
|
259
|
+
await ws_server.register_service({
|
|
260
|
+
"id": "async-callback-service",
|
|
261
|
+
"name": "Async Callback Service",
|
|
262
|
+
"config": {"visibility": "public"},
|
|
263
|
+
"process_async": process_async_callback,
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
token = await ws_server.generate_token()
|
|
267
|
+
|
|
268
|
+
http_server = await connect_to_server({
|
|
269
|
+
"server_url": SERVER_URL,
|
|
270
|
+
"workspace": workspace,
|
|
271
|
+
"client_id": "async-callback-consumer-http-test",
|
|
272
|
+
"transport": "http",
|
|
273
|
+
"token": token,
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
service = await http_server.get_service(f"{ws_server.config['client_id']}:async-callback-service")
|
|
278
|
+
|
|
279
|
+
# Async callback
|
|
280
|
+
async def async_transform(value):
|
|
281
|
+
await asyncio.sleep(0.01) # Simulate async work
|
|
282
|
+
return value ** 2
|
|
283
|
+
|
|
284
|
+
test_items = [1, 2, 3, 4, 5]
|
|
285
|
+
results = await service.process_async(test_items, async_transform)
|
|
286
|
+
assert results == [1, 4, 9, 16, 25]
|
|
287
|
+
|
|
288
|
+
finally:
|
|
289
|
+
await http_server.disconnect()
|
|
290
|
+
|
|
291
|
+
finally:
|
|
292
|
+
await ws_server.disconnect()
|
|
293
|
+
|
|
294
|
+
@pytest.mark.asyncio
|
|
295
|
+
async def test_http_callback_with_numpy(self):
|
|
296
|
+
"""Test callbacks that pass numpy arrays over HTTP."""
|
|
297
|
+
ws_server = await connect_to_server({
|
|
298
|
+
"server_url": SERVER_URL,
|
|
299
|
+
"client_id": "numpy-callback-provider-ws-test",
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
workspace = ws_server.config["workspace"]
|
|
304
|
+
|
|
305
|
+
# Service that sends arrays to callbacks
|
|
306
|
+
async def transform_batch(arrays, transform_callback):
|
|
307
|
+
results = []
|
|
308
|
+
for arr in arrays:
|
|
309
|
+
result = await transform_callback(arr)
|
|
310
|
+
results.append(result)
|
|
311
|
+
return results
|
|
312
|
+
|
|
313
|
+
await ws_server.register_service({
|
|
314
|
+
"id": "numpy-callback-service",
|
|
315
|
+
"name": "Numpy Callback Service",
|
|
316
|
+
"config": {"visibility": "public"},
|
|
317
|
+
"transform_batch": transform_batch,
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
token = await ws_server.generate_token()
|
|
321
|
+
|
|
322
|
+
http_server = await connect_to_server({
|
|
323
|
+
"server_url": SERVER_URL,
|
|
324
|
+
"workspace": workspace,
|
|
325
|
+
"client_id": "numpy-callback-consumer-http-test",
|
|
326
|
+
"transport": "http",
|
|
327
|
+
"token": token,
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
service = await http_server.get_service(f"{ws_server.config['client_id']}:numpy-callback-service")
|
|
332
|
+
|
|
333
|
+
# Callback that processes numpy arrays
|
|
334
|
+
def array_processor(arr):
|
|
335
|
+
return {
|
|
336
|
+
"sum": float(np.sum(arr)),
|
|
337
|
+
"modified": arr * 3,
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
test_arrays = [
|
|
341
|
+
np.array([1, 2, 3]),
|
|
342
|
+
np.array([4, 5, 6]),
|
|
343
|
+
np.array([7, 8, 9]),
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
results = await service.transform_batch(test_arrays, array_processor)
|
|
347
|
+
|
|
348
|
+
assert len(results) == 3
|
|
349
|
+
assert results[0]["sum"] == 6.0
|
|
350
|
+
assert results[1]["sum"] == 15.0
|
|
351
|
+
assert results[2]["sum"] == 24.0
|
|
352
|
+
|
|
353
|
+
assert np.array_equal(results[0]["modified"], np.array([3, 6, 9]))
|
|
354
|
+
assert np.array_equal(results[1]["modified"], np.array([12, 15, 18]))
|
|
355
|
+
assert np.array_equal(results[2]["modified"], np.array([21, 24, 27]))
|
|
356
|
+
|
|
357
|
+
finally:
|
|
358
|
+
await http_server.disconnect()
|
|
359
|
+
|
|
360
|
+
finally:
|
|
361
|
+
await ws_server.disconnect()
|
|
362
|
+
|
|
363
|
+
@pytest.mark.asyncio
|
|
364
|
+
async def test_http_binary_data_transmission(self):
|
|
365
|
+
"""Test transmitting raw binary data over HTTP."""
|
|
366
|
+
ws_server = await connect_to_server({
|
|
367
|
+
"server_url": SERVER_URL,
|
|
368
|
+
"client_id": "binary-provider-ws-test",
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
workspace = ws_server.config["workspace"]
|
|
373
|
+
|
|
374
|
+
# Service that handles binary data
|
|
375
|
+
await ws_server.register_service({
|
|
376
|
+
"id": "binary-service",
|
|
377
|
+
"name": "Binary Service",
|
|
378
|
+
"config": {"visibility": "public"},
|
|
379
|
+
"process_binary": lambda data: {
|
|
380
|
+
"length": len(data),
|
|
381
|
+
"first_bytes": data[:10],
|
|
382
|
+
"reversed": bytes(reversed(data)),
|
|
383
|
+
},
|
|
384
|
+
"concat_binary": lambda parts: b"".join(parts),
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
token = await ws_server.generate_token()
|
|
388
|
+
|
|
389
|
+
http_server = await connect_to_server({
|
|
390
|
+
"server_url": SERVER_URL,
|
|
391
|
+
"workspace": workspace,
|
|
392
|
+
"client_id": "binary-consumer-http-test",
|
|
393
|
+
"transport": "http",
|
|
394
|
+
"token": token,
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
service = await http_server.get_service(f"{ws_server.config['client_id']}:binary-service")
|
|
399
|
+
|
|
400
|
+
# Test 1: Send binary data
|
|
401
|
+
test_data = b"Hello, World! This is binary data."
|
|
402
|
+
result = await service.process_binary(test_data)
|
|
403
|
+
|
|
404
|
+
assert result["length"] == len(test_data)
|
|
405
|
+
assert result["first_bytes"] == test_data[:10]
|
|
406
|
+
assert result["reversed"] == bytes(reversed(test_data))
|
|
407
|
+
|
|
408
|
+
# Test 2: Multiple binary chunks
|
|
409
|
+
parts = [b"Part1", b"Part2", b"Part3"]
|
|
410
|
+
concatenated = await service.concat_binary(parts)
|
|
411
|
+
assert concatenated == b"Part1Part2Part3"
|
|
412
|
+
|
|
413
|
+
finally:
|
|
414
|
+
await http_server.disconnect()
|
|
415
|
+
|
|
416
|
+
finally:
|
|
417
|
+
await ws_server.disconnect()
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
if __name__ == "__main__":
|
|
421
|
+
# Allow running tests directly
|
|
422
|
+
pytest.main([__file__, "-v", "-s"])
|
|
@@ -1,123 +0,0 @@
|
|
|
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)
|
|
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
|
|
File without changes
|
|
File without changes
|