hopx-ai 0.1.15__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 +114 -0
- hopx_ai/_agent_client.py +391 -0
- hopx_ai/_async_agent_client.py +223 -0
- hopx_ai/_async_cache.py +38 -0
- hopx_ai/_async_client.py +230 -0
- hopx_ai/_async_commands.py +58 -0
- hopx_ai/_async_env_vars.py +151 -0
- hopx_ai/_async_files.py +81 -0
- hopx_ai/_async_files_clean.py +489 -0
- hopx_ai/_async_terminal.py +184 -0
- hopx_ai/_client.py +230 -0
- hopx_ai/_generated/__init__.py +22 -0
- hopx_ai/_generated/models.py +502 -0
- hopx_ai/_temp_async_token.py +14 -0
- hopx_ai/_test_env_fix.py +30 -0
- hopx_ai/_utils.py +9 -0
- hopx_ai/_ws_client.py +141 -0
- hopx_ai/async_sandbox.py +763 -0
- hopx_ai/cache.py +97 -0
- hopx_ai/commands.py +174 -0
- hopx_ai/desktop.py +1227 -0
- hopx_ai/env_vars.py +244 -0
- hopx_ai/errors.py +249 -0
- hopx_ai/files.py +489 -0
- hopx_ai/models.py +274 -0
- hopx_ai/models_updated.py +270 -0
- hopx_ai/sandbox.py +1447 -0
- hopx_ai/template/__init__.py +47 -0
- hopx_ai/template/build_flow.py +540 -0
- hopx_ai/template/builder.py +300 -0
- hopx_ai/template/file_hasher.py +81 -0
- hopx_ai/template/ready_checks.py +106 -0
- hopx_ai/template/tar_creator.py +122 -0
- hopx_ai/template/types.py +199 -0
- hopx_ai/terminal.py +164 -0
- hopx_ai-0.1.15.dist-info/METADATA +462 -0
- hopx_ai-0.1.15.dist-info/RECORD +38 -0
- hopx_ai-0.1.15.dist-info/WHEEL +4 -0
hopx_ai/async_sandbox.py
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
"""Async Sandbox class - for async/await usage."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional, List, AsyncIterator, Dict
|
|
4
|
+
from typing import Any
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from .models import SandboxInfo, Template
|
|
8
|
+
from ._async_client import AsyncHTTPClient
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class TokenData:
|
|
15
|
+
"""JWT token data."""
|
|
16
|
+
token: str
|
|
17
|
+
expires_at: datetime
|
|
18
|
+
|
|
19
|
+
# Global token cache (shared between AsyncSandbox instances)
|
|
20
|
+
_token_cache: Dict[str, TokenData] = {}
|
|
21
|
+
|
|
22
|
+
from ._utils import remove_none_values
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncSandbox:
|
|
26
|
+
"""
|
|
27
|
+
Async Bunnyshell Sandbox - lightweight VM management with async/await.
|
|
28
|
+
|
|
29
|
+
For async Python applications (FastAPI, aiohttp, etc.)
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
>>> from bunnyshell import AsyncSandbox
|
|
33
|
+
>>>
|
|
34
|
+
>>> async with AsyncSandbox.create(template="nodejs") as sandbox:
|
|
35
|
+
... info = await sandbox.get_info()
|
|
36
|
+
... print(info.public_host)
|
|
37
|
+
# Automatically cleaned up!
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
sandbox_id: str,
|
|
43
|
+
*,
|
|
44
|
+
api_key: Optional[str] = None,
|
|
45
|
+
base_url: str = "https://api.hopx.dev",
|
|
46
|
+
timeout: int = 60,
|
|
47
|
+
max_retries: int = 3,
|
|
48
|
+
):
|
|
49
|
+
"""
|
|
50
|
+
Initialize AsyncSandbox instance.
|
|
51
|
+
|
|
52
|
+
Note: Prefer using AsyncSandbox.create() or AsyncSandbox.connect() instead.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
sandbox_id: Sandbox ID
|
|
56
|
+
api_key: API key (or use HOPX_API_KEY env var)
|
|
57
|
+
base_url: API base URL
|
|
58
|
+
timeout: Request timeout in seconds
|
|
59
|
+
max_retries: Maximum number of retries
|
|
60
|
+
"""
|
|
61
|
+
self.sandbox_id = sandbox_id
|
|
62
|
+
self._client = AsyncHTTPClient(
|
|
63
|
+
api_key=api_key,
|
|
64
|
+
base_url=base_url,
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
max_retries=max_retries,
|
|
67
|
+
)
|
|
68
|
+
self._agent_client = None
|
|
69
|
+
self._jwt_token = None
|
|
70
|
+
|
|
71
|
+
# =============================================================================
|
|
72
|
+
# CLASS METHODS (Static - for creating/listing sandboxes)
|
|
73
|
+
# =============================================================================
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
async def create(
|
|
77
|
+
cls,
|
|
78
|
+
template: Optional[str] = None,
|
|
79
|
+
*,
|
|
80
|
+
template_id: Optional[str] = None,
|
|
81
|
+
region: Optional[str] = None,
|
|
82
|
+
timeout_seconds: Optional[int] = None,
|
|
83
|
+
internet_access: Optional[bool] = None,
|
|
84
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
85
|
+
api_key: Optional[str] = None,
|
|
86
|
+
base_url: str = "https://api.hopx.dev",
|
|
87
|
+
) -> "AsyncSandbox":
|
|
88
|
+
"""
|
|
89
|
+
Create a new sandbox (async).
|
|
90
|
+
|
|
91
|
+
You can create a sandbox in two ways:
|
|
92
|
+
1. From template ID (resources auto-loaded from template)
|
|
93
|
+
2. Custom sandbox (specify template name + resources)
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
template: Template name for custom sandbox (e.g., "code-interpreter", "nodejs")
|
|
97
|
+
template_id: Template ID to create from (resources auto-loaded, no vcpu/memory needed)
|
|
98
|
+
region: Preferred region (optional)
|
|
99
|
+
timeout_seconds: Auto-kill timeout in seconds (optional, default: no timeout)
|
|
100
|
+
internet_access: Enable internet access (optional, default: True)
|
|
101
|
+
env_vars: Environment variables (optional)
|
|
102
|
+
api_key: API key (or use HOPX_API_KEY env var)
|
|
103
|
+
base_url: API base URL
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
AsyncSandbox instance
|
|
107
|
+
|
|
108
|
+
Examples:
|
|
109
|
+
>>> # Create from template ID with timeout
|
|
110
|
+
>>> sandbox = await AsyncSandbox.create(
|
|
111
|
+
... template_id="282",
|
|
112
|
+
... timeout_seconds=600,
|
|
113
|
+
... internet_access=False
|
|
114
|
+
... )
|
|
115
|
+
|
|
116
|
+
>>> # Create custom sandbox
|
|
117
|
+
>>> sandbox = await AsyncSandbox.create(
|
|
118
|
+
... template="nodejs",
|
|
119
|
+
... timeout_seconds=300
|
|
120
|
+
... )
|
|
121
|
+
"""
|
|
122
|
+
client = AsyncHTTPClient(api_key=api_key, base_url=base_url)
|
|
123
|
+
|
|
124
|
+
# Validate parameters
|
|
125
|
+
if template_id:
|
|
126
|
+
# Create from template ID (resources from template)
|
|
127
|
+
# Convert template_id to string if it's an int (API may return int from build)
|
|
128
|
+
data = remove_none_values({
|
|
129
|
+
"template_id": str(template_id),
|
|
130
|
+
"region": region,
|
|
131
|
+
"timeout_seconds": timeout_seconds,
|
|
132
|
+
"internet_access": internet_access,
|
|
133
|
+
"env_vars": env_vars,
|
|
134
|
+
})
|
|
135
|
+
elif template:
|
|
136
|
+
# Create from template name (resources from template)
|
|
137
|
+
data = remove_none_values({
|
|
138
|
+
"template_name": template,
|
|
139
|
+
"region": region,
|
|
140
|
+
"timeout_seconds": timeout_seconds,
|
|
141
|
+
"internet_access": internet_access,
|
|
142
|
+
"env_vars": env_vars,
|
|
143
|
+
})
|
|
144
|
+
else:
|
|
145
|
+
raise ValueError("Either 'template' or 'template_id' must be provided")
|
|
146
|
+
|
|
147
|
+
response = await client.post("/v1/sandboxes", json=data)
|
|
148
|
+
sandbox_id = response["id"]
|
|
149
|
+
|
|
150
|
+
return cls(
|
|
151
|
+
sandbox_id=sandbox_id,
|
|
152
|
+
api_key=api_key,
|
|
153
|
+
base_url=base_url,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
async def connect(
|
|
158
|
+
cls,
|
|
159
|
+
sandbox_id: str,
|
|
160
|
+
*,
|
|
161
|
+
api_key: Optional[str] = None,
|
|
162
|
+
base_url: str = "https://api.hopx.dev",
|
|
163
|
+
) -> "AsyncSandbox":
|
|
164
|
+
"""
|
|
165
|
+
Connect to an existing sandbox (async).
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
sandbox_id: Sandbox ID
|
|
169
|
+
api_key: API key (or use HOPX_API_KEY env var)
|
|
170
|
+
base_url: API base URL
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
AsyncSandbox instance
|
|
174
|
+
|
|
175
|
+
Example:
|
|
176
|
+
>>> sandbox = await AsyncSandbox.connect("sandbox_id")
|
|
177
|
+
>>> info = await sandbox.get_info()
|
|
178
|
+
"""
|
|
179
|
+
instance = cls(
|
|
180
|
+
sandbox_id=sandbox_id,
|
|
181
|
+
api_key=api_key,
|
|
182
|
+
base_url=base_url,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Verify it exists
|
|
186
|
+
await instance.get_info()
|
|
187
|
+
|
|
188
|
+
return instance
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
async def list(
|
|
192
|
+
cls,
|
|
193
|
+
*,
|
|
194
|
+
status: Optional[str] = None,
|
|
195
|
+
region: Optional[str] = None,
|
|
196
|
+
limit: int = 100,
|
|
197
|
+
api_key: Optional[str] = None,
|
|
198
|
+
base_url: str = "https://api.hopx.dev",
|
|
199
|
+
) -> List["AsyncSandbox"]:
|
|
200
|
+
"""
|
|
201
|
+
List all sandboxes (async).
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
status: Filter by status
|
|
205
|
+
region: Filter by region
|
|
206
|
+
limit: Maximum number of results
|
|
207
|
+
api_key: API key
|
|
208
|
+
base_url: API base URL
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
List of AsyncSandbox instances
|
|
212
|
+
|
|
213
|
+
Example:
|
|
214
|
+
>>> sandboxes = await AsyncSandbox.list(status="running")
|
|
215
|
+
>>> for sb in sandboxes:
|
|
216
|
+
... info = await sb.get_info()
|
|
217
|
+
... print(info.public_host)
|
|
218
|
+
"""
|
|
219
|
+
client = AsyncHTTPClient(api_key=api_key, base_url=base_url)
|
|
220
|
+
|
|
221
|
+
params = remove_none_values({
|
|
222
|
+
"status": status,
|
|
223
|
+
"region": region,
|
|
224
|
+
"limit": limit,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
response = await client.get("/v1/sandboxes", params=params)
|
|
228
|
+
sandboxes_data = response.get("data", [])
|
|
229
|
+
|
|
230
|
+
return [
|
|
231
|
+
cls(
|
|
232
|
+
sandbox_id=sb["id"],
|
|
233
|
+
api_key=api_key,
|
|
234
|
+
base_url=base_url,
|
|
235
|
+
)
|
|
236
|
+
for sb in sandboxes_data
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
@classmethod
|
|
240
|
+
async def iter(
|
|
241
|
+
cls,
|
|
242
|
+
*,
|
|
243
|
+
status: Optional[str] = None,
|
|
244
|
+
region: Optional[str] = None,
|
|
245
|
+
api_key: Optional[str] = None,
|
|
246
|
+
base_url: str = "https://api.hopx.dev",
|
|
247
|
+
) -> AsyncIterator["AsyncSandbox"]:
|
|
248
|
+
"""
|
|
249
|
+
Lazy async iterator for sandboxes.
|
|
250
|
+
|
|
251
|
+
Yields sandboxes one by one, fetching pages as needed.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
status: Filter by status
|
|
255
|
+
region: Filter by region
|
|
256
|
+
api_key: API key
|
|
257
|
+
base_url: API base URL
|
|
258
|
+
|
|
259
|
+
Yields:
|
|
260
|
+
AsyncSandbox instances
|
|
261
|
+
|
|
262
|
+
Example:
|
|
263
|
+
>>> async for sandbox in AsyncSandbox.iter(status="running"):
|
|
264
|
+
... info = await sandbox.get_info()
|
|
265
|
+
... print(info.public_host)
|
|
266
|
+
... if found:
|
|
267
|
+
... break # Doesn't fetch remaining pages
|
|
268
|
+
"""
|
|
269
|
+
client = AsyncHTTPClient(api_key=api_key, base_url=base_url)
|
|
270
|
+
limit = 100
|
|
271
|
+
has_more = True
|
|
272
|
+
cursor = None
|
|
273
|
+
|
|
274
|
+
while has_more:
|
|
275
|
+
params = {"limit": limit}
|
|
276
|
+
if status:
|
|
277
|
+
params["status"] = status
|
|
278
|
+
if region:
|
|
279
|
+
params["region"] = region
|
|
280
|
+
if cursor:
|
|
281
|
+
params["cursor"] = cursor
|
|
282
|
+
|
|
283
|
+
response = await client.get("/v1/sandboxes", params=params)
|
|
284
|
+
|
|
285
|
+
for item in response.get("data", []):
|
|
286
|
+
yield cls(
|
|
287
|
+
sandbox_id=item["id"],
|
|
288
|
+
api_key=api_key,
|
|
289
|
+
base_url=base_url,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
has_more = response.get("has_more", False)
|
|
293
|
+
cursor = response.get("next_cursor")
|
|
294
|
+
|
|
295
|
+
@classmethod
|
|
296
|
+
async def list_templates(
|
|
297
|
+
cls,
|
|
298
|
+
*,
|
|
299
|
+
category: Optional[str] = None,
|
|
300
|
+
language: Optional[str] = None,
|
|
301
|
+
api_key: Optional[str] = None,
|
|
302
|
+
base_url: str = "https://api.hopx.dev",
|
|
303
|
+
) -> List[Template]:
|
|
304
|
+
"""
|
|
305
|
+
List available templates (async).
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
category: Filter by category
|
|
309
|
+
language: Filter by language
|
|
310
|
+
api_key: API key
|
|
311
|
+
base_url: API base URL
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
List of Template objects
|
|
315
|
+
|
|
316
|
+
Example:
|
|
317
|
+
>>> templates = await AsyncSandbox.list_templates()
|
|
318
|
+
>>> for t in templates:
|
|
319
|
+
... print(f"{t.name}: {t.display_name}")
|
|
320
|
+
"""
|
|
321
|
+
client = AsyncHTTPClient(api_key=api_key, base_url=base_url)
|
|
322
|
+
|
|
323
|
+
params = remove_none_values({
|
|
324
|
+
"category": category,
|
|
325
|
+
"language": language,
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
response = await client.get("/v1/templates", params=params)
|
|
329
|
+
templates_data = response.get("data", [])
|
|
330
|
+
|
|
331
|
+
return [Template(**t) for t in templates_data]
|
|
332
|
+
|
|
333
|
+
@classmethod
|
|
334
|
+
async def get_template(
|
|
335
|
+
cls,
|
|
336
|
+
name: str,
|
|
337
|
+
*,
|
|
338
|
+
api_key: Optional[str] = None,
|
|
339
|
+
base_url: str = "https://api.hopx.dev",
|
|
340
|
+
) -> Template:
|
|
341
|
+
"""
|
|
342
|
+
Get template details (async).
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
name: Template name
|
|
346
|
+
api_key: API key
|
|
347
|
+
base_url: API base URL
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
Template object
|
|
351
|
+
|
|
352
|
+
Example:
|
|
353
|
+
>>> template = await AsyncSandbox.get_template("nodejs")
|
|
354
|
+
>>> print(template.description)
|
|
355
|
+
"""
|
|
356
|
+
client = AsyncHTTPClient(api_key=api_key, base_url=base_url)
|
|
357
|
+
response = await client.get(f"/v1/templates/{name}")
|
|
358
|
+
return Template(**response)
|
|
359
|
+
|
|
360
|
+
# =============================================================================
|
|
361
|
+
# INSTANCE METHODS (for managing individual sandbox)
|
|
362
|
+
# =============================================================================
|
|
363
|
+
|
|
364
|
+
async def get_info(self) -> SandboxInfo:
|
|
365
|
+
"""
|
|
366
|
+
Get current sandbox information (async).
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
SandboxInfo with current state
|
|
370
|
+
|
|
371
|
+
Example:
|
|
372
|
+
>>> info = await sandbox.get_info()
|
|
373
|
+
>>> print(f"Status: {info.status}")
|
|
374
|
+
"""
|
|
375
|
+
response = await self._client.get(f"/v1/sandboxes/{self.sandbox_id}")
|
|
376
|
+
return SandboxInfo(
|
|
377
|
+
sandbox_id=response["id"],
|
|
378
|
+
template_id=response.get("template_id"),
|
|
379
|
+
template_name=response.get("template_name"),
|
|
380
|
+
organization_id=response.get("organization_id", ""),
|
|
381
|
+
node_id=response.get("node_id"),
|
|
382
|
+
region=response.get("region"),
|
|
383
|
+
status=response["status"],
|
|
384
|
+
public_host=response.get("public_host") or response.get("direct_url", ""),
|
|
385
|
+
vcpu=response.get("resources", {}).get("vcpu"),
|
|
386
|
+
memory_mb=response.get("resources", {}).get("memory_mb"),
|
|
387
|
+
disk_mb=response.get("resources", {}).get("disk_mb"),
|
|
388
|
+
created_at=response.get("created_at"),
|
|
389
|
+
started_at=None,
|
|
390
|
+
end_at=None,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
async def stop(self) -> None:
|
|
394
|
+
"""Stop the sandbox (async)."""
|
|
395
|
+
await self._client.post(f"/v1/sandboxes/{self.sandbox_id}/stop")
|
|
396
|
+
|
|
397
|
+
async def start(self) -> None:
|
|
398
|
+
"""Start a stopped sandbox (async)."""
|
|
399
|
+
await self._client.post(f"/v1/sandboxes/{self.sandbox_id}/start")
|
|
400
|
+
|
|
401
|
+
async def pause(self) -> None:
|
|
402
|
+
"""Pause the sandbox (async)."""
|
|
403
|
+
await self._client.post(f"/v1/sandboxes/{self.sandbox_id}/pause")
|
|
404
|
+
|
|
405
|
+
async def resume(self) -> None:
|
|
406
|
+
"""Resume a paused sandbox (async)."""
|
|
407
|
+
await self._client.post(f"/v1/sandboxes/{self.sandbox_id}/resume")
|
|
408
|
+
|
|
409
|
+
async def kill(self) -> None:
|
|
410
|
+
"""
|
|
411
|
+
Destroy the sandbox immediately (async).
|
|
412
|
+
|
|
413
|
+
This action is irreversible.
|
|
414
|
+
|
|
415
|
+
Example:
|
|
416
|
+
>>> await sandbox.kill()
|
|
417
|
+
"""
|
|
418
|
+
await self._client.delete(f"/v1/sandboxes/{self.sandbox_id}")
|
|
419
|
+
|
|
420
|
+
# =============================================================================
|
|
421
|
+
# ASYNC CONTEXT MANAGER (auto-cleanup)
|
|
422
|
+
# =============================================================================
|
|
423
|
+
|
|
424
|
+
async def __aenter__(self) -> "AsyncSandbox":
|
|
425
|
+
"""Async context manager entry."""
|
|
426
|
+
return self
|
|
427
|
+
|
|
428
|
+
async def __aexit__(self, *args) -> None:
|
|
429
|
+
"""Async context manager exit - auto cleanup."""
|
|
430
|
+
try:
|
|
431
|
+
await self.kill()
|
|
432
|
+
except Exception:
|
|
433
|
+
# Ignore errors on cleanup
|
|
434
|
+
pass
|
|
435
|
+
|
|
436
|
+
# =============================================================================
|
|
437
|
+
# UTILITY METHODS
|
|
438
|
+
# =============================================================================
|
|
439
|
+
|
|
440
|
+
def __repr__(self) -> str:
|
|
441
|
+
return f"<AsyncSandbox {self.sandbox_id}>"
|
|
442
|
+
|
|
443
|
+
def __str__(self) -> str:
|
|
444
|
+
return f"AsyncSandbox(id={self.sandbox_id})"
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# =============================================================================
|
|
448
|
+
# AGENT OPERATIONS (Code Execution)
|
|
449
|
+
# =============================================================================
|
|
450
|
+
|
|
451
|
+
async def _ensure_valid_token(self) -> None:
|
|
452
|
+
"""Ensure JWT token is valid and refresh if needed."""
|
|
453
|
+
token_data = _token_cache.get(self.sandbox_id)
|
|
454
|
+
|
|
455
|
+
if token_data is None:
|
|
456
|
+
# Get initial token
|
|
457
|
+
await self.refresh_token()
|
|
458
|
+
else:
|
|
459
|
+
# Check if token expires soon (within 1 hour)
|
|
460
|
+
time_until_expiry = token_data.expires_at - datetime.now(token_data.expires_at.tzinfo)
|
|
461
|
+
if time_until_expiry < timedelta(hours=1):
|
|
462
|
+
await self.refresh_token()
|
|
463
|
+
|
|
464
|
+
async def _ensure_agent_client(self) -> None:
|
|
465
|
+
"""Ensure agent HTTP client is initialized."""
|
|
466
|
+
if self._agent_client is None:
|
|
467
|
+
from ._async_agent_client import AsyncAgentHTTPClient
|
|
468
|
+
import asyncio
|
|
469
|
+
|
|
470
|
+
# Get sandbox info to get agent URL
|
|
471
|
+
info = await self.get_info()
|
|
472
|
+
agent_url = info.public_host.rstrip('/')
|
|
473
|
+
|
|
474
|
+
# Ensure JWT token is valid
|
|
475
|
+
await self._ensure_valid_token()
|
|
476
|
+
|
|
477
|
+
# Get JWT token for agent authentication
|
|
478
|
+
jwt_token = _token_cache.get(self.sandbox_id)
|
|
479
|
+
jwt_token_str = jwt_token.token if jwt_token else None
|
|
480
|
+
|
|
481
|
+
# Create agent client with token refresh callback
|
|
482
|
+
async def refresh_token_callback():
|
|
483
|
+
"""Async callback to refresh token when agent returns 401."""
|
|
484
|
+
await self.refresh_token()
|
|
485
|
+
token_data = _token_cache.get(self.sandbox_id)
|
|
486
|
+
return token_data.token if token_data else None
|
|
487
|
+
|
|
488
|
+
self._agent_client = AsyncAgentHTTPClient(
|
|
489
|
+
agent_url=agent_url,
|
|
490
|
+
jwt_token=jwt_token_str,
|
|
491
|
+
timeout=60,
|
|
492
|
+
max_retries=3,
|
|
493
|
+
token_refresh_callback=refresh_token_callback
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Wait for agent to be ready
|
|
497
|
+
max_wait = 30
|
|
498
|
+
retry_delay = 1.5
|
|
499
|
+
|
|
500
|
+
for attempt in range(max_wait):
|
|
501
|
+
try:
|
|
502
|
+
health = await self._agent_client.get("/health", operation="agent health check")
|
|
503
|
+
if health.get("status") == "healthy":
|
|
504
|
+
break
|
|
505
|
+
except Exception as e:
|
|
506
|
+
if attempt < max_wait - 1:
|
|
507
|
+
await asyncio.sleep(retry_delay)
|
|
508
|
+
continue
|
|
509
|
+
|
|
510
|
+
async def run_code(
|
|
511
|
+
self,
|
|
512
|
+
code: str,
|
|
513
|
+
*,
|
|
514
|
+
language: str = "python",
|
|
515
|
+
timeout_seconds: int = 60,
|
|
516
|
+
env: Optional[Dict[str, str]] = None,
|
|
517
|
+
working_dir: str = "/workspace",
|
|
518
|
+
):
|
|
519
|
+
"""
|
|
520
|
+
Execute code with rich output capture (async).
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
code: Code to execute
|
|
524
|
+
language: Language (python, javascript, bash, go)
|
|
525
|
+
timeout_seconds: Execution timeout in seconds
|
|
526
|
+
env: Optional environment variables
|
|
527
|
+
working_dir: Working directory
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
ExecutionResult with stdout, stderr, rich_outputs
|
|
531
|
+
"""
|
|
532
|
+
await self._ensure_agent_client()
|
|
533
|
+
|
|
534
|
+
from .models import ExecutionResult, RichOutput
|
|
535
|
+
|
|
536
|
+
payload = {
|
|
537
|
+
"language": language,
|
|
538
|
+
"code": code,
|
|
539
|
+
"working_dir": working_dir,
|
|
540
|
+
"timeout": timeout_seconds
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if env:
|
|
544
|
+
payload["env"] = env
|
|
545
|
+
|
|
546
|
+
response = await self._agent_client.post(
|
|
547
|
+
"/execute",
|
|
548
|
+
json=payload,
|
|
549
|
+
operation="execute code",
|
|
550
|
+
context={"language": language}
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
# Parse response
|
|
554
|
+
rich_outputs = []
|
|
555
|
+
if response and isinstance(response, dict):
|
|
556
|
+
rich_outputs_data = response.get("rich_outputs") or []
|
|
557
|
+
for output in rich_outputs_data:
|
|
558
|
+
if output:
|
|
559
|
+
rich_outputs.append(RichOutput(
|
|
560
|
+
type=output.get("type", ""),
|
|
561
|
+
data=output.get("data", {}),
|
|
562
|
+
metadata=output.get("metadata"),
|
|
563
|
+
timestamp=output.get("timestamp")
|
|
564
|
+
))
|
|
565
|
+
|
|
566
|
+
result = ExecutionResult(
|
|
567
|
+
success=response.get("success", True) if response else False,
|
|
568
|
+
stdout=response.get("stdout", "") if response else "",
|
|
569
|
+
stderr=response.get("stderr", "") if response else "",
|
|
570
|
+
exit_code=response.get("exit_code", 0) if response else 1,
|
|
571
|
+
execution_time=response.get("execution_time", 0.0) if response else 0.0,
|
|
572
|
+
rich_outputs=rich_outputs
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
return result
|
|
576
|
+
|
|
577
|
+
async def run_code_async(
|
|
578
|
+
self,
|
|
579
|
+
code: str,
|
|
580
|
+
*,
|
|
581
|
+
language: str = "python",
|
|
582
|
+
timeout_seconds: int = 60,
|
|
583
|
+
env: Optional[Dict[str, str]] = None,
|
|
584
|
+
) -> str:
|
|
585
|
+
"""
|
|
586
|
+
Execute code asynchronously (non-blocking, returns execution ID).
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
Execution ID for tracking
|
|
590
|
+
"""
|
|
591
|
+
await self._ensure_agent_client()
|
|
592
|
+
|
|
593
|
+
payload = {
|
|
594
|
+
"language": language,
|
|
595
|
+
"code": code,
|
|
596
|
+
"timeout": timeout_seconds,
|
|
597
|
+
"async": True
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if env:
|
|
601
|
+
payload["env"] = env
|
|
602
|
+
|
|
603
|
+
response = await self._agent_client.post(
|
|
604
|
+
"/execute",
|
|
605
|
+
json=payload,
|
|
606
|
+
operation="execute code async"
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
return response.get("execution_id", "")
|
|
610
|
+
|
|
611
|
+
async def list_processes(self) -> List[Dict[str, Any]]:
|
|
612
|
+
"""List running processes in sandbox."""
|
|
613
|
+
await self._ensure_agent_client()
|
|
614
|
+
|
|
615
|
+
response = await self._agent_client.get(
|
|
616
|
+
"/processes",
|
|
617
|
+
operation="list processes"
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
return response.get("processes", [])
|
|
621
|
+
|
|
622
|
+
async def kill_process(self, process_id: str) -> Dict[str, Any]:
|
|
623
|
+
"""Kill a process by ID."""
|
|
624
|
+
await self._ensure_agent_client()
|
|
625
|
+
|
|
626
|
+
response = await self._agent_client.post(
|
|
627
|
+
f"/processes/{process_id}/kill",
|
|
628
|
+
operation="kill process",
|
|
629
|
+
context={"process_id": process_id}
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
return response
|
|
633
|
+
|
|
634
|
+
async def get_metrics_snapshot(self) -> Dict[str, Any]:
|
|
635
|
+
"""Get agent metrics snapshot."""
|
|
636
|
+
await self._ensure_agent_client()
|
|
637
|
+
|
|
638
|
+
response = await self._agent_client.get(
|
|
639
|
+
"/metrics",
|
|
640
|
+
operation="get metrics"
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
return response
|
|
644
|
+
|
|
645
|
+
async def refresh_token(self) -> None:
|
|
646
|
+
"""Refresh JWT token for agent authentication."""
|
|
647
|
+
response = await self._client.post(f"/v1/sandboxes/{self.sandbox_id}/token/refresh")
|
|
648
|
+
|
|
649
|
+
if "auth_token" in response and "token_expires_at" in response:
|
|
650
|
+
_token_cache[self.sandbox_id] = TokenData(
|
|
651
|
+
token=response["auth_token"],
|
|
652
|
+
expires_at=datetime.fromisoformat(response["token_expires_at"].replace("Z", "+00:00"))
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
# Update agent client's JWT token if already initialized
|
|
656
|
+
if self._agent_client is not None:
|
|
657
|
+
self._agent_client.update_jwt_token(response["auth_token"])
|
|
658
|
+
|
|
659
|
+
# =============================================================================
|
|
660
|
+
# PROPERTIES - Access to specialized operations
|
|
661
|
+
# =============================================================================
|
|
662
|
+
|
|
663
|
+
@property
|
|
664
|
+
def files(self):
|
|
665
|
+
"""Access file operations (lazy init)."""
|
|
666
|
+
if not hasattr(self, '_files'):
|
|
667
|
+
from ._async_files import AsyncFiles
|
|
668
|
+
self._files = AsyncFiles(self)
|
|
669
|
+
return self._files
|
|
670
|
+
|
|
671
|
+
@property
|
|
672
|
+
def commands(self):
|
|
673
|
+
"""Access command operations (lazy init)."""
|
|
674
|
+
if not hasattr(self, '_commands'):
|
|
675
|
+
from ._async_commands import AsyncCommands
|
|
676
|
+
self._commands = AsyncCommands(self)
|
|
677
|
+
return self._commands
|
|
678
|
+
|
|
679
|
+
@property
|
|
680
|
+
def env(self):
|
|
681
|
+
"""Access environment variable operations (lazy init)."""
|
|
682
|
+
if not hasattr(self, '_env'):
|
|
683
|
+
from ._async_env_vars import AsyncEnvironmentVariables
|
|
684
|
+
self._env = AsyncEnvironmentVariables(self)
|
|
685
|
+
return self._env
|
|
686
|
+
|
|
687
|
+
@property
|
|
688
|
+
def cache(self):
|
|
689
|
+
"""Access cache operations (lazy init)."""
|
|
690
|
+
if not hasattr(self, '_cache'):
|
|
691
|
+
from ._async_cache import AsyncCache
|
|
692
|
+
self._cache = AsyncCache(self)
|
|
693
|
+
return self._cache
|
|
694
|
+
|
|
695
|
+
@property
|
|
696
|
+
def terminal(self):
|
|
697
|
+
"""Access terminal operations (lazy init)."""
|
|
698
|
+
if not hasattr(self, '_terminal'):
|
|
699
|
+
from ._async_terminal import AsyncTerminal
|
|
700
|
+
self._terminal = AsyncTerminal(self)
|
|
701
|
+
return self._terminal
|
|
702
|
+
|
|
703
|
+
async def run_ipython(
|
|
704
|
+
self,
|
|
705
|
+
code: str,
|
|
706
|
+
*,
|
|
707
|
+
timeout_seconds: int = 60,
|
|
708
|
+
env: Optional[Dict[str, str]] = None
|
|
709
|
+
):
|
|
710
|
+
"""Execute Python code in IPython environment (async)."""
|
|
711
|
+
await self._ensure_agent_client()
|
|
712
|
+
|
|
713
|
+
from .models import ExecutionResult, RichOutput
|
|
714
|
+
|
|
715
|
+
payload = {
|
|
716
|
+
"code": code,
|
|
717
|
+
"timeout": timeout_seconds,
|
|
718
|
+
"ipython": True
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if env:
|
|
722
|
+
payload["env"] = env
|
|
723
|
+
|
|
724
|
+
response = await self._agent_client.post(
|
|
725
|
+
"/execute",
|
|
726
|
+
json=payload,
|
|
727
|
+
operation="execute ipython code"
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
# Parse response
|
|
731
|
+
rich_outputs = []
|
|
732
|
+
if response and isinstance(response, dict):
|
|
733
|
+
rich_outputs_data = response.get("rich_outputs") or []
|
|
734
|
+
for output in rich_outputs_data:
|
|
735
|
+
if output:
|
|
736
|
+
rich_outputs.append(RichOutput(
|
|
737
|
+
type=output.get("type", ""),
|
|
738
|
+
data=output.get("data", {}),
|
|
739
|
+
metadata=output.get("metadata"),
|
|
740
|
+
timestamp=output.get("timestamp")
|
|
741
|
+
))
|
|
742
|
+
|
|
743
|
+
return ExecutionResult(
|
|
744
|
+
success=response.get("success", True) if response else False,
|
|
745
|
+
stdout=response.get("stdout", "") if response else "",
|
|
746
|
+
stderr=response.get("stderr", "") if response else "",
|
|
747
|
+
exit_code=response.get("exit_code", 0) if response else 1,
|
|
748
|
+
execution_time=response.get("execution_time", 0.0) if response else 0.0,
|
|
749
|
+
rich_outputs=rich_outputs
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
async def run_code_stream(self, code: str, *, language: str = "python", timeout_seconds: int = 60):
|
|
753
|
+
"""
|
|
754
|
+
Stream code execution output (async generator).
|
|
755
|
+
|
|
756
|
+
Yields stdout/stderr as they're produced.
|
|
757
|
+
"""
|
|
758
|
+
await self._ensure_agent_client()
|
|
759
|
+
|
|
760
|
+
# For now, return regular execution result
|
|
761
|
+
# TODO: Implement WebSocket streaming for async
|
|
762
|
+
result = await self.run_code(code, language=language, timeout_seconds=timeout_seconds)
|
|
763
|
+
yield result.stdout
|