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.
@@ -0,0 +1,438 @@
1
+ import aiohttp
2
+ import asyncio
3
+ import os
4
+ import mimetypes
5
+ from pathlib import Path
6
+ from typing import Optional, Union, BinaryIO, Dict, Any
7
+ from urllib.parse import quote
8
+
9
+ from .base import BaseSubmodule
10
+ from ..models import FileOperationResponse, SandboxResponse
11
+
12
+ class FileSystemModule(BaseSubmodule):
13
+ """File system operations for the sandbox environment.
14
+
15
+ This module provides methods for file operations such as uploading,
16
+ downloading, reading, and writing files in the sandbox.
17
+ """
18
+
19
+ async def upload_file(
20
+ self,
21
+ file_path: Union[str, Path],
22
+ destination: str = "/workspace",
23
+ chunk_size: int = 1024 * 1024, # 1MB chunks
24
+ timeout: int = 300 # 5 minutes
25
+ ) -> FileOperationResponse:
26
+ """Upload a file to the sandbox environment.
27
+
28
+ Args:
29
+ file_path: Path to the file to upload
30
+ destination: Destination path in the sandbox (absolute path starting with /)
31
+ chunk_size: Size of chunks for reading large files
32
+ timeout: Maximum upload time in seconds
33
+
34
+ Returns:
35
+ FileOperationResponse containing upload results
36
+ """
37
+ ready = await self._ensure_ready()
38
+ if not ready.success:
39
+ return FileOperationResponse(
40
+ success=False,
41
+ error=ready.error or "Sandbox not ready"
42
+ )
43
+
44
+ # Convert to Path object and validate file
45
+ file_path = Path(file_path)
46
+ if not file_path.exists():
47
+ return FileOperationResponse(
48
+ success=False,
49
+ error=f"File not found: {file_path}"
50
+ )
51
+
52
+ if not file_path.is_file():
53
+ return FileOperationResponse(
54
+ success=False,
55
+ error=f"Not a file: {file_path}"
56
+ )
57
+
58
+ # Get file size and validate
59
+ file_size = file_path.stat().st_size
60
+ if file_size > 100 * 1024 * 1024: # 100MB limit
61
+ return FileOperationResponse(
62
+ success=False,
63
+ error="File too large. Maximum size is 100MB"
64
+ )
65
+
66
+ try:
67
+ # Prepare the upload
68
+ headers = {
69
+ "Authorization": f"Bearer {self.token}"
70
+ }
71
+
72
+ # Guess content type
73
+ content_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
74
+
75
+ # Prepare multipart form data
76
+ data = aiohttp.FormData()
77
+ data.add_field('file',
78
+ open(file_path, 'rb'), # Pass file object directly for streaming
79
+ filename=file_path.name,
80
+ content_type=content_type)
81
+ data.add_field('path', destination)
82
+
83
+ timeout_settings = aiohttp.ClientTimeout(
84
+ total=timeout,
85
+ connect=30,
86
+ sock_connect=30,
87
+ sock_read=timeout
88
+ )
89
+
90
+ async with aiohttp.ClientSession(timeout=timeout_settings) as session:
91
+ async with session.post(
92
+ f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/files/upload",
93
+ headers=headers,
94
+ data=data
95
+ ) as response:
96
+ if response.status != 200:
97
+ error_text = await response.text()
98
+ return FileOperationResponse(
99
+ success=False,
100
+ error=f"Upload failed: {error_text}"
101
+ )
102
+
103
+ result = await response.json()
104
+ return FileOperationResponse(
105
+ success=True,
106
+ filename=result.get("filename"),
107
+ size=result.get("size"),
108
+ path=result.get("path"),
109
+ message=result.get("message")
110
+ )
111
+
112
+ except asyncio.TimeoutError:
113
+ return FileOperationResponse(
114
+ success=False,
115
+ error=f"Upload timed out after {timeout} seconds"
116
+ )
117
+ except Exception as e:
118
+ return FileOperationResponse(
119
+ success=False,
120
+ error=f"Upload failed: {str(e)}"
121
+ )
122
+
123
+ async def download_file(
124
+ self,
125
+ remote_path: str,
126
+ local_path: Optional[str] = None,
127
+ timeout: int = 300
128
+ ) -> FileOperationResponse:
129
+ """Download a file from the sandbox to the local filesystem.
130
+
131
+ Args:
132
+ remote_path: Path to the file in the sandbox
133
+ local_path: Local path to save the file (defaults to basename of remote_path)
134
+ timeout: Maximum download time in seconds
135
+
136
+ Returns:
137
+ FileOperationResponse containing download results
138
+ """
139
+ ready = await self._ensure_ready()
140
+ if not ready.success:
141
+ return FileOperationResponse(
142
+ success=False,
143
+ error=ready.error
144
+ )
145
+
146
+ # Determine local path if not provided
147
+ if local_path is None:
148
+ local_path = os.path.basename(remote_path)
149
+ local_path = Path(local_path)
150
+
151
+ # Create parent directories if they don't exist
152
+ os.makedirs(local_path.parent, exist_ok=True)
153
+
154
+ headers = {
155
+ "Authorization": f"Bearer {self.token}"
156
+ }
157
+
158
+ # Store original path for error messages
159
+ original_path = remote_path
160
+
161
+ # Ensure path is absolute
162
+ if not remote_path.startswith('/'):
163
+ remote_path = f"/{remote_path}"
164
+
165
+ timeout_settings = aiohttp.ClientTimeout(
166
+ total=timeout,
167
+ connect=30,
168
+ sock_connect=30,
169
+ sock_read=timeout
170
+ )
171
+
172
+ try:
173
+ async with aiohttp.ClientSession(timeout=timeout_settings) as session:
174
+ # Use the new API endpoint with query parameters
175
+ url = f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/files"
176
+ params = {"path": quote(remote_path)}
177
+
178
+ async with session.get(
179
+ url,
180
+ headers=headers,
181
+ params=params
182
+ ) as response:
183
+ if response.status != 200:
184
+ error_text = await response.text()
185
+ return FileOperationResponse(
186
+ success=False,
187
+ error=f"Failed to download file '{original_path}': {error_text}"
188
+ )
189
+
190
+ # Get content disposition header to extract filename
191
+ content_disposition = response.headers.get('Content-Disposition', '')
192
+ filename = os.path.basename(remote_path) # Default to basename of remote path
193
+
194
+ # Extract filename from content disposition if available
195
+ if 'filename=' in content_disposition:
196
+ filename = content_disposition.split('filename=')[1].strip('"\'')
197
+
198
+ # Save the file
199
+ with open(local_path, 'wb') as f:
200
+ size = 0
201
+ async for chunk in response.content.iter_chunked(1024 * 1024): # 1MB chunks
202
+ f.write(chunk)
203
+ size += len(chunk)
204
+
205
+ return FileOperationResponse(
206
+ success=True,
207
+ filename=filename,
208
+ size=size,
209
+ path=str(local_path),
210
+ message=f"File downloaded successfully to {local_path}"
211
+ )
212
+
213
+ except asyncio.TimeoutError:
214
+ # Clean up partial download
215
+ if local_path.exists():
216
+ local_path.unlink()
217
+ return FileOperationResponse(
218
+ success=False,
219
+ error=f"Download timed out after {timeout} seconds"
220
+ )
221
+ except Exception as e:
222
+ # Clean up partial download
223
+ if local_path.exists():
224
+ local_path.unlink()
225
+ return FileOperationResponse(
226
+ success=False,
227
+ error=f"Download failed: {str(e)}"
228
+ )
229
+
230
+ async def read_file(self, path: str, encoding: Optional[str] = 'utf-8') -> SandboxResponse:
231
+ """Read a file from the sandbox.
232
+
233
+ Args:
234
+ path: Path to the file in the sandbox
235
+ encoding: Text encoding to use (None for binary)
236
+
237
+ Returns:
238
+ SandboxResponse with the file content
239
+ """
240
+ ready = await self._ensure_ready()
241
+ if not ready.success:
242
+ return ready
243
+
244
+ # Ensure path is absolute
245
+ if not path.startswith('/'):
246
+ path = f"/{path}"
247
+
248
+ headers = {
249
+ "Authorization": f"Bearer {self.token}"
250
+ }
251
+
252
+ try:
253
+ async with aiohttp.ClientSession() as session:
254
+ # Use the new API endpoint with query parameters
255
+ url = f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/files"
256
+ params = {"path": quote(path)}
257
+
258
+ async with session.get(
259
+ url,
260
+ headers=headers,
261
+ params=params
262
+ ) as response:
263
+ if response.status != 200:
264
+ error_text = await response.text()
265
+ return SandboxResponse(
266
+ success=False,
267
+ error=f"Failed to read file: {error_text}"
268
+ )
269
+
270
+ # Read the content
271
+ content = await response.read()
272
+ size = len(content)
273
+
274
+ # Decode if needed
275
+ if encoding is not None:
276
+ try:
277
+ content = content.decode(encoding)
278
+ except UnicodeDecodeError:
279
+ return SandboxResponse(
280
+ success=False,
281
+ error=f"Failed to decode file with encoding {encoding}"
282
+ )
283
+
284
+ return SandboxResponse(
285
+ success=True,
286
+ data={
287
+ 'content': content,
288
+ 'size': size
289
+ }
290
+ )
291
+
292
+ except Exception as e:
293
+ return SandboxResponse(
294
+ success=False,
295
+ error=f"Failed to read file: {str(e)}"
296
+ )
297
+
298
+ async def write_file(
299
+ self,
300
+ path: str,
301
+ content: Union[str, bytes],
302
+ encoding: str = 'utf-8'
303
+ ) -> SandboxResponse:
304
+ """Write content to a file in the sandbox.
305
+
306
+ Args:
307
+ path: Path to the file in the sandbox
308
+ content: Content to write (string or bytes)
309
+ encoding: Text encoding to use (ignored for bytes content)
310
+
311
+ Returns:
312
+ SandboxResponse indicating success or failure
313
+ """
314
+ ready = await self._ensure_ready()
315
+ if not ready.success:
316
+ return ready
317
+
318
+ # Convert string to bytes if needed
319
+ if isinstance(content, str):
320
+ try:
321
+ content = content.encode(encoding)
322
+ except UnicodeEncodeError:
323
+ return SandboxResponse(
324
+ success=False,
325
+ error=f"Failed to encode content with encoding {encoding}"
326
+ )
327
+
328
+ # Ensure path is absolute and normalize any double slashes
329
+ if not path.startswith('/'):
330
+ path = f"/{path}"
331
+ clean_path = '/'.join(part for part in path.split('/') if part)
332
+ clean_path = f"/{clean_path}"
333
+
334
+ # Extract the filename and destination directory
335
+ filename = os.path.basename(path)
336
+ destination = os.path.dirname(path)
337
+
338
+ # Create a temporary file with the content
339
+ import tempfile
340
+ temp_file = tempfile.NamedTemporaryFile(delete=False)
341
+ try:
342
+ temp_file.write(content)
343
+ temp_file.close()
344
+
345
+ # Upload the temporary file
346
+ result = await self.upload_file(
347
+ file_path=temp_file.name,
348
+ destination=destination
349
+ )
350
+
351
+ if result.success:
352
+ return SandboxResponse(
353
+ success=True,
354
+ data={
355
+ 'path': result.path,
356
+ 'size': result.size
357
+ }
358
+ )
359
+ else:
360
+ return SandboxResponse(
361
+ success=False,
362
+ error=f"Failed to write file: {result.error}"
363
+ )
364
+ except Exception as e:
365
+ return SandboxResponse(
366
+ success=False,
367
+ error=f"Failed to write file: {str(e)}"
368
+ )
369
+ finally:
370
+ # Clean up the temporary file
371
+ if os.path.exists(temp_file.name):
372
+ os.unlink(temp_file.name)
373
+
374
+ async def download_bytes(self, path: str, timeout: int = 300) -> SandboxResponse:
375
+ """Download a file from the sandbox into memory.
376
+
377
+ Args:
378
+ path: Path to the file in the sandbox
379
+ timeout: Maximum download time in seconds
380
+
381
+ Returns:
382
+ SandboxResponse with the file content as bytes in the data field
383
+ """
384
+ ready = await self._ensure_ready()
385
+ if not ready.success:
386
+ return ready
387
+
388
+ # Ensure path is absolute
389
+ if not path.startswith('/'):
390
+ path = f"/{path}"
391
+
392
+ headers = {
393
+ "Authorization": f"Bearer {self.token}"
394
+ }
395
+
396
+ timeout_settings = aiohttp.ClientTimeout(
397
+ total=timeout,
398
+ connect=30,
399
+ sock_connect=30,
400
+ sock_read=timeout
401
+ )
402
+
403
+ try:
404
+ async with aiohttp.ClientSession(timeout=timeout_settings) as session:
405
+ # Use the new API endpoint with query parameters
406
+ url = f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/files"
407
+ params = {"path": quote(path)}
408
+
409
+ async with session.get(
410
+ url,
411
+ headers=headers,
412
+ params=params
413
+ ) as response:
414
+ if response.status != 200:
415
+ error_text = await response.text()
416
+ return SandboxResponse(
417
+ success=False,
418
+ error=f"Download failed: {error_text}"
419
+ )
420
+
421
+ # Read the content
422
+ content = await response.read()
423
+ size = len(content)
424
+
425
+ return SandboxResponse(
426
+ success=True,
427
+ data={
428
+ 'content': content,
429
+ 'size': size
430
+ }
431
+ )
432
+
433
+ except Exception as e:
434
+ return SandboxResponse(
435
+ success=False,
436
+ error=f"Failed to download file: {str(e)}"
437
+ )
438
+
@@ -0,0 +1,52 @@
1
+ import aiohttp
2
+ import asyncio
3
+ from typing import Optional, List, Dict, Any
4
+
5
+ from .base import BaseSubmodule
6
+ from ..models import SandboxResponse, StreamEvent
7
+
8
+ class ShellModule(BaseSubmodule):
9
+ """Shell command execution for the sandbox environment.
10
+
11
+ This module provides methods for executing shell commands in the sandbox.
12
+ """
13
+
14
+ async def execute(
15
+ self,
16
+ command: str,
17
+ args: Optional[List[str]] = None,
18
+ timeout: int = 30
19
+ ) -> SandboxResponse:
20
+ """Execute a shell command in the sandbox.
21
+
22
+ Args:
23
+ command: The shell command to execute
24
+ args: Optional list of arguments for the command
25
+ timeout: Maximum execution time in seconds
26
+
27
+ Returns:
28
+ SandboxResponse containing execution results
29
+ """
30
+ ready = await self._ensure_ready()
31
+ if not ready.success:
32
+ return ready
33
+
34
+ headers = self._get_headers()
35
+
36
+ data = {
37
+ "command": command,
38
+ "args": args or [],
39
+ "timeout": timeout
40
+ }
41
+
42
+ try:
43
+ async with aiohttp.ClientSession() as session:
44
+ async with session.post(
45
+ f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/execute/shell",
46
+ headers=headers,
47
+ json=data
48
+ ) as response:
49
+ return await self._handle_response(response)
50
+
51
+ except Exception as e:
52
+ return SandboxResponse(success=False, error=f"Connection error: {str(e)}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-computer-client
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Python client for interacting with the AI Computer service
5
5
  Project-URL: Homepage, https://github.com/ColeMurray/ai-computer-client-python
6
6
  Project-URL: Documentation, https://github.com/ColeMurray/ai-computer-client-python#readme
@@ -23,6 +23,9 @@ Provides-Extra: dev
23
23
  Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
24
24
  Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
25
25
  Requires-Dist: pytest>=7.0.0; extra == 'dev'
26
+ Provides-Extra: integration
27
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'integration'
28
+ Requires-Dist: pytest>=7.0.0; extra == 'integration'
26
29
  Description-Content-Type: text/markdown
27
30
 
28
31
  # AI Computer Python Client
@@ -201,14 +204,34 @@ class StreamEvent:
201
204
 
202
205
  ### Running Tests
203
206
 
204
- ```bash
205
- # Install development dependencies
206
- pip install -e ".[dev]"
207
+ To run the unit tests:
207
208
 
208
- # Run tests
209
+ ```bash
209
210
  pytest
210
211
  ```
211
212
 
213
+ ### Running Integration Tests
214
+
215
+ We have a comprehensive suite of integration tests that validate the client against the live API. These tests are automatically run as part of our CI/CD pipeline before each release.
216
+
217
+ To run the integration tests locally:
218
+
219
+ 1. Set the required environment variables:
220
+
221
+ ```bash
222
+ export AI_COMPUTER_API_KEY="your_api_key_here"
223
+ # Optional: Use a specific sandbox ID (if not provided, a new one will be created)
224
+ export AI_COMPUTER_SANDBOX_ID="optional_sandbox_id"
225
+ ```
226
+
227
+ 2. Run the tests:
228
+
229
+ ```bash
230
+ python -m integration_tests.test_integration
231
+ ```
232
+
233
+ For more details, see the [Integration Tests README](integration_tests/README.md).
234
+
212
235
  ### Contributing
213
236
 
214
237
  1. Fork the repository
@@ -0,0 +1,12 @@
1
+ ai_computer/__init__.py,sha256=0L4QSM3q4zWzYqNwisOqF_8ObcMdacmlXHbn55RX9YU,364
2
+ ai_computer/client.py,sha256=U-xm04Koy429TbiMwJTURLeRH3NWy3lJFX5i7rvvacs,15090
3
+ ai_computer/models.py,sha256=JG3gTKqCVrKlKsr4REAq5tb-WmZttn78LzxlrNiOJsQ,1214
4
+ ai_computer/submodules/__init__.py,sha256=kz4NTuF9r3i_VwTLWsyRHaHKereyq0kFe1HrfKQLtB4,162
5
+ ai_computer/submodules/base.py,sha256=3WoURENRnho26PkdghKM8S9s3-GYr11CwZidXhs-fFM,2530
6
+ ai_computer/submodules/code.py,sha256=8GNpgL-6aVpD1UNtq-nvtvnMQy72dWKe247YA4cLsOs,11201
7
+ ai_computer/submodules/filesystem.py,sha256=7x3g6Cr8PDKIuc9ogETuVjcOAlFKoHXhUcuntgRJv3s,15831
8
+ ai_computer/submodules/shell.py,sha256=lcy4CpgoJWN6tsGi-IUb6PQpywLSNOD3_lt_yvSU6Y8,1632
9
+ ai_computer_client-0.3.4.dist-info/METADATA,sha256=vVUG7UlP2FxGwugFbRm4gdL_JzLW1vt-S_kOH1W6Ugk,6407
10
+ ai_computer_client-0.3.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
+ ai_computer_client-0.3.4.dist-info/licenses/LICENSE,sha256=N_0S5G1Wik2LWVDViJMAM0Z-6vTBX1bvDjb8vouBA-c,1068
12
+ ai_computer_client-0.3.4.dist-info/RECORD,,
@@ -1,6 +0,0 @@
1
- ai_computer/__init__.py,sha256=GlAloQ10Yg8fIBKNj9HXovNHWAq9PsazLtYCIS1jOig,197
2
- ai_computer/client.py,sha256=fo0OxmbZPYsSCWeYWkBzEGGDQ7bvx0qqvijDkr8zO8M,28733
3
- ai_computer_client-0.3.2.dist-info/METADATA,sha256=aFEKdik1aa7F2hBhhWImmdewJgekuXnqDU5QJoPKzpU,5658
4
- ai_computer_client-0.3.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
5
- ai_computer_client-0.3.2.dist-info/licenses/LICENSE,sha256=N_0S5G1Wik2LWVDViJMAM0Z-6vTBX1bvDjb8vouBA-c,1068
6
- ai_computer_client-0.3.2.dist-info/RECORD,,