ai-computer-client 0.3.2__py3-none-any.whl → 0.3.4__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.
- ai_computer/__init__.py +13 -3
- ai_computer/client.py +188 -549
- ai_computer/models.py +45 -0
- ai_computer/submodules/__init__.py +5 -0
- ai_computer/submodules/base.py +81 -0
- ai_computer/submodules/code.py +295 -0
- ai_computer/submodules/filesystem.py +438 -0
- ai_computer/submodules/shell.py +52 -0
- {ai_computer_client-0.3.2.dist-info → ai_computer_client-0.3.4.dist-info}/METADATA +28 -5
- ai_computer_client-0.3.4.dist-info/RECORD +12 -0
- ai_computer_client-0.3.2.dist-info/RECORD +0 -6
- {ai_computer_client-0.3.2.dist-info → ai_computer_client-0.3.4.dist-info}/WHEEL +0 -0
- {ai_computer_client-0.3.2.dist-info → ai_computer_client-0.3.4.dist-info}/licenses/LICENSE +0 -0
ai_computer/client.py
CHANGED
@@ -6,6 +6,13 @@ from dataclasses import dataclass
|
|
6
6
|
import os
|
7
7
|
import mimetypes
|
8
8
|
from pathlib import Path
|
9
|
+
import logging
|
10
|
+
|
11
|
+
from .models import SandboxResponse, StreamEvent, FileOperationResponse
|
12
|
+
from .submodules import FileSystemModule, ShellModule, CodeModule
|
13
|
+
|
14
|
+
# Set up logging
|
15
|
+
logger = logging.getLogger(__name__)
|
9
16
|
|
10
17
|
@dataclass
|
11
18
|
class SandboxResponse:
|
@@ -56,6 +63,11 @@ class SandboxClient:
|
|
56
63
|
This client provides methods to execute Python code in an isolated sandbox environment.
|
57
64
|
It handles authentication, sandbox creation/deletion, and code execution.
|
58
65
|
|
66
|
+
The client is organized into submodules for different types of operations:
|
67
|
+
- fs: File system operations (upload, download, read, write)
|
68
|
+
- shell: Shell command execution
|
69
|
+
- code: Python code execution
|
70
|
+
|
59
71
|
Args:
|
60
72
|
base_url: The base URL of the sandbox service
|
61
73
|
token: Optional pre-existing authentication token
|
@@ -70,6 +82,26 @@ class SandboxClient:
|
|
70
82
|
self.token = token
|
71
83
|
self.sandbox_id = None
|
72
84
|
|
85
|
+
# Initialize submodules
|
86
|
+
self._fs = FileSystemModule(self)
|
87
|
+
self._shell = ShellModule(self)
|
88
|
+
self._code = CodeModule(self)
|
89
|
+
|
90
|
+
@property
|
91
|
+
def fs(self) -> FileSystemModule:
|
92
|
+
"""File system operations submodule."""
|
93
|
+
return self._fs
|
94
|
+
|
95
|
+
@property
|
96
|
+
def shell(self) -> ShellModule:
|
97
|
+
"""Shell operations submodule."""
|
98
|
+
return self._shell
|
99
|
+
|
100
|
+
@property
|
101
|
+
def code(self) -> CodeModule:
|
102
|
+
"""Code execution operations submodule."""
|
103
|
+
return self._code
|
104
|
+
|
73
105
|
async def setup(self) -> SandboxResponse:
|
74
106
|
"""Initialize the client and create a sandbox.
|
75
107
|
|
@@ -98,179 +130,136 @@ class SandboxClient:
|
|
98
130
|
if response.status == 200:
|
99
131
|
data = await response.json()
|
100
132
|
self.sandbox_id = data["sandbox_id"]
|
101
|
-
# Wait for sandbox to be ready
|
102
|
-
ready = await self.wait_for_ready()
|
103
|
-
if not ready.success:
|
104
|
-
return ready
|
105
|
-
return SandboxResponse(success=True, data=data)
|
106
133
|
else:
|
107
134
|
text = await response.text()
|
108
135
|
return SandboxResponse(success=False, error=text)
|
136
|
+
|
137
|
+
# Wait for sandbox to be ready
|
138
|
+
return await self.wait_for_ready()
|
109
139
|
|
110
|
-
async def wait_for_ready(self,
|
111
|
-
"""Wait for the sandbox to be
|
140
|
+
async def wait_for_ready(self, max_attempts: int = 10, delay: float = 1.0) -> SandboxResponse:
|
141
|
+
"""Wait for the sandbox to be ready.
|
112
142
|
|
113
143
|
Args:
|
114
|
-
|
115
|
-
delay: Delay between
|
144
|
+
max_attempts: Maximum number of attempts to check status
|
145
|
+
delay: Delay between attempts in seconds
|
116
146
|
|
117
147
|
Returns:
|
118
|
-
SandboxResponse
|
148
|
+
SandboxResponse with success=True if sandbox is ready
|
119
149
|
"""
|
120
|
-
if not self.
|
121
|
-
return SandboxResponse(
|
150
|
+
if not self.sandbox_id:
|
151
|
+
return SandboxResponse(
|
152
|
+
success=False,
|
153
|
+
error="Sandbox ID not set. Call setup() first."
|
154
|
+
)
|
122
155
|
|
123
156
|
headers = {"Authorization": f"Bearer {self.token}"}
|
124
157
|
|
125
|
-
for
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
158
|
+
for attempt in range(max_attempts):
|
159
|
+
try:
|
160
|
+
logger.debug(f"Checking sandbox status (attempt {attempt + 1}/{max_attempts})...")
|
161
|
+
async with aiohttp.ClientSession() as session:
|
162
|
+
async with session.get(
|
163
|
+
f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/status",
|
164
|
+
headers=headers
|
165
|
+
) as response:
|
166
|
+
if response.status != 200:
|
167
|
+
# If we get an error, wait and try again
|
168
|
+
logger.debug(f"Waiting {delay}s before next attempt...")
|
169
|
+
await asyncio.sleep(delay)
|
170
|
+
continue
|
171
|
+
|
132
172
|
data = await response.json()
|
133
|
-
|
173
|
+
status = data.get("status", "").lower()
|
174
|
+
logger.debug(f"Current sandbox status: {status}")
|
175
|
+
|
176
|
+
# Check for both 'ready' and 'running' status as indicators that the sandbox is ready
|
177
|
+
if status == "ready" or status == "running":
|
134
178
|
return SandboxResponse(success=True, data=data)
|
135
|
-
|
136
|
-
|
137
|
-
|
179
|
+
elif status == "error":
|
180
|
+
return SandboxResponse(
|
181
|
+
success=False,
|
182
|
+
error=data.get("error", "Unknown error initializing sandbox")
|
183
|
+
)
|
184
|
+
|
185
|
+
# If not ready yet, wait and try again
|
186
|
+
logger.debug(f"Waiting {delay}s before next attempt...")
|
187
|
+
await asyncio.sleep(delay)
|
188
|
+
|
189
|
+
except Exception as e:
|
190
|
+
# If we get an exception, wait and try again
|
191
|
+
logger.error(f"Error checking sandbox status: {str(e)}")
|
192
|
+
await asyncio.sleep(delay)
|
193
|
+
|
194
|
+
return SandboxResponse(
|
195
|
+
success=False,
|
196
|
+
error=f"Sandbox not ready after {max_attempts} attempts"
|
197
|
+
)
|
138
198
|
|
139
|
-
async def
|
140
|
-
|
141
|
-
code: Union[str, bytes],
|
142
|
-
timeout: int = 30
|
143
|
-
) -> SandboxResponse:
|
144
|
-
"""Execute Python code in the sandbox and return the combined output.
|
145
|
-
|
146
|
-
This method collects all output from the streaming response and returns it as a single result.
|
147
|
-
It captures both stdout and stderr, and handles any errors during execution.
|
199
|
+
async def cleanup(self) -> SandboxResponse:
|
200
|
+
"""Delete the sandbox.
|
148
201
|
|
149
|
-
Args:
|
150
|
-
code: Python code to execute
|
151
|
-
timeout: Maximum execution time in seconds
|
152
|
-
|
153
202
|
Returns:
|
154
|
-
SandboxResponse
|
203
|
+
SandboxResponse indicating success/failure
|
155
204
|
"""
|
156
205
|
if not self.token or not self.sandbox_id:
|
157
206
|
return SandboxResponse(success=False, error="Client not properly initialized. Call setup() first")
|
158
207
|
|
159
|
-
|
160
|
-
ready = await self.wait_for_ready()
|
161
|
-
if not ready.success:
|
162
|
-
return ready
|
163
|
-
|
164
|
-
headers = {
|
165
|
-
"Authorization": f"Bearer {self.token}",
|
166
|
-
"Content-Type": "application/json"
|
167
|
-
}
|
168
|
-
|
169
|
-
data = {
|
170
|
-
"code": code,
|
171
|
-
"timeout": timeout
|
172
|
-
}
|
208
|
+
headers = {"Authorization": f"Bearer {self.token}"}
|
173
209
|
|
174
210
|
try:
|
175
211
|
async with aiohttp.ClientSession() as session:
|
176
|
-
async with session.
|
177
|
-
f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}
|
178
|
-
headers=headers
|
179
|
-
json=data
|
212
|
+
async with session.delete(
|
213
|
+
f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}",
|
214
|
+
headers=headers
|
180
215
|
) as response:
|
181
216
|
if response.status != 200:
|
182
|
-
|
183
|
-
return SandboxResponse(success=False, error=
|
217
|
+
text = await response.text()
|
218
|
+
return SandboxResponse(success=False, error=text)
|
184
219
|
|
185
|
-
#
|
186
|
-
|
187
|
-
return SandboxResponse(success=True
|
220
|
+
# Reset sandbox ID
|
221
|
+
self.sandbox_id = None
|
222
|
+
return SandboxResponse(success=True)
|
188
223
|
|
189
224
|
except Exception as e:
|
190
225
|
return SandboxResponse(success=False, error=f"Connection error: {str(e)}")
|
191
226
|
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
) -> AsyncGenerator[StreamEvent, None]:
|
197
|
-
"""Execute Python code in the sandbox and stream the output.
|
227
|
+
# Backward compatibility methods
|
228
|
+
|
229
|
+
async def execute_code(self, code: str, timeout: int = 30) -> SandboxResponse:
|
230
|
+
"""Execute Python code in the sandbox.
|
198
231
|
|
199
|
-
This
|
200
|
-
the type of event and the associated data.
|
232
|
+
This is a backward compatibility method that delegates to the code submodule.
|
201
233
|
|
202
234
|
Args:
|
203
|
-
code: Python code to execute
|
235
|
+
code: The Python code to execute
|
204
236
|
timeout: Maximum execution time in seconds
|
205
237
|
|
206
|
-
|
207
|
-
|
238
|
+
Returns:
|
239
|
+
SandboxResponse containing execution results
|
208
240
|
"""
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
# Ensure sandbox is ready
|
214
|
-
ready = await self.wait_for_ready()
|
215
|
-
if not ready.success:
|
216
|
-
yield StreamEvent(type="error", data=ready.error or "Sandbox not ready")
|
217
|
-
return
|
218
|
-
|
219
|
-
headers = {
|
220
|
-
"Authorization": f"Bearer {self.token}",
|
221
|
-
"Content-Type": "application/json"
|
222
|
-
}
|
241
|
+
return await self.code.execute(code, timeout)
|
242
|
+
|
243
|
+
async def execute_code_stream(self, code: str, timeout: int = 30) -> AsyncGenerator[StreamEvent, None]:
|
244
|
+
"""Execute Python code in the sandbox with streaming output.
|
223
245
|
|
224
|
-
|
225
|
-
"code": code,
|
226
|
-
"timeout": timeout
|
227
|
-
}
|
246
|
+
This is a backward compatibility method that delegates to the code submodule.
|
228
247
|
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
total=timeout + 30, # Add buffer for connection overhead
|
233
|
-
connect=30,
|
234
|
-
sock_connect=30,
|
235
|
-
sock_read=timeout + 30
|
236
|
-
)
|
248
|
+
Args:
|
249
|
+
code: The Python code to execute
|
250
|
+
timeout: Maximum execution time in seconds
|
237
251
|
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
) as response:
|
244
|
-
if response.status != 200:
|
245
|
-
error_text = await response.text()
|
246
|
-
yield StreamEvent(type="error", data=error_text)
|
247
|
-
return
|
248
|
-
|
249
|
-
# Process the streaming response
|
250
|
-
async for line in response.content:
|
251
|
-
if line:
|
252
|
-
try:
|
253
|
-
event = json.loads(line.decode())
|
254
|
-
yield StreamEvent(type=event['type'], data=event['data'])
|
255
|
-
|
256
|
-
# Stop if we receive an error or completed event
|
257
|
-
if event['type'] in ['error', 'completed']:
|
258
|
-
break
|
259
|
-
except json.JSONDecodeError as e:
|
260
|
-
yield StreamEvent(type="error", data=f"Failed to parse event: {str(e)}")
|
261
|
-
break
|
262
|
-
|
263
|
-
except Exception as e:
|
264
|
-
yield StreamEvent(type="error", data=f"Connection error: {str(e)}")
|
252
|
+
Yields:
|
253
|
+
StreamEvent objects containing execution output
|
254
|
+
"""
|
255
|
+
async for event in self.code.execute_stream(code, timeout):
|
256
|
+
yield event
|
265
257
|
|
266
|
-
async def execute_shell(
|
267
|
-
self,
|
268
|
-
command: str,
|
269
|
-
args: Optional[List[str]] = None,
|
270
|
-
timeout: int = 30
|
271
|
-
) -> SandboxResponse:
|
258
|
+
async def execute_shell(self, command: str, args: Optional[List[str]] = None, timeout: int = 30) -> SandboxResponse:
|
272
259
|
"""Execute a shell command in the sandbox.
|
273
260
|
|
261
|
+
This is a backward compatibility method that delegates to the shell submodule.
|
262
|
+
|
274
263
|
Args:
|
275
264
|
command: The shell command to execute
|
276
265
|
args: Optional list of arguments for the command
|
@@ -279,75 +268,19 @@ class SandboxClient:
|
|
279
268
|
Returns:
|
280
269
|
SandboxResponse containing execution results
|
281
270
|
"""
|
282
|
-
|
283
|
-
return SandboxResponse(success=False, error="Client not properly initialized. Call setup() first")
|
284
|
-
|
285
|
-
# Ensure sandbox is ready
|
286
|
-
ready = await self.wait_for_ready()
|
287
|
-
if not ready.success:
|
288
|
-
return ready
|
289
|
-
|
290
|
-
headers = {
|
291
|
-
"Authorization": f"Bearer {self.token}",
|
292
|
-
"Content-Type": "application/json"
|
293
|
-
}
|
294
|
-
|
295
|
-
data = {
|
296
|
-
"command": command,
|
297
|
-
"args": args or [],
|
298
|
-
"timeout": timeout
|
299
|
-
}
|
300
|
-
|
301
|
-
try:
|
302
|
-
async with aiohttp.ClientSession() as session:
|
303
|
-
async with session.post(
|
304
|
-
f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/execute/shell",
|
305
|
-
headers=headers,
|
306
|
-
json=data
|
307
|
-
) as response:
|
308
|
-
if response.status != 200:
|
309
|
-
error_text = await response.text()
|
310
|
-
return SandboxResponse(success=False, error=error_text)
|
311
|
-
|
312
|
-
# Parse the response
|
313
|
-
result = await response.json()
|
314
|
-
return SandboxResponse(success=True, data=result)
|
315
|
-
|
316
|
-
except Exception as e:
|
317
|
-
return SandboxResponse(success=False, error=f"Connection error: {str(e)}")
|
271
|
+
return await self.shell.execute(command, args, timeout)
|
318
272
|
|
319
|
-
async def cleanup(self) -> SandboxResponse:
|
320
|
-
"""Delete the sandbox.
|
321
|
-
|
322
|
-
Returns:
|
323
|
-
SandboxResponse indicating success/failure of cleanup
|
324
|
-
"""
|
325
|
-
if not self.token or not self.sandbox_id:
|
326
|
-
return SandboxResponse(success=True)
|
327
|
-
|
328
|
-
headers = {"Authorization": f"Bearer {self.token}"}
|
329
|
-
async with aiohttp.ClientSession() as session:
|
330
|
-
async with session.delete(
|
331
|
-
f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}",
|
332
|
-
headers=headers
|
333
|
-
) as response:
|
334
|
-
if response.status == 200:
|
335
|
-
data = await response.json()
|
336
|
-
self.sandbox_id = None
|
337
|
-
return SandboxResponse(success=True, data=data)
|
338
|
-
else:
|
339
|
-
text = await response.text()
|
340
|
-
return SandboxResponse(success=False, error=text)
|
341
|
-
|
342
273
|
async def upload_file(
|
343
274
|
self,
|
344
275
|
file_path: Union[str, Path],
|
345
276
|
destination: str = "/workspace",
|
346
|
-
chunk_size: int = 1024 * 1024,
|
347
|
-
timeout: int = 300
|
277
|
+
chunk_size: int = 1024 * 1024,
|
278
|
+
timeout: int = 300
|
348
279
|
) -> FileOperationResponse:
|
349
280
|
"""Upload a file to the sandbox environment.
|
350
281
|
|
282
|
+
This is a backward compatibility method that delegates to the fs submodule.
|
283
|
+
|
351
284
|
Args:
|
352
285
|
file_path: Path to the file to upload
|
353
286
|
destination: Destination path in the sandbox (absolute path starting with /)
|
@@ -357,222 +290,40 @@ class SandboxClient:
|
|
357
290
|
Returns:
|
358
291
|
FileOperationResponse containing upload results
|
359
292
|
"""
|
360
|
-
|
361
|
-
|
362
|
-
success=False,
|
363
|
-
error="Client not properly initialized. Call setup() first"
|
364
|
-
)
|
365
|
-
|
366
|
-
# Ensure sandbox is ready
|
367
|
-
ready = await self.wait_for_ready()
|
368
|
-
if not ready.success:
|
369
|
-
return FileOperationResponse(
|
370
|
-
success=False,
|
371
|
-
error=ready.error or "Sandbox not ready"
|
372
|
-
)
|
373
|
-
|
374
|
-
# Convert to Path object and validate file
|
375
|
-
file_path = Path(file_path)
|
376
|
-
if not file_path.exists():
|
377
|
-
return FileOperationResponse(
|
378
|
-
success=False,
|
379
|
-
error=f"File not found: {file_path}"
|
380
|
-
)
|
381
|
-
|
382
|
-
if not file_path.is_file():
|
383
|
-
return FileOperationResponse(
|
384
|
-
success=False,
|
385
|
-
error=f"Not a file: {file_path}"
|
386
|
-
)
|
387
|
-
|
388
|
-
# Get file size and validate
|
389
|
-
file_size = file_path.stat().st_size
|
390
|
-
if file_size > 100 * 1024 * 1024: # 100MB limit
|
391
|
-
return FileOperationResponse(
|
392
|
-
success=False,
|
393
|
-
error="File too large. Maximum size is 100MB"
|
394
|
-
)
|
395
|
-
|
396
|
-
try:
|
397
|
-
# Prepare the upload
|
398
|
-
headers = {
|
399
|
-
"Authorization": f"Bearer {self.token}"
|
400
|
-
}
|
401
|
-
|
402
|
-
# Guess content type
|
403
|
-
content_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
|
404
|
-
|
405
|
-
# Prepare multipart form data
|
406
|
-
data = aiohttp.FormData()
|
407
|
-
data.add_field('file',
|
408
|
-
open(file_path, 'rb').read(),
|
409
|
-
filename=file_path.name,
|
410
|
-
content_type=content_type)
|
411
|
-
data.add_field('path', destination)
|
412
|
-
|
413
|
-
timeout_settings = aiohttp.ClientTimeout(
|
414
|
-
total=timeout,
|
415
|
-
connect=30,
|
416
|
-
sock_connect=30,
|
417
|
-
sock_read=timeout
|
418
|
-
)
|
419
|
-
|
420
|
-
async with aiohttp.ClientSession(timeout=timeout_settings) as session:
|
421
|
-
async with session.post(
|
422
|
-
f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/files/upload",
|
423
|
-
headers=headers,
|
424
|
-
data=data
|
425
|
-
) as response:
|
426
|
-
if response.status != 200:
|
427
|
-
error_text = await response.text()
|
428
|
-
return FileOperationResponse(
|
429
|
-
success=False,
|
430
|
-
error=f"Upload failed: {error_text}"
|
431
|
-
)
|
432
|
-
|
433
|
-
result = await response.json()
|
434
|
-
return FileOperationResponse(
|
435
|
-
success=True,
|
436
|
-
filename=result.get("filename"),
|
437
|
-
size=result.get("size"),
|
438
|
-
path=result.get("path"),
|
439
|
-
message=result.get("message")
|
440
|
-
)
|
441
|
-
|
442
|
-
except asyncio.TimeoutError:
|
443
|
-
return FileOperationResponse(
|
444
|
-
success=False,
|
445
|
-
error=f"Upload timed out after {timeout} seconds"
|
446
|
-
)
|
447
|
-
except Exception as e:
|
448
|
-
return FileOperationResponse(
|
449
|
-
success=False,
|
450
|
-
error=f"Upload failed: {str(e)}"
|
451
|
-
)
|
452
|
-
|
293
|
+
return await self.fs.upload_file(file_path, destination, chunk_size, timeout)
|
294
|
+
|
453
295
|
async def download_file(
|
454
296
|
self,
|
455
|
-
|
297
|
+
remote_path: str,
|
456
298
|
local_path: Optional[Union[str, Path]] = None,
|
457
|
-
|
458
|
-
timeout: int = 300 # 5 minutes
|
299
|
+
timeout: int = 300
|
459
300
|
) -> FileOperationResponse:
|
460
|
-
"""Download a file from the sandbox
|
301
|
+
"""Download a file from the sandbox.
|
302
|
+
|
303
|
+
This is a backward compatibility method that delegates to the fs submodule.
|
461
304
|
|
462
305
|
Args:
|
463
|
-
|
464
|
-
|
465
|
-
local_path: Local path to save the file (default: current directory with original filename)
|
466
|
-
chunk_size: Size of chunks for downloading large files
|
306
|
+
remote_path: Path to the file in the sandbox
|
307
|
+
local_path: Local path to save the file (if None, uses the filename from remote_path)
|
467
308
|
timeout: Maximum download time in seconds
|
468
309
|
|
469
310
|
Returns:
|
470
311
|
FileOperationResponse containing download results
|
471
312
|
"""
|
472
|
-
|
473
|
-
|
474
|
-
success=False,
|
475
|
-
error="Client not properly initialized. Call setup() first"
|
476
|
-
)
|
477
|
-
|
478
|
-
# Ensure sandbox is ready
|
479
|
-
ready = await self.wait_for_ready()
|
480
|
-
if not ready.success:
|
481
|
-
return FileOperationResponse(
|
482
|
-
success=False,
|
483
|
-
error=ready.error or "Sandbox not ready"
|
484
|
-
)
|
485
|
-
|
486
|
-
# Ensure path is absolute and normalize any double slashes
|
487
|
-
if not sandbox_path.startswith('/'):
|
488
|
-
sandbox_path = f"/{sandbox_path}"
|
489
|
-
clean_path = '/'.join(part for part in sandbox_path.split('/') if part)
|
490
|
-
clean_path = f"/{clean_path}"
|
491
|
-
|
492
|
-
# Determine local path
|
493
|
-
if local_path is None:
|
494
|
-
local_path = Path(os.path.basename(sandbox_path))
|
495
|
-
else:
|
496
|
-
local_path = Path(local_path)
|
497
|
-
|
498
|
-
# Create parent directories if they don't exist
|
499
|
-
local_path.parent.mkdir(parents=True, exist_ok=True)
|
500
|
-
|
501
|
-
try:
|
502
|
-
timeout_settings = aiohttp.ClientTimeout(
|
503
|
-
total=timeout,
|
504
|
-
connect=30,
|
505
|
-
sock_connect=30,
|
506
|
-
sock_read=timeout
|
507
|
-
)
|
508
|
-
|
509
|
-
headers = {
|
510
|
-
"Authorization": f"Bearer {self.token}"
|
511
|
-
}
|
512
|
-
|
513
|
-
async with aiohttp.ClientSession(timeout=timeout_settings) as session:
|
514
|
-
async with session.get(
|
515
|
-
f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/files/download{clean_path}",
|
516
|
-
headers=headers
|
517
|
-
) as response:
|
518
|
-
if response.status != 200:
|
519
|
-
error_text = await response.text()
|
520
|
-
return FileOperationResponse(
|
521
|
-
success=False,
|
522
|
-
error=f"Download failed: {error_text}"
|
523
|
-
)
|
524
|
-
|
525
|
-
# Get content length if available
|
526
|
-
total_size = int(response.headers.get('content-length', 0))
|
527
|
-
|
528
|
-
# Download the file in chunks
|
529
|
-
downloaded_size = 0
|
530
|
-
try:
|
531
|
-
with open(local_path, 'wb') as f:
|
532
|
-
async for chunk in response.content.iter_chunked(chunk_size):
|
533
|
-
f.write(chunk)
|
534
|
-
downloaded_size += len(chunk)
|
535
|
-
|
536
|
-
return FileOperationResponse(
|
537
|
-
success=True,
|
538
|
-
filename=local_path.name,
|
539
|
-
size=downloaded_size or total_size,
|
540
|
-
path=str(local_path.absolute()),
|
541
|
-
message="File downloaded successfully"
|
542
|
-
)
|
543
|
-
except Exception as e:
|
544
|
-
# Clean up partial download
|
545
|
-
if local_path.exists():
|
546
|
-
local_path.unlink()
|
547
|
-
raise e
|
548
|
-
|
549
|
-
except asyncio.TimeoutError:
|
550
|
-
# Clean up partial download
|
551
|
-
if local_path.exists():
|
552
|
-
local_path.unlink()
|
553
|
-
return FileOperationResponse(
|
554
|
-
success=False,
|
555
|
-
error=f"Download timed out after {timeout} seconds"
|
556
|
-
)
|
557
|
-
except Exception as e:
|
558
|
-
# Clean up partial download
|
559
|
-
if local_path.exists():
|
560
|
-
local_path.unlink()
|
561
|
-
return FileOperationResponse(
|
562
|
-
success=False,
|
563
|
-
error=f"Download failed: {str(e)}"
|
564
|
-
)
|
565
|
-
|
313
|
+
return await self.fs.download_file(remote_path, local_path, timeout)
|
314
|
+
|
566
315
|
async def upload_bytes(
|
567
316
|
self,
|
568
317
|
content: Union[bytes, BinaryIO],
|
569
318
|
filename: str,
|
570
319
|
destination: str = "/workspace",
|
571
320
|
content_type: Optional[str] = None,
|
572
|
-
timeout: int = 300
|
321
|
+
timeout: int = 300
|
573
322
|
) -> FileOperationResponse:
|
574
323
|
"""Upload bytes or a file-like object to the sandbox environment.
|
575
324
|
|
325
|
+
This is a backward compatibility method that delegates to the fs submodule.
|
326
|
+
|
576
327
|
Args:
|
577
328
|
content: Bytes or file-like object to upload
|
578
329
|
filename: Name to give the file in the sandbox
|
@@ -583,179 +334,67 @@ class SandboxClient:
|
|
583
334
|
Returns:
|
584
335
|
FileOperationResponse containing upload results
|
585
336
|
"""
|
586
|
-
|
587
|
-
|
588
|
-
|
589
|
-
error="Client not properly initialized. Call setup() first"
|
590
|
-
)
|
591
|
-
|
592
|
-
# Ensure sandbox is ready
|
593
|
-
ready = await self.wait_for_ready()
|
594
|
-
if not ready.success:
|
595
|
-
return FileOperationResponse(
|
596
|
-
success=False,
|
597
|
-
error=ready.error or "Sandbox not ready"
|
598
|
-
)
|
599
|
-
|
600
|
-
try:
|
601
|
-
# Handle both bytes and file-like objects
|
337
|
+
# Create a temporary file with the content
|
338
|
+
import tempfile
|
339
|
+
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
602
340
|
if isinstance(content, bytes):
|
603
|
-
|
604
|
-
content_length = len(content)
|
341
|
+
temp_file.write(content)
|
605
342
|
else:
|
606
343
|
# Ensure we're at the start of the file
|
607
344
|
if hasattr(content, 'seek'):
|
608
345
|
content.seek(0)
|
609
|
-
|
610
|
-
|
611
|
-
|
612
|
-
|
613
|
-
|
614
|
-
current_pos = content.tell()
|
615
|
-
content.seek(0, os.SEEK_END)
|
616
|
-
content_length = content.tell()
|
617
|
-
content.seek(current_pos)
|
618
|
-
except (OSError, IOError):
|
619
|
-
pass
|
620
|
-
|
621
|
-
# Validate size if we can determine it
|
622
|
-
if content_length and content_length > 100 * 1024 * 1024: # 100MB limit
|
623
|
-
return FileOperationResponse(
|
624
|
-
success=False,
|
625
|
-
error="Content too large. Maximum size is 100MB"
|
626
|
-
)
|
627
|
-
|
628
|
-
# Prepare the upload
|
629
|
-
headers = {
|
630
|
-
"Authorization": f"Bearer {self.token}"
|
631
|
-
}
|
632
|
-
|
633
|
-
# Guess content type if not provided
|
634
|
-
if not content_type:
|
635
|
-
content_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
|
636
|
-
|
637
|
-
# Prepare multipart form data
|
638
|
-
data = aiohttp.FormData()
|
639
|
-
data.add_field('file',
|
640
|
-
file_obj,
|
641
|
-
filename=filename,
|
642
|
-
content_type=content_type)
|
643
|
-
data.add_field('path', destination)
|
644
|
-
|
645
|
-
timeout_settings = aiohttp.ClientTimeout(
|
646
|
-
total=timeout,
|
647
|
-
connect=30,
|
648
|
-
sock_connect=30,
|
649
|
-
sock_read=timeout
|
650
|
-
)
|
651
|
-
|
652
|
-
async with aiohttp.ClientSession(timeout=timeout_settings) as session:
|
653
|
-
async with session.post(
|
654
|
-
f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/files/upload",
|
655
|
-
headers=headers,
|
656
|
-
data=data
|
657
|
-
) as response:
|
658
|
-
if response.status != 200:
|
659
|
-
error_text = await response.text()
|
660
|
-
return FileOperationResponse(
|
661
|
-
success=False,
|
662
|
-
error=f"Upload failed: {error_text}"
|
663
|
-
)
|
664
|
-
|
665
|
-
result = await response.json()
|
666
|
-
return FileOperationResponse(
|
667
|
-
success=True,
|
668
|
-
filename=result.get("filename"),
|
669
|
-
size=result.get("size"),
|
670
|
-
path=result.get("path"),
|
671
|
-
message=result.get("message")
|
672
|
-
)
|
673
|
-
|
674
|
-
except asyncio.TimeoutError:
|
675
|
-
return FileOperationResponse(
|
676
|
-
success=False,
|
677
|
-
error=f"Upload timed out after {timeout} seconds"
|
678
|
-
)
|
679
|
-
except Exception as e:
|
680
|
-
return FileOperationResponse(
|
681
|
-
success=False,
|
682
|
-
error=f"Upload failed: {str(e)}"
|
683
|
-
)
|
684
|
-
|
685
|
-
async def download_bytes(
|
686
|
-
self,
|
687
|
-
sandbox_path: str,
|
688
|
-
chunk_size: int = 8192, # 8KB chunks for download
|
689
|
-
timeout: int = 300 # 5 minutes
|
690
|
-
) -> Union[bytes, FileOperationResponse]:
|
691
|
-
"""Download a file from the sandbox environment into memory.
|
346
|
+
# Read and write in chunks to handle large files
|
347
|
+
chunk = content.read(1024 * 1024) # 1MB chunks
|
348
|
+
while chunk:
|
349
|
+
temp_file.write(chunk)
|
350
|
+
chunk = content.read(1024 * 1024)
|
692
351
|
|
693
|
-
|
694
|
-
|
695
|
-
|
696
|
-
|
697
|
-
|
352
|
+
try:
|
353
|
+
# Upload the temporary file
|
354
|
+
temp_path = Path(temp_file.name)
|
355
|
+
result = await self.fs.upload_file(
|
356
|
+
file_path=temp_path,
|
357
|
+
destination=os.path.join(destination, filename),
|
358
|
+
timeout=timeout
|
359
|
+
)
|
698
360
|
|
699
|
-
|
700
|
-
|
701
|
-
|
361
|
+
# If successful, update the filename in the response
|
362
|
+
if result.success:
|
363
|
+
result.filename = filename
|
364
|
+
|
365
|
+
return result
|
366
|
+
finally:
|
367
|
+
# Clean up the temporary file
|
368
|
+
if os.path.exists(temp_file.name):
|
369
|
+
os.unlink(temp_file.name)
|
370
|
+
|
371
|
+
async def download_bytes(self, remote_path: str, timeout: Optional[float] = None) -> Union[bytes, FileOperationResponse]:
|
702
372
|
"""
|
703
|
-
|
704
|
-
return FileOperationResponse(
|
705
|
-
success=False,
|
706
|
-
error="Client not properly initialized. Call setup() first"
|
707
|
-
)
|
708
|
-
|
709
|
-
# Ensure sandbox is ready
|
710
|
-
ready = await self.wait_for_ready()
|
711
|
-
if not ready.success:
|
712
|
-
return FileOperationResponse(
|
713
|
-
success=False,
|
714
|
-
error=ready.error or "Sandbox not ready"
|
715
|
-
)
|
373
|
+
Download a file from the sandbox into memory.
|
716
374
|
|
717
|
-
|
718
|
-
|
719
|
-
|
720
|
-
clean_path = '/'.join(part for part in sandbox_path.split('/') if part)
|
721
|
-
clean_path = f"/{clean_path}"
|
375
|
+
Args:
|
376
|
+
remote_path: Path to the file in the sandbox.
|
377
|
+
timeout: Timeout in seconds for the operation.
|
722
378
|
|
379
|
+
Returns:
|
380
|
+
bytes: The file contents as bytes if successful.
|
381
|
+
FileOperationResponse: On failure, returns a FileOperationResponse with error details.
|
382
|
+
"""
|
383
|
+
await self.wait_for_ready()
|
384
|
+
|
723
385
|
try:
|
724
|
-
|
725
|
-
|
726
|
-
|
727
|
-
|
728
|
-
|
729
|
-
|
730
|
-
|
731
|
-
|
732
|
-
"Authorization": f"Bearer {self.token}"
|
733
|
-
}
|
734
|
-
|
735
|
-
async with aiohttp.ClientSession(timeout=timeout_settings) as session:
|
736
|
-
async with session.get(
|
737
|
-
f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/files/download{clean_path}",
|
738
|
-
headers=headers
|
739
|
-
) as response:
|
740
|
-
if response.status != 200:
|
741
|
-
error_text = await response.text()
|
742
|
-
return FileOperationResponse(
|
743
|
-
success=False,
|
744
|
-
error=f"Download failed: {error_text}"
|
745
|
-
)
|
746
|
-
|
747
|
-
# Read the entire response into memory
|
748
|
-
content = await response.read()
|
749
|
-
|
750
|
-
return content
|
751
|
-
|
752
|
-
except asyncio.TimeoutError:
|
753
|
-
return FileOperationResponse(
|
754
|
-
success=False,
|
755
|
-
error=f"Download timed out after {timeout} seconds"
|
756
|
-
)
|
386
|
+
response = await self.fs.download_bytes(remote_path, timeout=timeout or 300)
|
387
|
+
if response.success:
|
388
|
+
return response.data.get('content')
|
389
|
+
else:
|
390
|
+
return FileOperationResponse(
|
391
|
+
success=False,
|
392
|
+
error=response.error or "Failed to download file"
|
393
|
+
)
|
757
394
|
except Exception as e:
|
758
395
|
return FileOperationResponse(
|
759
396
|
success=False,
|
760
|
-
error=f"
|
761
|
-
)
|
397
|
+
error=f"Error downloading file: {str(e)}"
|
398
|
+
)
|
399
|
+
|
400
|
+
# Additional backward compatibility methods can be added as needed
|