ai-computer-client 0.3.3__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/client.py CHANGED
@@ -6,10 +6,14 @@ from dataclasses import dataclass
6
6
  import os
7
7
  import mimetypes
8
8
  from pathlib import Path
9
+ import logging
9
10
 
10
11
  from .models import SandboxResponse, StreamEvent, FileOperationResponse
11
12
  from .submodules import FileSystemModule, ShellModule, CodeModule
12
13
 
14
+ # Set up logging
15
+ logger = logging.getLogger(__name__)
16
+
13
17
  @dataclass
14
18
  class SandboxResponse:
15
19
  """Response from sandbox operations.
@@ -137,19 +141,23 @@ class SandboxClient:
137
141
  """Wait for the sandbox to be ready.
138
142
 
139
143
  Args:
140
- max_attempts: Maximum number of attempts to check if sandbox is ready
144
+ max_attempts: Maximum number of attempts to check status
141
145
  delay: Delay between attempts in seconds
142
146
 
143
147
  Returns:
144
- SandboxResponse indicating if the sandbox is ready
148
+ SandboxResponse with success=True if sandbox is ready
145
149
  """
146
- if not self.token or not self.sandbox_id:
147
- return SandboxResponse(success=False, error="Client not properly initialized. Call setup() first")
150
+ if not self.sandbox_id:
151
+ return SandboxResponse(
152
+ success=False,
153
+ error="Sandbox ID not set. Call setup() first."
154
+ )
148
155
 
149
156
  headers = {"Authorization": f"Bearer {self.token}"}
150
157
 
151
158
  for attempt in range(max_attempts):
152
159
  try:
160
+ logger.debug(f"Checking sandbox status (attempt {attempt + 1}/{max_attempts})...")
153
161
  async with aiohttp.ClientSession() as session:
154
162
  async with session.get(
155
163
  f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/status",
@@ -157,13 +165,16 @@ class SandboxClient:
157
165
  ) as response:
158
166
  if response.status != 200:
159
167
  # If we get an error, wait and try again
168
+ logger.debug(f"Waiting {delay}s before next attempt...")
160
169
  await asyncio.sleep(delay)
161
170
  continue
162
171
 
163
172
  data = await response.json()
164
- status = data.get("status")
173
+ status = data.get("status", "").lower()
174
+ logger.debug(f"Current sandbox status: {status}")
165
175
 
166
- if status == "ready":
176
+ # Check for both 'ready' and 'running' status as indicators that the sandbox is ready
177
+ if status == "ready" or status == "running":
167
178
  return SandboxResponse(success=True, data=data)
168
179
  elif status == "error":
169
180
  return SandboxResponse(
@@ -172,13 +183,18 @@ class SandboxClient:
172
183
  )
173
184
 
174
185
  # If not ready yet, wait and try again
186
+ logger.debug(f"Waiting {delay}s before next attempt...")
175
187
  await asyncio.sleep(delay)
176
188
 
177
189
  except Exception as e:
178
190
  # If we get an exception, wait and try again
191
+ logger.error(f"Error checking sandbox status: {str(e)}")
179
192
  await asyncio.sleep(delay)
180
193
 
181
- return SandboxResponse(success=False, error="Timed out waiting for sandbox to be ready")
194
+ return SandboxResponse(
195
+ success=False,
196
+ error=f"Sandbox not ready after {max_attempts} attempts"
197
+ )
182
198
 
183
199
  async def cleanup(self) -> SandboxResponse:
184
200
  """Delete the sandbox.
@@ -367,7 +383,7 @@ class SandboxClient:
367
383
  await self.wait_for_ready()
368
384
 
369
385
  try:
370
- response = await self.fs.read_file(remote_path, encoding=None)
386
+ response = await self.fs.download_bytes(remote_path, timeout=timeout or 300)
371
387
  if response.success:
372
388
  return response.data.get('content')
373
389
  else:
@@ -44,7 +44,7 @@ class CodeModule(BaseSubmodule):
44
44
  try:
45
45
  async with aiohttp.ClientSession() as session:
46
46
  async with session.post(
47
- f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/execute/code",
47
+ f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/execute",
48
48
  headers=headers,
49
49
  json=data
50
50
  ) as response:
@@ -87,7 +87,7 @@ class CodeModule(BaseSubmodule):
87
87
  try:
88
88
  async with aiohttp.ClientSession() as session:
89
89
  async with session.post(
90
- f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/execute/code/stream",
90
+ f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/execute/stream",
91
91
  headers=headers,
92
92
  json=data
93
93
  ) as response:
@@ -186,7 +186,7 @@ result
186
186
  try:
187
187
  async with aiohttp.ClientSession() as session:
188
188
  async with session.post(
189
- f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/execute/file",
189
+ f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/execute",
190
190
  headers=headers,
191
191
  json=data
192
192
  ) as response:
@@ -4,6 +4,7 @@ import os
4
4
  import mimetypes
5
5
  from pathlib import Path
6
6
  from typing import Optional, Union, BinaryIO, Dict, Any
7
+ from urllib.parse import quote
7
8
 
8
9
  from .base import BaseSubmodule
9
10
  from ..models import FileOperationResponse, SandboxResponse
@@ -122,14 +123,14 @@ class FileSystemModule(BaseSubmodule):
122
123
  async def download_file(
123
124
  self,
124
125
  remote_path: str,
125
- local_path: Optional[Union[str, Path]] = None,
126
- timeout: int = 300 # 5 minutes
126
+ local_path: Optional[str] = None,
127
+ timeout: int = 300
127
128
  ) -> FileOperationResponse:
