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

hopx_ai/__init__.py ADDED
@@ -0,0 +1,114 @@
1
+ """
2
+ Bunnyshell Python SDK
3
+
4
+ Official Python client for Bunnyshell Sandboxes.
5
+
6
+ Sync Example:
7
+ >>> from bunnyshell import Sandbox
8
+ >>>
9
+ >>> # Create sandbox
10
+ >>> sandbox = Sandbox.create(template="code-interpreter")
11
+ >>> print(sandbox.get_info().public_host)
12
+ >>>
13
+ >>> # Cleanup
14
+ >>> sandbox.kill()
15
+
16
+ Async Example:
17
+ >>> from bunnyshell import AsyncSandbox
18
+ >>>
19
+ >>> async with AsyncSandbox.create(template="nodejs") as sandbox:
20
+ ... info = await sandbox.get_info()
21
+ ... print(f"Running at: {info.public_host}")
22
+ # Automatically killed when exiting context
23
+ """
24
+
25
+ from .sandbox import Sandbox
26
+ from .async_sandbox import AsyncSandbox
27
+ from .models import (
28
+ SandboxInfo,
29
+ Template as SandboxTemplate,
30
+ TemplateResources,
31
+ ExecutionResult,
32
+ CommandResult,
33
+ FileInfo,
34
+ RichOutput,
35
+ # Desktop models
36
+ VNCInfo,
37
+ WindowInfo,
38
+ RecordingInfo,
39
+ DisplayInfo,
40
+ )
41
+ from .errors import (
42
+ HopxError,
43
+ APIError,
44
+ AuthenticationError,
45
+ NotFoundError,
46
+ RateLimitError,
47
+ ResourceLimitError,
48
+ ValidationError,
49
+ ServerError,
50
+ NetworkError,
51
+ TimeoutError,
52
+ # Agent operation errors
53
+ AgentError,
54
+ FileNotFoundError,
55
+ FileOperationError,
56
+ CodeExecutionError,
57
+ CommandExecutionError,
58
+ DesktopNotAvailableError,
59
+ )
60
+ # Template Building
61
+ from .template import (
62
+ Template,
63
+ create_template,
64
+ wait_for_port,
65
+ wait_for_url,
66
+ wait_for_file,
67
+ wait_for_process,
68
+ wait_for_command,
69
+ )
70
+
71
+ __version__ = "0.1.8"
72
+ __all__ = [
73
+ "Sandbox",
74
+ "AsyncSandbox",
75
+ "SandboxInfo",
76
+ "SandboxTemplate",
77
+ "TemplateResources",
78
+ "ExecutionResult",
79
+ "CommandResult",
80
+ "FileInfo",
81
+ "RichOutput",
82
+ # Desktop models
83
+ "VNCInfo",
84
+ "WindowInfo",
85
+ "RecordingInfo",
86
+ "DisplayInfo",
87
+ # Errors
88
+ "HopxError",
89
+ "APIError",
90
+ "AuthenticationError",
91
+ "NotFoundError",
92
+ "RateLimitError",
93
+ "ResourceLimitError",
94
+ "ValidationError",
95
+ "ServerError",
96
+ "NetworkError",
97
+ "TimeoutError",
98
+ # Agent operation errors
99
+ "AgentError",
100
+ "FileNotFoundError",
101
+ "FileOperationError",
102
+ "CodeExecutionError",
103
+ "CommandExecutionError",
104
+ "DesktopNotAvailableError",
105
+ # Template Building
106
+ "Template",
107
+ "create_template",
108
+ "wait_for_port",
109
+ "wait_for_url",
110
+ "wait_for_file",
111
+ "wait_for_process",
112
+ "wait_for_command",
113
+ ]
114
+
@@ -0,0 +1,391 @@
1
+ """HTTP client for agent operations with retry logic."""
2
+
3
+ import time
4
+ import logging
5
+ from typing import Optional, Dict, Any
6
+ import httpx
7
+ from .errors import (
8
+ FileNotFoundError,
9
+ FileOperationError,
10
+ CodeExecutionError,
11
+ CommandExecutionError,
12
+ DesktopNotAvailableError,
13
+ AgentError,
14
+ NetworkError,
15
+ TimeoutError as BunnyshellTimeoutError,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class AgentHTTPClient:
22
+ """
23
+ HTTP client for agent operations with retry logic and error handling.
24
+
25
+ Features:
26
+ - Connection pooling (reuses TCP connections)
27
+ - Automatic retries with exponential backoff
28
+ - Proper error wrapping to Bunnyshell exceptions
29
+ - Configurable timeouts
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ agent_url: str,
35
+ *,
36
+ jwt_token: Optional[str] = None,
37
+ timeout: int = 30,
38
+ max_retries: int = 3,
39
+ token_refresh_callback: Optional[callable] = None,
40
+ ):
41
+ """
42
+ Initialize agent HTTP client.
43
+
44
+ Args:
45
+ agent_url: Agent base URL (e.g., https://7777-{id}.domain)
46
+ jwt_token: JWT token for agent authentication
47
+ timeout: Default timeout in seconds
48
+ max_retries: Maximum retry attempts
49
+ token_refresh_callback: Callback to refresh token on 401 (returns new token)
50
+ """
51
+ self._agent_url = agent_url.rstrip('/')
52
+ self._jwt_token = jwt_token
53
+ self._timeout = timeout
54
+ self._max_retries = max_retries
55
+ self._token_refresh_callback = token_refresh_callback
56
+
57
+ # Build headers
58
+ headers = {}
59
+ if self._jwt_token:
60
+ headers['Authorization'] = f'Bearer {self._jwt_token}'
61
+
62
+ # Create reusable HTTP client with connection pooling
63
+ # Force IPv4 to avoid IPv6 timeout issues (270s delay)
64
+ self._client = httpx.Client(
65
+ timeout=httpx.Timeout(timeout),
66
+ limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
67
+ headers=headers,
68
+ transport=httpx.HTTPTransport(
69
+ local_address="0.0.0.0", # Force IPv4
70
+ )
71
+ )
72
+
73
+ logger.debug(f"Agent client initialized: {self._agent_url}")
74
+
75
+ def update_jwt_token(self, token: str) -> None:
76
+ """
77
+ Update JWT token for agent authentication.
78
+ Used internally when token is refreshed.
79
+ """
80
+ self._jwt_token = token
81
+
82
+ # Update client headers
83
+ self._client.headers['Authorization'] = f'Bearer {token}'
84
+
85
+ def _should_retry(self, status_code: int) -> bool:
86
+ """Check if request should be retried based on status code."""
87
+ return status_code in {429, 500, 502, 503, 504}
88
+
89
+ def _get_retry_delay(self, attempt: int) -> float:
90
+ """Calculate retry delay with exponential backoff."""
91
+ return min(2 ** attempt, 10) # Max 10 seconds
92
+
93
+ def _wrap_error(
94
+ self,
95
+ error: Exception,
96
+ operation: str,
97
+ context: Optional[Dict[str, Any]] = None
98
+ ) -> AgentError:
99
+ """
100
+ Wrap httpx errors into Bunnyshell exceptions.
101
+
102
+ Uses Agent v3.1.1+ error codes for precise exception mapping:
103
+ - FILE_NOT_FOUND -> FileNotFoundError
104
+ - PATH_NOT_ALLOWED -> FileOperationError
105
+ - EXECUTION_FAILED/TIMEOUT -> CodeExecutionError
106
+ - COMMAND_FAILED -> CommandExecutionError
107
+ - DESKTOP_NOT_AVAILABLE -> DesktopNotAvailableError
108
+
109
+ Falls back to HTTP status code + context for older agents.
110
+ """
111
+ context = context or {}
112
+
113
+ if isinstance(error, httpx.HTTPStatusError):
114
+ status_code = error.response.status_code
115
+ # Extract request ID from response headers (case-insensitive)
116
+ request_id = (
117
+ error.response.headers.get("X-Request-ID") or
118
+ error.response.headers.get("x-request-id") or
119
+ error.response.headers.get("X-Request-Id")
120
+ )
121
+
122
+ # Try to get error message, code, and details from response (Agent v3.1.1+)
123
+ error_code = None
124
+ error_details = {}
125
+ try:
126
+ error_data = error.response.json()
127
+ message = error_data.get("error", error_data.get("message", str(error)))
128
+ error_code = error_data.get("code") # Machine-readable error code (v3.1.1+)
129
+ error_details = error_data.get("details", {})
130
+ except:
131
+ message = f"HTTP {status_code}: {error.response.text[:100]}"
132
+
133
+ # Map error codes to specific exceptions (Agent v3.1.1+)
134
+ # Note: Agent returns UPPERCASE error codes (e.g., "FILE_NOT_FOUND")
135
+ if error_code:
136
+ # File-related errors
137
+ if error_code == "FILE_NOT_FOUND":
138
+ return FileNotFoundError(
139
+ message=message,
140
+ path=error_details.get("path") or context.get("path"),
141
+ request_id=request_id,
142
+ code=error_code
143
+ )
144
+
145
+ if error_code == "PATH_NOT_ALLOWED":
146
+ return FileOperationError(
147
+ message=message,
148
+ operation=operation,
149
+ request_id=request_id,
150
+ status_code=status_code,
151
+ code=error_code
152
+ )
153
+
154
+ if error_code in ("DIRECTORY_NOT_FOUND", "INVALID_PATH", "FILE_ALREADY_EXISTS"):
155
+ return FileOperationError(
156
+ message=message,
157
+ operation=operation,
158
+ request_id=request_id,
159
+ status_code=status_code,
160
+ code=error_code
161
+ )
162
+
163
+ # Execution errors
164
+ if error_code in ("EXECUTION_FAILED", "EXECUTION_TIMEOUT"):
165
+ return CodeExecutionError(
166
+ message=message,
167
+ language=context.get("language"),
168
+ request_id=request_id,
169
+ status_code=status_code,
170
+ code=error_code
171
+ )
172
+
173
+ if error_code == "COMMAND_FAILED":
174
+ return CommandExecutionError(
175
+ message=message,
176
+ command=context.get("command"),
177
+ request_id=request_id,
178
+ status_code=status_code,
179
+ code=error_code
180
+ )
181
+
182
+ # Desktop errors
183
+ if error_code == "DESKTOP_NOT_AVAILABLE":
184
+ missing_deps = error_details.get("missing_dependencies", [])
185
+ return DesktopNotAvailableError(
186
+ message=message,
187
+ missing_dependencies=missing_deps,
188
+ request_id=request_id,
189
+ status_code=status_code,
190
+ code=error_code
191
+ )
192
+
193
+ # Generic errors with code
194
+ return AgentError(
195
+ message=message,
196
+ code=error_code,
197
+ request_id=request_id,
198
+ status_code=status_code
199
+ )
200
+
201
+ # Fallback: Map by status code + context (for older agents without error codes)
202
+ if status_code in (403, 404):
203
+ if "file" in operation.lower() or "read" in operation.lower() or "download" in operation.lower():
204
+ # 403/404 for files usually means not found
205
+ return FileNotFoundError(
206
+ message=message,
207
+ path=context.get("path"),
208
+ request_id=request_id
209
+ )
210
+
211
+ # File operation errors
212
+ if "file" in operation.lower():
213
+ return FileOperationError(
214
+ message=message,
215
+ operation=operation,
216
+ request_id=request_id,
217
+ status_code=status_code
218
+ )
219
+
220
+ # Code execution errors
221
+ if "code" in operation.lower() or "execute" in operation.lower():
222
+ return CodeExecutionError(
223
+ message=message,
224
+ language=context.get("language"),
225
+ request_id=request_id,
226
+ status_code=status_code
227
+ )
228
+
229
+ # Command execution errors
230
+ if "command" in operation.lower():
231
+ return CommandExecutionError(
232
+ message=message,
233
+ command=context.get("command"),
234
+ request_id=request_id,
235
+ status_code=status_code
236
+ )
237
+
238
+ # Generic agent error
239
+ return AgentError(
240
+ message=message,
241
+ code=f"HTTP_{status_code}",
242
+ request_id=request_id,
243
+ status_code=status_code
244
+ )
245
+
246
+ elif isinstance(error, httpx.TimeoutException):
247
+ return BunnyshellTimeoutError(
248
+ f"{operation} timed out after {self._timeout}s"
249
+ )
250
+
251
+ elif isinstance(error, httpx.NetworkError):
252
+ return NetworkError(f"{operation} failed: {error}")
253
+
254
+ else:
255
+ return AgentError(f"{operation} failed: {error}")
256
+
257
+ def _request(
258
+ self,
259
+ method: str,
260
+ endpoint: str,
261
+ *,
262
+ operation: str,
263
+ context: Optional[Dict[str, Any]] = None,
264
+ timeout: Optional[int] = None,
265
+ **kwargs
266
+ ) -> httpx.Response:
267
+ """
268
+ Make HTTP request with retry logic.
269
+
270
+ Args:
271
+ method: HTTP method (GET, POST, etc.)
272
+ endpoint: API endpoint (e.g., /files/read)
273
+ operation: Operation name for error messages
274
+ context: Additional context for error handling
275
+ timeout: Request timeout (overrides default)
276
+ **kwargs: Additional arguments for httpx request
277
+
278
+ Returns:
279
+ HTTP response
280
+
281
+ Raises:
282
+ AgentError: On request failure
283
+ """
284
+ url = f"{self._agent_url}{endpoint}"
285
+ timeout_val = timeout or self._timeout
286
+
287
+ for attempt in range(self._max_retries):
288
+ try:
289
+ logger.debug(f"{method} {url} (attempt {attempt + 1}/{self._max_retries})")
290
+
291
+ response = self._client.request(
292
+ method=method,
293
+ url=url,
294
+ timeout=timeout_val,
295
+ **kwargs
296
+ )
297
+
298
+ # Raise for 4xx and 5xx status codes
299
+ response.raise_for_status()
300
+
301
+ return response
302
+
303
+ except httpx.HTTPStatusError as e:
304
+ # Handle 401 Unauthorized - try to refresh token
305
+ if e.response.status_code == 401 and self._token_refresh_callback and attempt == 0:
306
+ logger.info(f"{operation} got 401 Unauthorized, attempting token refresh...")
307
+ try:
308
+ # Call refresh callback to get new token
309
+ new_token = self._token_refresh_callback()
310
+ if new_token:
311
+ # Update token and retry request
312
+ self.update_jwt_token(new_token)
313
+ logger.info(f"{operation} token refreshed, retrying request...")
314
+ continue
315
+ except Exception as refresh_error:
316
+ logger.error(f"Token refresh failed: {refresh_error}")
317
+ # Fall through to raise original error
318
+
319
+ # Check if should retry
320
+ if attempt < self._max_retries - 1 and self._should_retry(e.response.status_code):
321
+ delay = self._get_retry_delay(attempt)
322
+ logger.warning(
323
+ f"{operation} failed with {e.response.status_code}, "
324
+ f"retrying in {delay}s (attempt {attempt + 1}/{self._max_retries})"
325
+ )
326
+ time.sleep(delay)
327
+ continue
328
+
329
+ # No more retries, raise wrapped error
330
+ raise self._wrap_error(e, operation, context)
331
+
332
+ except (httpx.TimeoutException, httpx.NetworkError) as e:
333
+ # Retry on timeout/network errors
334
+ if attempt < self._max_retries - 1:
335
+ delay = self._get_retry_delay(attempt)
336
+ logger.warning(
337
+ f"{operation} failed: {e}, "
338
+ f"retrying in {delay}s (attempt {attempt + 1}/{self._max_retries})"
339
+ )
340
+ time.sleep(delay)
341
+ continue
342
+
343
+ # No more retries
344
+ raise self._wrap_error(e, operation, context)
345
+
346
+ except Exception as e:
347
+ # Unexpected error, don't retry
348
+ raise self._wrap_error(e, operation, context)
349
+
350
+ # Should never reach here
351
+ raise AgentError(f"{operation} failed after {self._max_retries} attempts")
352
+
353
+ def get(self, endpoint: str, *, operation: str, context: Optional[Dict[str, Any]] = None, **kwargs) -> httpx.Response:
354
+ """Make GET request."""
355
+ return self._request("GET", endpoint, operation=operation, context=context, **kwargs)
356
+
357
+ def post(self, endpoint: str, *, operation: str, context: Optional[Dict[str, Any]] = None, **kwargs) -> httpx.Response:
358
+ """Make POST request."""
359
+ return self._request("POST", endpoint, operation=operation, context=context, **kwargs)
360
+
361
+ def put(self, endpoint: str, *, operation: str, context: Optional[Dict[str, Any]] = None, **kwargs) -> httpx.Response:
362
+ """Make PUT request."""
363
+ return self._request("PUT", endpoint, operation=operation, context=context, **kwargs)
364
+
365
+ def patch(self, endpoint: str, *, operation: str, context: Optional[Dict[str, Any]] = None, **kwargs) -> httpx.Response:
366
+ """Make PATCH request."""
367
+ return self._request("PATCH", endpoint, operation=operation, context=context, **kwargs)
368
+
369
+ def delete(self, endpoint: str, *, operation: str, context: Optional[Dict[str, Any]] = None, **kwargs) -> httpx.Response:
370
+ """Make DELETE request."""
371
+ return self._request("DELETE", endpoint, operation=operation, context=context, **kwargs)
372
+
373
+ def close(self):
374
+ """Close HTTP client and release connections."""
375
+ self._client.close()
376
+
377
+ def __del__(self):
378
+ """Cleanup on deletion."""
379
+ try:
380
+ self.close()
381
+ except:
382
+ pass
383
+
384
+ def __enter__(self):
385
+ """Context manager entry."""
386
+ return self
387
+
388
+ def __exit__(self, *args):
389
+ """Context manager exit."""
390
+ self.close()
391
+