hopx-ai 0.1.10__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.

Potentially problematic release.


This version of hopx-ai might be problematic. Click here for more details.

@@ -0,0 +1,502 @@
1
+ # generated by datamodel-codegen:
2
+ # filename: openapi_specs_3.1.md
3
+ # timestamp: 2025-10-22T08:21:26+00:00
4
+
5
+ from __future__ import annotations
6
+
7
+ from enum import Enum
8
+ from typing import Annotated, Any, Dict, List, Optional, Union
9
+
10
+ from pydantic import AnyUrl, AwareDatetime, BaseModel, Field
11
+
12
+
13
+ class Features(BaseModel):
14
+ code_execution: Optional[bool] = None
15
+ file_operations: Optional[bool] = None
16
+ terminal_access: Optional[bool] = None
17
+ websocket_streaming: Optional[bool] = None
18
+ rich_output: Optional[bool] = None
19
+ background_jobs: Optional[bool] = None
20
+ ipython_kernel: Optional[bool] = None
21
+ system_metrics: Optional[bool] = None
22
+ languages: Annotated[
23
+ Optional[List[str]], Field(examples=[['python', 'javascript', 'bash', 'go']])
24
+ ] = None
25
+
26
+
27
+ class HealthResponse(BaseModel):
28
+ status: Annotated[Optional[str], Field(examples=['healthy'])] = None
29
+ agent: Annotated[Optional[str], Field(examples=['hopx-vm-agent-desktop'])] = None
30
+ version: Annotated[Optional[str], Field(examples=['3.1.1'])] = None
31
+ uptime: Annotated[Optional[str], Field(examples=['2h34m12s'])] = None
32
+ go_version: Annotated[Optional[str], Field(examples=['go1.22.2'])] = None
33
+ vm_id: Annotated[Optional[str], Field(examples=['1760954509layu9lw0'])] = None
34
+ features: Optional[Features] = None
35
+ active_streams: Annotated[Optional[int], Field(examples=[0])] = None
36
+
37
+
38
+ class InfoResponse(BaseModel):
39
+ vm_id: Optional[str] = None
40
+ agent: Optional[str] = None
41
+ agent_version: Optional[str] = None
42
+ os: Optional[str] = None
43
+ arch: Optional[str] = None
44
+ go_version: Optional[str] = None
45
+ vm_ip: Optional[str] = None
46
+ vm_port: Optional[str] = None
47
+ start_time: Optional[AwareDatetime] = None
48
+ uptime: Annotated[Optional[float], Field(description='Uptime in seconds')] = None
49
+ endpoints: Annotated[
50
+ Optional[Dict[str, str]], Field(description='Map of endpoint names to HTTP methods + paths')
51
+ ] = None
52
+ features: Annotated[Optional[Dict[str, Any]], Field(description='Available features')] = None
53
+
54
+
55
+ class Cpu(BaseModel):
56
+ usage_percent: Optional[float] = None
57
+ cores: Optional[int] = None
58
+
59
+
60
+ class Memory(BaseModel):
61
+ total: Optional[int] = None
62
+ used: Optional[int] = None
63
+ free: Optional[int] = None
64
+ usage_percent: Optional[float] = None
65
+
66
+
67
+ class Disk(BaseModel):
68
+ total: Optional[int] = None
69
+ used: Optional[int] = None
70
+ free: Optional[int] = None
71
+ usage_percent: Optional[float] = None
72
+
73
+
74
+ class SystemMetrics(BaseModel):
75
+ cpu: Optional[Cpu] = None
76
+ memory: Optional[Memory] = None
77
+ disk: Optional[Disk] = None
78
+ uptime: Annotated[Optional[float], Field(description='System uptime in seconds')] = None
79
+
80
+
81
+ class Language(Enum):
82
+ PYTHON = 'python'
83
+ PYTHON3 = 'python3'
84
+ NODE = 'node'
85
+ NODEJS = 'nodejs'
86
+ JAVASCRIPT = 'javascript'
87
+ JS = 'js'
88
+ BASH = 'bash'
89
+ SH = 'sh'
90
+ SHELL = 'shell'
91
+ GO = 'go'
92
+
93
+
94
+ class ExecuteRequest(BaseModel):
95
+ code: Annotated[str, Field(description='Code to execute', examples=['print("Hello, World!")'])]
96
+ language: Annotated[Language, Field(description='Programming language', examples=['python'])]
97
+ env: Annotated[
98
+ Optional[Dict[str, str]],
99
+ Field(
100
+ description='Optional environment variables for this execution only.\n\n**Priority**: Request env > Global env > Agent env\n\n**Example**: `{"DATABASE_URL": "postgres://localhost/app", "DEBUG": "true"}`\n',
101
+ examples=[{'DATABASE_URL': 'postgres://localhost/testdb', 'DEBUG': 'true'}],
102
+ ),
103
+ ] = None
104
+ timeout: Annotated[Optional[int], Field(description='Timeout in seconds', ge=1, le=300)] = 30
105
+
106
+
107
+ class ExecuteResponse(BaseModel):
108
+ stdout: Annotated[Optional[str], Field(description='Standard output')] = None
109
+ stderr: Annotated[Optional[str], Field(description='Standard error')] = None
110
+ exit_code: Annotated[Optional[int], Field(description='Exit code (0 = success)')] = None
111
+ execution_time: Annotated[Optional[float], Field(description='Execution time in seconds')] = (
112
+ None
113
+ )
114
+ timestamp: Optional[AwareDatetime] = None
115
+ language: Optional[str] = None
116
+ success: Annotated[
117
+ Optional[bool], Field(description='Whether execution succeeded (exit_code == 0)')
118
+ ] = None
119
+
120
+
121
+ class BackgroundExecuteRequest(ExecuteRequest):
122
+ name: Annotated[
123
+ Optional[str], Field(description='Optional process name for identification')
124
+ ] = None
125
+
126
+
127
+ class Status(Enum):
128
+ running = 'running'
129
+ completed = 'completed'
130
+ failed = 'failed'
131
+ killed = 'killed'
132
+
133
+
134
+ class BackgroundExecuteResponse(BaseModel):
135
+ process_id: Annotated[Optional[str], Field(description='Unique process identifier')] = None
136
+ execution_id: Annotated[Optional[str], Field(description='Execution identifier')] = None
137
+ status: Optional[Status] = None
138
+ start_time: Optional[AwareDatetime] = None
139
+ message: Optional[str] = None
140
+ name: Annotated[Optional[str], Field(description='Process name (if provided)')] = None
141
+
142
+
143
+ class Status1(Enum):
144
+ queued = 'queued'
145
+
146
+
147
+ class AsyncExecuteResponse(BaseModel):
148
+ execution_id: Annotated[
149
+ Optional[str], Field(description='Unique execution identifier', examples=['abc123-def456'])
150
+ ] = None
151
+ status: Annotated[
152
+ Optional[Status1], Field(description='Execution status (always "queued" initially)')
153
+ ] = None
154
+ callback_url: Annotated[
155
+ Optional[AnyUrl],
156
+ Field(
157
+ description='URL that will receive execution results',
158
+ examples=['https://client.com/webhooks/execution'],
159
+ ),
160
+ ] = None
161
+ message: Annotated[
162
+ Optional[str],
163
+ Field(
164
+ description='Human-readable message',
165
+ examples=['Execution queued. Will POST to callback_url when complete.'],
166
+ ),
167
+ ] = None
168
+ timestamp: Annotated[Optional[AwareDatetime], Field(description='Queue timestamp')] = None
169
+
170
+
171
+ class Status2(Enum):
172
+ COMPLETED = 'completed'
173
+ FAILED = 'failed'
174
+ TIMEOUT = 'timeout'
175
+
176
+
177
+ class WebhookExecutionComplete(BaseModel):
178
+ execution_id: Annotated[
179
+ Optional[str],
180
+ Field(
181
+ description='Execution identifier (matches AsyncExecuteResponse)',
182
+ examples=['abc123-def456'],
183
+ ),
184
+ ] = None
185
+ status: Annotated[Optional[Status2], Field(description='Final execution status')] = None
186
+ stdout: Annotated[Optional[str], Field(description='Standard output')] = None
187
+ stderr: Annotated[Optional[str], Field(description='Standard error')] = None
188
+ exit_code: Annotated[Optional[int], Field(description='Exit code (0 = success)')] = None
189
+ execution_time: Annotated[
190
+ Optional[float], Field(description='Execution time in seconds', examples=[600.123])
191
+ ] = None
192
+ timestamp: Annotated[Optional[AwareDatetime], Field(description='Completion timestamp')] = None
193
+ error: Annotated[
194
+ Optional[str], Field(description='Error message (if status is "failed" or "timeout")')
195
+ ] = None
196
+
197
+
198
+ class Status3(Enum):
199
+ running = 'running'
200
+ completed = 'completed'
201
+ failed = 'failed'
202
+ killed = 'killed'
203
+
204
+
205
+ class ProcessInfo(BaseModel):
206
+ process_id: Optional[str] = None
207
+ execution_id: Optional[str] = None
208
+ name: Optional[str] = None
209
+ status: Optional[Status3] = None
210
+ language: Optional[str] = None
211
+ start_time: Optional[AwareDatetime] = None
212
+ end_time: Optional[AwareDatetime] = None
213
+ exit_code: Optional[int] = None
214
+ duration: Annotated[Optional[float], Field(description='Duration in seconds')] = None
215
+ pid: Annotated[Optional[int], Field(description='System process ID')] = None
216
+
217
+
218
+ class ProcessListResponse(BaseModel):
219
+ processes: Optional[List[ProcessInfo]] = None
220
+ count: Optional[int] = None
221
+ timestamp: Optional[AwareDatetime] = None
222
+
223
+
224
+ class Type(Enum):
225
+ image_png = 'image/png'
226
+ text_html = 'text/html'
227
+ application_json = 'application/json'
228
+ application_vnd_dataframe_json = 'application/vnd.dataframe+json' # Pandas dataframe
229
+
230
+
231
+ class Format(Enum):
232
+ base64 = 'base64'
233
+ html = 'html'
234
+ json = 'json'
235
+
236
+
237
+ class Source(Enum):
238
+ matplotlib = 'matplotlib'
239
+ pandas = 'pandas'
240
+ plotly = 'plotly'
241
+ pandas_stdout = 'pandas_stdout' # Pandas to stdout
242
+ other = 'other'
243
+
244
+
245
+ class Metadata(BaseModel):
246
+ source: Optional[Source] = None
247
+ filename: Optional[str] = None
248
+ size: Optional[int] = None
249
+
250
+
251
+ class RichOutput(BaseModel):
252
+ type: Annotated[Optional[Type], Field(description='MIME type')] = None
253
+ format: Optional[Format] = None
254
+ data: Annotated[
255
+ Optional[Union[str, Dict[str, Any]]], Field(description='Output data (base64 for images, HTML for tables, dict for dataframes)')
256
+ ] = None
257
+ metadata: Optional[Metadata] = None
258
+
259
+
260
+ class CommandResponse(BaseModel):
261
+ stdout: Optional[str] = None
262
+ stderr: Optional[str] = None
263
+ exit_code: Optional[int] = None
264
+ execution_time: Optional[float] = None
265
+ command: Optional[str] = None
266
+ timestamp: Optional[AwareDatetime] = None
267
+
268
+
269
+ class EnvVarsSetRequest(BaseModel):
270
+ env_vars: Annotated[
271
+ Dict[str, str],
272
+ Field(
273
+ description='Environment variables as key-value pairs',
274
+ examples=[
275
+ {
276
+ 'DATABASE_URL': 'postgres://prod-db/app',
277
+ 'API_KEY': 'sk-1234567890abcdef',
278
+ 'ENVIRONMENT': 'production',
279
+ }
280
+ ],
281
+ ),
282
+ ]
283
+ timestamp: Annotated[
284
+ Optional[AwareDatetime],
285
+ Field(
286
+ description='Optional timestamp for deduplication (prevents old requests from overwriting new ones)'
287
+ ),
288
+ ] = None
289
+ merge: Annotated[
290
+ Optional[bool],
291
+ Field(
292
+ description='If true, merge with existing env vars. If false (default), replace all.\n\n**Note**: PATCH /env always merges, PUT /env always replaces (this field is ignored for those endpoints)\n'
293
+ ),
294
+ ] = False
295
+
296
+
297
+ class EnvVarsResponse(BaseModel):
298
+ env_vars: Annotated[
299
+ Optional[Dict[str, str]],
300
+ Field(
301
+ description='Environment variables as key-value pairs.\n\n**Security**: Sensitive values (containing KEY, SECRET, PASSWORD, TOKEN, etc.) are masked.\n',
302
+ examples=[
303
+ {
304
+ 'DATABASE_URL': 'postgres://localhost/app',
305
+ 'API_KEY': '***MASKED***',
306
+ 'DEBUG': 'true',
307
+ }
308
+ ],
309
+ ),
310
+ ] = None
311
+ count: Annotated[
312
+ Optional[int], Field(description='Number of environment variables', examples=[3])
313
+ ] = None
314
+ timestamp: Annotated[Optional[AwareDatetime], Field(description='Response timestamp')] = None
315
+
316
+
317
+ class FileInfo(BaseModel):
318
+ name: Annotated[Optional[str], Field(description='File or directory name')] = None
319
+ path: Annotated[Optional[str], Field(description='Full path')] = None
320
+ size: Annotated[Optional[int], Field(description='Size in bytes')] = None
321
+ is_directory: Optional[bool] = None
322
+ modified_time: Optional[AwareDatetime] = None
323
+ permissions: Annotated[
324
+ Optional[str], Field(description='Unix permissions (e.g., drwxr-xr-x)')
325
+ ] = None
326
+
327
+
328
+ class FileListResponse(BaseModel):
329
+ files: Optional[List[FileInfo]] = None
330
+ path: Annotated[Optional[str], Field(description='Directory path')] = None
331
+ count: Annotated[Optional[int], Field(description='Number of files')] = None
332
+
333
+
334
+ class FileContentResponse(BaseModel):
335
+ content: Annotated[Optional[str], Field(description='File contents')] = None
336
+ path: Optional[str] = None
337
+ size: Annotated[Optional[int], Field(description='Size in bytes')] = None
338
+
339
+
340
+ class FileWriteRequest(BaseModel):
341
+ path: Annotated[str, Field(description='File path to write', examples=['/workspace/script.py'])]
342
+ content: Annotated[str, Field(description='File contents')]
343
+
344
+
345
+ class FileResponse(BaseModel):
346
+ message: Optional[str] = None
347
+ path: Optional[str] = None
348
+ success: Optional[bool] = None
349
+ size: Annotated[Optional[int], Field(description='File size (for write operations)')] = None
350
+ timestamp: Optional[AwareDatetime] = None
351
+
352
+
353
+ class VNCInfo(BaseModel):
354
+ url: Annotated[
355
+ Optional[str], Field(description='VNC connection URL', examples=['vnc://vm.hopx.dev:5901'])
356
+ ] = None
357
+ password: Annotated[Optional[str], Field(description='VNC password')] = None
358
+ display: Annotated[Optional[int], Field(description='X11 display number', examples=[1])] = None
359
+ port: Annotated[Optional[int], Field(description='VNC port', examples=[5901])] = None
360
+ websocket_url: Annotated[
361
+ Optional[str], Field(description='noVNC WebSocket URL (if available)')
362
+ ] = None
363
+
364
+
365
+ class WindowInfo(BaseModel):
366
+ id: Annotated[Optional[int], Field(description='Window ID (X11 window identifier)')] = None
367
+ title: Annotated[
368
+ Optional[str], Field(description='Window title', examples=['Firefox - Mozilla Firefox'])
369
+ ] = None
370
+ x: Annotated[Optional[int], Field(description='X coordinate')] = None
371
+ y: Annotated[Optional[int], Field(description='Y coordinate')] = None
372
+ width: Annotated[Optional[int], Field(description='Window width')] = None
373
+ height: Annotated[Optional[int], Field(description='Window height')] = None
374
+ is_active: Annotated[
375
+ Optional[bool], Field(description='Whether this window is currently active')
376
+ ] = None
377
+ is_minimized: Annotated[
378
+ Optional[bool], Field(description='Whether this window is minimized')
379
+ ] = None
380
+ pid: Annotated[Optional[int], Field(description='Process ID owning this window')] = None
381
+
382
+
383
+ class Status4(Enum):
384
+ RECORDING = 'recording'
385
+ STOPPED = 'stopped'
386
+ FAILED = 'failed'
387
+
388
+
389
+ class Format1(Enum):
390
+ MP4 = 'mp4'
391
+ WEBM = 'webm'
392
+
393
+
394
+ class RecordingInfo(BaseModel):
395
+ recording_id: Annotated[Optional[str], Field(description='Unique recording identifier')] = None
396
+ status: Annotated[Optional[Status4], Field(description='Recording status')] = None
397
+ start_time: Annotated[Optional[AwareDatetime], Field(description='Recording start time')] = None
398
+ end_time: Annotated[
399
+ Optional[AwareDatetime], Field(description='Recording end time (if stopped)')
400
+ ] = None
401
+ duration: Annotated[Optional[float], Field(description='Recording duration in seconds')] = None
402
+ file_path: Annotated[Optional[str], Field(description='Path to recorded video file')] = None
403
+ file_size: Annotated[Optional[int], Field(description='Video file size in bytes')] = None
404
+ format: Annotated[Optional[Format1], Field(description='Video format')] = None
405
+
406
+
407
+ class Display(BaseModel):
408
+ id: Optional[int] = None
409
+ name: Optional[str] = None
410
+ width: Optional[int] = None
411
+ height: Optional[int] = None
412
+ primary: Optional[bool] = None
413
+
414
+
415
+ class DisplayInfo(BaseModel):
416
+ width: Annotated[
417
+ Optional[int], Field(description='Display width in pixels', examples=[1920])
418
+ ] = None
419
+ height: Annotated[
420
+ Optional[int], Field(description='Display height in pixels', examples=[1080])
421
+ ] = None
422
+ depth: Annotated[
423
+ Optional[int], Field(description='Color depth (bits per pixel)', examples=[24])
424
+ ] = None
425
+ refresh_rate: Annotated[
426
+ Optional[int], Field(description='Refresh rate in Hz', examples=[60])
427
+ ] = None
428
+ displays: Annotated[
429
+ Optional[List[Display]],
430
+ Field(description='List of available displays (multi-monitor support)'),
431
+ ] = None
432
+
433
+
434
+ class Format2(Enum):
435
+ png = 'png'
436
+ jpeg = 'jpeg'
437
+
438
+
439
+ class ScreenshotResponse(BaseModel):
440
+ image: Annotated[Optional[str], Field(description='Base64-encoded image data')] = None
441
+ format: Annotated[Optional[Format2], Field(description='Image format')] = None
442
+ width: Annotated[Optional[int], Field(description='Image width in pixels')] = None
443
+ height: Annotated[Optional[int], Field(description='Image height in pixels')] = None
444
+ size: Annotated[Optional[int], Field(description='Image size in bytes')] = None
445
+ timestamp: Annotated[Optional[AwareDatetime], Field(description='Screenshot timestamp')] = None
446
+
447
+
448
+ class MetricsSnapshot(BaseModel):
449
+ uptime_seconds: Annotated[Optional[float], Field(description='Agent uptime in seconds')] = None
450
+ total_requests: Annotated[Optional[int], Field(description='Total HTTP requests handled')] = (
451
+ None
452
+ )
453
+ total_errors: Annotated[Optional[int], Field(description='Total errors encountered')] = None
454
+ active_executions: Annotated[
455
+ Optional[int], Field(description='Current active code executions')
456
+ ] = None
457
+ total_executions: Annotated[
458
+ Optional[int], Field(description='Total code executions completed')
459
+ ] = None
460
+
461
+
462
+ class Code(Enum):
463
+ METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED'
464
+ INVALID_JSON = 'INVALID_JSON'
465
+ MISSING_PARAMETER = 'MISSING_PARAMETER'
466
+ PATH_NOT_ALLOWED = 'PATH_NOT_ALLOWED'
467
+ FILE_NOT_FOUND = 'FILE_NOT_FOUND'
468
+ PERMISSION_DENIED = 'PERMISSION_DENIED'
469
+ COMMAND_FAILED = 'COMMAND_FAILED'
470
+ EXECUTION_TIMEOUT = 'EXECUTION_TIMEOUT'
471
+ EXECUTION_FAILED = 'EXECUTION_FAILED'
472
+ INTERNAL_ERROR = 'INTERNAL_ERROR'
473
+ INVALID_PATH = 'INVALID_PATH'
474
+ FILE_ALREADY_EXISTS = 'FILE_ALREADY_EXISTS'
475
+ DIRECTORY_NOT_FOUND = 'DIRECTORY_NOT_FOUND'
476
+ INVALID_REQUEST = 'INVALID_REQUEST'
477
+ PROCESS_NOT_FOUND = 'PROCESS_NOT_FOUND'
478
+ DESKTOP_NOT_AVAILABLE = 'DESKTOP_NOT_AVAILABLE'
479
+
480
+
481
+ class ErrorResponse(BaseModel):
482
+ error: Annotated[
483
+ str, Field(description='Human-readable error message', examples=['File not found'])
484
+ ]
485
+ code: Annotated[
486
+ Optional[Code],
487
+ Field(description='Machine-readable error code', examples=['FILE_NOT_FOUND']),
488
+ ] = None
489
+ request_id: Annotated[
490
+ Optional[str],
491
+ Field(
492
+ description='Request ID for tracing (from X-Request-ID header)',
493
+ examples=['550e8400-e29b-41d4-a716-446655440000'],
494
+ ),
495
+ ] = None
496
+ timestamp: AwareDatetime
497
+ path: Annotated[
498
+ Optional[str], Field(description='Related file path (for file operation errors)')
499
+ ] = None
500
+ details: Annotated[Optional[Dict[str, Any]], Field(description='Additional error context')] = (
501
+ None
502
+ )
hopx_ai/_utils.py ADDED
@@ -0,0 +1,9 @@
1
+ """Utility functions."""
2
+
3
+ from typing import Any, Dict
4
+
5
+
6
+ def remove_none_values(data: Dict[str, Any]) -> Dict[str, Any]:
7
+ """Remove None values from dictionary."""
8
+ return {k: v for k, v in data.items() if v is not None}
9
+
hopx_ai/_ws_client.py ADDED
@@ -0,0 +1,141 @@
1
+ """WebSocket client for real-time streaming."""
2
+
3
+ import json
4
+ import asyncio
5
+ import logging
6
+ from typing import Optional, Dict, Any, AsyncIterator, Callable
7
+ from urllib.parse import urlparse
8
+
9
+ try:
10
+ import websockets
11
+ from websockets.client import WebSocketClientProtocol
12
+ WEBSOCKETS_AVAILABLE = True
13
+ except ImportError:
14
+ WEBSOCKETS_AVAILABLE = False
15
+ WebSocketClientProtocol = Any # type: ignore
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class WebSocketClient:
21
+ """
22
+ WebSocket client for Agent API streaming.
23
+
24
+ Handles WebSocket connections with automatic reconnection,
25
+ message protocol, and async iteration.
26
+ """
27
+
28
+ def __init__(self, agent_url: str):
29
+ """
30
+ Initialize WebSocket client.
31
+
32
+ Args:
33
+ agent_url: Agent base URL (https://...)
34
+ """
35
+ if not WEBSOCKETS_AVAILABLE:
36
+ raise ImportError(
37
+ "websockets library is required for WebSocket features. "
38
+ "Install with: pip install websockets"
39
+ )
40
+
41
+ self.agent_url = agent_url.rstrip('/')
42
+ # Convert https:// to wss:// for WebSocket
43
+ parsed = urlparse(self.agent_url)
44
+ ws_scheme = 'wss' if parsed.scheme == 'https' else 'ws'
45
+ self.ws_base_url = f"{ws_scheme}://{parsed.netloc}"
46
+
47
+ logger.debug(f"WebSocket client initialized: {self.ws_base_url}")
48
+
49
+ async def connect(
50
+ self,
51
+ endpoint: str,
52
+ *,
53
+ timeout: Optional[int] = None
54
+ ) -> WebSocketClientProtocol:
55
+ """
56
+ Connect to WebSocket endpoint.
57
+
58
+ Args:
59
+ endpoint: WebSocket endpoint path (e.g., "/terminal")
60
+ timeout: Connection timeout in seconds
61
+
62
+ Returns:
63
+ WebSocket connection
64
+ """
65
+ url = f"{self.ws_base_url}{endpoint}"
66
+ logger.debug(f"Connecting to WebSocket: {url}")
67
+
68
+ try:
69
+ ws = await asyncio.wait_for(
70
+ websockets.connect(url),
71
+ timeout=timeout
72
+ )
73
+ logger.debug(f"WebSocket connected: {endpoint}")
74
+ return ws
75
+ except asyncio.TimeoutError:
76
+ raise TimeoutError(f"WebSocket connection timeout: {endpoint}")
77
+ except Exception as e:
78
+ logger.error(f"WebSocket connection failed: {e}")
79
+ raise
80
+
81
+ async def send_message(
82
+ self,
83
+ ws: WebSocketClientProtocol,
84
+ message: Dict[str, Any]
85
+ ) -> None:
86
+ """
87
+ Send JSON message over WebSocket.
88
+
89
+ Args:
90
+ ws: WebSocket connection
91
+ message: Message dictionary
92
+ """
93
+ await ws.send(json.dumps(message))
94
+ logger.debug(f"Sent WS message: {message.get('type', 'unknown')}")
95
+
96
+ async def receive_message(
97
+ self,
98
+ ws: WebSocketClientProtocol
99
+ ) -> Dict[str, Any]:
100
+ """
101
+ Receive and parse JSON message from WebSocket.
102
+
103
+ Args:
104
+ ws: WebSocket connection
105
+
106
+ Returns:
107
+ Parsed message dictionary
108
+ """
109
+ data = await ws.recv()
110
+ if isinstance(data, bytes):
111
+ data = data.decode('utf-8')
112
+ message = json.loads(data)
113
+ logger.debug(f"Received WS message: {message.get('type', 'unknown')}")
114
+ return message
115
+
116
+ async def iter_messages(
117
+ self,
118
+ ws: WebSocketClientProtocol
119
+ ) -> AsyncIterator[Dict[str, Any]]:
120
+ """
121
+ Iterate over incoming messages.
122
+
123
+ Args:
124
+ ws: WebSocket connection
125
+
126
+ Yields:
127
+ Parsed message dictionaries
128
+ """
129
+ try:
130
+ async for data in ws:
131
+ if isinstance(data, bytes):
132
+ data = data.decode('utf-8')
133
+ message = json.loads(data)
134
+ logger.debug(f"Yielding WS message: {message.get('type', 'unknown')}")
135
+ yield message
136
+ except websockets.exceptions.ConnectionClosed:
137
+ logger.debug("WebSocket connection closed")
138
+
139
+ def __repr__(self) -> str:
140
+ return f"<WebSocketClient url={self.ws_base_url}>"
141
+