128
- """Download a file from the sandbox.
129
+ """Download a file from the sandbox to the local filesystem.
129
130
 
130
131
  Args:
131
132
  remote_path: Path to the file in the sandbox
132
- local_path: Local path to save the file (if None, uses the filename from remote_path)
133
+ local_path: Local path to save the file (defaults to basename of remote_path)
133
134
  timeout: Maximum download time in seconds
134
135
 
135
136
  Returns:
@@ -139,15 +140,9 @@ class FileSystemModule(BaseSubmodule):
139
140
  if not ready.success:
140
141
  return FileOperationResponse(
141
142
  success=False,
142
- error=ready.error or "Sandbox not ready"
143
+ error=ready.error
143
144
  )
144
145
 
145
- # Ensure path is absolute and normalize any double slashes
146
- if not remote_path.startswith('/'):
147
- remote_path = f"/{remote_path}"
148
- clean_path = '/'.join(part for part in remote_path.split('/') if part)
149
- clean_path = f"/{clean_path}"
150
-
151
146
  # Determine local path if not provided
152
147
  if local_path is None:
153
148
  local_path = os.path.basename(remote_path)
@@ -160,9 +155,12 @@ class FileSystemModule(BaseSubmodule):
160
155
  "Authorization": f"Bearer {self.token}"
161
156
  }
162
157
 
163
- params = {
164
- "path": remote_path
165
- }
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}"
166
164
 
167
165
  timeout_settings = aiohttp.ClientTimeout(
168
166
  total=timeout,
@@ -173,8 +171,12 @@ class FileSystemModule(BaseSubmodule):
173
171
 
174
172
  try:
175
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
+
176
178
  async with session.get(
177
- f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/files/download",
179
+ url,
178
180
  headers=headers,
179
181
  params=params
180
182
  ) as response:
@@ -182,7 +184,7 @@ class FileSystemModule(BaseSubmodule):
182
184
  error_text = await response.text()
183
185
  return FileOperationResponse(
184
186
  success=False,
185
- error=f"Download failed: {error_text}"
187
+ error=f"Failed to download file '{original_path}': {error_text}"
186
188
  )
187
189
 
188
190
  # Get content disposition header to extract filename
@@ -225,38 +227,36 @@ class FileSystemModule(BaseSubmodule):
225
227
  error=f"Download failed: {str(e)}"
226
228
  )
227
229
 
228
- async def read_file(self, path: str, encoding: str = 'utf-8') -> SandboxResponse:
229
- """Read a file from the sandbox and return its contents.
230
+ async def read_file(self, path: str, encoding: Optional[str] = 'utf-8') -> SandboxResponse:
231
+ """Read a file from the sandbox.
230
232
 
231
233
  Args:
232
234
  path: Path to the file in the sandbox
233
- encoding: Text encoding to use (set to None for binary files)
235
+ encoding: Text encoding to use (None for binary)
234
236
 
235
237
  Returns:
236
- SandboxResponse with the file contents in the data field
238
+ SandboxResponse with the file content
237
239
  """
238
240
  ready = await self._ensure_ready()
239
241
  if not ready.success:
240
242
  return ready
241
-
242
- # Ensure path is absolute and normalize any double slashes
243
+
244
+ # Ensure path is absolute
243
245
  if not path.startswith('/'):
244
246
  path = f"/{path}"
245
- clean_path = '/'.join(part for part in path.split('/') if part)
246
- clean_path = f"/{clean_path}"
247
-
247
+
248
248
  headers = {
249
249
  "Authorization": f"Bearer {self.token}"
250
250
  }
251
251
 
252
- params = {
253
- "path": clean_path
254
- }
255
-
256
252
  try:
257
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
258
  async with session.get(
259
- f"{self.base_url}/api/v1/sandbox/{self.sandbox_id}/files/download",
259
+ url,
260
260
  headers=headers,
261
261
  params=params
262
262
  ) as response:
@@ -370,4 +370,69 @@ class FileSystemModule(BaseSubmodule):
370
370
  # Clean up the temporary file
371
371
  if os.path.exists(temp_file.name):
372
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
+ )
373
438
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-computer-client
3
- Version: 0.3.3
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,12 +0,0 @@
1
- ai_computer/__init__.py,sha256=0L4QSM3q4zWzYqNwisOqF_8ObcMdacmlXHbn55RX9YU,364
2
- ai_computer/client.py,sha256=ozdVAp_I8l7lGI5zI8cQUN00RfJ_V8XhrkjspwHV5yI,14402
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=U0cLcB7iu90QS3BbFP31oNPJYgpgvYSWzxakhxMa3-A,11216
7
- ai_computer/submodules/filesystem.py,sha256=79gBefBIMj8qwFwTdVrpktOTqfvAfeZTf_jdTSxtcHs,13610
8
- ai_computer/submodules/shell.py,sha256=lcy4CpgoJWN6tsGi-IUb6PQpywLSNOD3_lt_yvSU6Y8,1632
9
- ai_computer_client-0.3.3.dist-info/METADATA,sha256=5kn2XaOh0LeQR7hexOsd1C1pOPrbr-pfWPLhDPUIaWU,5658
10
- ai_computer_client-0.3.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
- ai_computer_client-0.3.3.dist-info/licenses/LICENSE,sha256=N_0S5G1Wik2LWVDViJMAM0Z-6vTBX1bvDjb8vouBA-c,1068
12
- ai_computer_client-0.3.3.dist-info/RECORD,,