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.

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