hopx-ai 0.1.17__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.

@@ -0,0 +1,489 @@
1
+ """File operations resource for Bunnyshell Sandboxes."""
2
+
3
+ from typing import List, Optional, Union, AsyncIterator, Dict, Any
4
+ import logging
5
+ from .models import FileInfo
6
+ from ._agent_client import AgentHTTPClient
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class Files:
12
+ """
13
+ File operations resource.
14
+
15
+ Provides methods for reading, writing, uploading, downloading, and managing files
16
+ inside the sandbox.
17
+
18
+ Features:
19
+ - Text and binary file support
20
+ - Automatic retry with exponential backoff
21
+ - Connection pooling for efficiency
22
+ - Proper error handling
23
+
24
+ Example:
25
+ >>> sandbox = Sandbox.create(template="code-interpreter")
26
+ >>>
27
+ >>> # Text files
28
+ >>> sandbox.files.write('/workspace/hello.py', 'print("Hello, World!")')
29
+ >>> content = sandbox.files.read('/workspace/hello.py')
30
+ >>>
31
+ >>> # Binary files
32
+ >>> sandbox.files.write_bytes('/workspace/image.png', image_bytes)
33
+ >>> data = sandbox.files.read_bytes('/workspace/image.png')
34
+ >>>
35
+ >>> # List files
36
+ >>> files = sandbox.files.list('/workspace')
37
+ >>> for f in files:
38
+ ... print(f"{f.name}: {f.size_kb:.2f} KB")
39
+ """
40
+
41
+ def __init__(self, client: AgentHTTPClient, sandbox: Optional[Any] = None):
42
+ """
43
+ Initialize Files resource.
44
+
45
+ Args:
46
+ client: Shared agent HTTP client
47
+ sandbox: Parent sandbox instance for lazy WebSocket init
48
+ """
49
+ self._client = client
50
+ self._sandbox = sandbox
51
+ logger.debug("Files resource initialized")
52
+
53
+ def read(self, path: str, *, timeout: Optional[int] = None) -> str:
54
+ """
55
+ Read text file contents.
56
+
57
+ For binary files, use read_bytes() instead.
58
+
59
+ Args:
60
+ path: File path (e.g., '/workspace/data.txt')
61
+ timeout: Request timeout in seconds (overrides default)
62
+
63
+ Returns:
64
+ File contents as string
65
+
66
+ Raises:
67
+ FileNotFoundError: If file doesn't exist
68
+ FileOperationError: If read fails
69
+
70
+ Example:
71
+ >>> content = sandbox.files.read('/workspace/data.txt')
72
+ >>> print(content)
73
+ """
74
+ logger.debug(f"Reading text file: {path}")
75
+
76
+ response = self._client.get(
77
+ "/files/read",
78
+ params={"path": path},
79
+ operation="read file",
80
+ context={"path": path},
81
+ timeout=timeout
82
+ )
83
+
84
+ data = response.json()
85
+ return data.get("content", "")
86
+
87
+ def read_bytes(self, path: str, *, timeout: Optional[int] = None) -> bytes:
88
+ """
89
+ Read binary file contents.
90
+
91
+ Use this for images, PDFs, or any binary data.
92
+
93
+ Args:
94
+ path: File path (e.g., '/workspace/plot.png')
95
+ timeout: Request timeout in seconds (overrides default)
96
+
97
+ Returns:
98
+ File contents as bytes
99
+
100
+ Raises:
101
+ FileNotFoundError: If file doesn't exist
102
+ FileOperationError: If read fails
103
+
104
+ Example:
105
+ >>> # Read matplotlib plot
106
+ >>> plot_data = sandbox.files.read_bytes('/workspace/plot.png')
107
+ >>> with open('local_plot.png', 'wb') as f:
108
+ ... f.write(plot_data)
109
+ """
110
+ logger.debug(f"Reading binary file: {path}")
111
+
112
+ response = self._client.get(
113
+ "/files/download",
114
+ params={"path": path},
115
+ operation="read binary file",
116
+ context={"path": path},
117
+ timeout=timeout
118
+ )
119
+
120
+ return response.content
121
+
122
+ def write(
123
+ self,
124
+ path: str,
125
+ content: str,
126
+ mode: str = "0644",
127
+ *,
128
+ timeout: Optional[int] = None
129
+ ) -> None:
130
+ """
131
+ Write text file contents.
132
+
133
+ For binary files, use write_bytes() instead.
134
+
135
+ Args:
136
+ path: File path (e.g., '/workspace/output.txt')
137
+ content: File contents to write (string)
138
+ mode: File permissions (default: '0644')
139
+ timeout: Request timeout in seconds (overrides default)
140
+
141
+ Raises:
142
+ FileOperationError: If write fails
143
+
144
+ Example:
145
+ >>> sandbox.files.write('/workspace/hello.py', 'print("Hello!")')
146
+ >>>
147
+ >>> # With custom permissions
148
+ >>> sandbox.files.write('/workspace/script.sh', '#!/bin/bash\\necho hi', mode='0755')
149
+ """
150
+ logger.debug(f"Writing text file: {path} ({len(content)} chars)")
151
+
152
+ self._client.post(
153
+ "/files/write",
154
+ json={
155
+ "path": path,
156
+ "content": content,
157
+ "mode": mode
158
+ },
159
+ operation="write file",
160
+ context={"path": path},
161
+ timeout=timeout
162
+ )
163
+
164
+ def write_bytes(
165
+ self,
166
+ path: str,
167
+ content: bytes,
168
+ mode: str = "0644",
169
+ *,
170
+ timeout: Optional[int] = None
171
+ ) -> None:
172
+ """
173
+ Write binary file contents.
174
+
175
+ Use this for images, PDFs, or any binary data.
176
+
177
+ Args:
178
+ path: File path (e.g., '/workspace/image.png')
179
+ content: File contents to write (bytes)
180
+ mode: File permissions (default: '0644')
181
+ timeout: Request timeout in seconds (overrides default)
182
+
183
+ Raises:
184
+ FileOperationError: If write fails
185
+
186
+ Example:
187
+ >>> # Save image
188
+ >>> with open('image.png', 'rb') as f:
189
+ ... image_data = f.read()
190
+ >>> sandbox.files.write_bytes('/workspace/image.png', image_data)
191
+ """
192
+ logger.debug(f"Writing binary file: {path} ({len(content)} bytes)")
193
+
194
+ # Encode bytes to base64 for JSON transport
195
+ import base64
196
+ content_b64 = base64.b64encode(content).decode('ascii')
197
+
198
+ self._client.post(
199
+ "/files/write",
200
+ json={
201
+ "path": path,
202
+ "content": content_b64,
203
+ "mode": mode,
204
+ "encoding": "base64"
205
+ },
206
+ operation="write binary file",
207
+ context={"path": path},
208
+ timeout=timeout
209
+ )
210
+
211
+ def list(self, path: str = "/workspace", *, timeout: Optional[int] = None) -> List[FileInfo]:
212
+ """
213
+ List directory contents.
214
+
215
+ Args:
216
+ path: Directory path (default: '/workspace')
217
+ timeout: Request timeout in seconds (overrides default)
218
+
219
+ Returns:
220
+ List of FileInfo objects
221
+
222
+ Raises:
223
+ FileNotFoundError: If directory doesn't exist
224
+ FileOperationError: If list fails
225
+
226
+ Example:
227
+ >>> files = sandbox.files.list('/workspace')
228
+ >>> for f in files:
229
+ ... if f.is_file:
230
+ ... print(f"📄 {f.name}: {f.size_kb:.2f} KB")
231
+ ... else:
232
+ ... print(f"📁 {f.name}/")
233
+ """
234
+ logger.debug(f"Listing directory: {path}")
235
+
236
+ response = self._client.get(
237
+ "/files/list",
238
+ params={"path": path},
239
+ operation="list directory",
240
+ context={"path": path},
241
+ timeout=timeout
242
+ )
243
+
244
+ data = response.json()
245
+
246
+ files = []
247
+ for item in data.get("files", []):
248
+ files.append(FileInfo(
249
+ name=item.get("name", ""),
250
+ path=item.get("path", ""),
251
+ size=item.get("size", 0),
252
+ is_directory=item.get("is_directory", item.get("is_dir", False)), # Support both
253
+ permissions=item.get("permissions", item.get("mode", "")), # Support both
254
+ modified_time=item.get("modified_time", item.get("modified")) # Support both
255
+ ))
256
+
257
+ return files
258
+
259
+ def upload(
260
+ self,
261
+ local_path: str,
262
+ remote_path: str,
263
+ *,
264
+ timeout: Optional[int] = None
265
+ ) -> None:
266
+ """
267
+ Upload file from local filesystem to sandbox.
268
+
269
+ Args:
270
+ local_path: Path to local file
271
+ remote_path: Destination path in sandbox
272
+ timeout: Request timeout in seconds (overrides default, recommended: 60+)
273
+
274
+ Raises:
275
+ FileNotFoundError: If local file doesn't exist
276
+ FileOperationError: If upload fails
277
+
278
+ Example:
279
+ >>> # Upload local file to sandbox
280
+ >>> sandbox.files.upload('./data.csv', '/workspace/data.csv')
281
+ >>>
282
+ >>> # Upload with custom timeout for large file
283
+ >>> sandbox.files.upload('./large.zip', '/workspace/large.zip', timeout=120)
284
+ """
285
+ logger.debug(f"Uploading file: {local_path} -> {remote_path}")
286
+
287
+ with open(local_path, 'rb') as f:
288
+ self._client.post(
289
+ "/files/upload",
290
+ files={"file": f},
291
+ data={"path": remote_path},
292
+ operation="upload file",
293
+ context={"path": remote_path},
294
+ timeout=timeout or 60 # Default 60s for uploads
295
+ )
296
+
297
+ def download(
298
+ self,
299
+ remote_path: str,
300
+ local_path: str,
301
+ *,
302
+ timeout: Optional[int] = None
303
+ ) -> None:
304
+ """
305
+ Download file from sandbox to local filesystem.
306
+
307
+ Args:
308
+ remote_path: Path in sandbox
309
+ local_path: Destination path on local filesystem
310
+ timeout: Request timeout in seconds (overrides default, recommended: 60+)
311
+
312
+ Raises:
313
+ FileNotFoundError: If file doesn't exist in sandbox
314
+ FileOperationError: If download fails
315
+
316
+ Example:
317
+ >>> # Download file from sandbox
318
+ >>> sandbox.files.download('/workspace/result.csv', './result.csv')
319
+ >>>
320
+ >>> # Download plot
321
+ >>> sandbox.files.download('/workspace/plot.png', './plot.png')
322
+ """
323
+ logger.debug(f"Downloading file: {remote_path} -> {local_path}")
324
+
325
+ response = self._client.get(
326
+ "/files/download",
327
+ params={"path": remote_path},
328
+ operation="download file",
329
+ context={"path": remote_path},
330
+ timeout=timeout or 60 # Default 60s for downloads
331
+ )
332
+
333
+ with open(local_path, 'wb') as f:
334
+ f.write(response.content)
335
+
336
+ def exists(self, path: str, *, timeout: Optional[int] = None) -> bool:
337
+ """
338
+ Check if file or directory exists.
339
+
340
+ Args:
341
+ path: File or directory path
342
+ timeout: Request timeout in seconds (overrides default)
343
+
344
+ Returns:
345
+ True if exists, False otherwise
346
+
347
+ Example:
348
+ >>> if sandbox.files.exists('/workspace/data.csv'):
349
+ ... print("File exists!")
350
+ ... else:
351
+ ... print("File not found")
352
+ """
353
+ logger.debug(f"Checking if exists: {path}")
354
+
355
+ try:
356
+ response = self._client.get(
357
+ "/files/exists",
358
+ params={"path": path},
359
+ operation="check file exists",
360
+ context={"path": path},
361
+ timeout=timeout or 10
362
+ )
363
+ data = response.json()
364
+ return data.get("exists", False)
365
+ except Exception:
366
+ return False
367
+
368
+ def remove(self, path: str, *, timeout: Optional[int] = None) -> None:
369
+ """
370
+ Delete file or directory.
371
+
372
+ Args:
373
+ path: Path to file or directory to delete
374
+ timeout: Request timeout in seconds (overrides default)
375
+
376
+ Raises:
377
+ FileNotFoundError: If file doesn't exist
378
+ FileOperationError: If delete fails
379
+
380
+ Example:
381
+ >>> # Remove file
382
+ >>> sandbox.files.remove('/workspace/temp.txt')
383
+ >>>
384
+ >>> # Remove directory (recursive)
385
+ >>> sandbox.files.remove('/workspace/old_data')
386
+ """
387
+ logger.debug(f"Removing: {path}")
388
+
389
+ self._client.delete(
390
+ "/files/remove",
391
+ params={"path": path},
392
+ operation="remove file",
393
+ context={"path": path},
394
+ timeout=timeout
395
+ )
396
+
397
+ def mkdir(self, path: str, *, timeout: Optional[int] = None) -> None:
398
+ """
399
+ Create directory.
400
+
401
+ Args:
402
+ path: Directory path to create
403
+ timeout: Request timeout in seconds (overrides default)
404
+
405
+ Raises:
406
+ FileOperationError: If mkdir fails
407
+
408
+ Example:
409
+ >>> # Create directory
410
+ >>> sandbox.files.mkdir('/workspace/data')
411
+ >>>
412
+ >>> # Create nested directories
413
+ >>> sandbox.files.mkdir('/workspace/project/src')
414
+ """
415
+ logger.debug(f"Creating directory: {path}")
416
+
417
+ self._client.post(
418
+ "/files/mkdir",
419
+ json={"path": path},
420
+ operation="create directory",
421
+ context={"path": path},
422
+ timeout=timeout
423
+ )
424
+
425
+ async def watch(
426
+ self,
427
+ path: str = "/workspace",
428
+ *,
429
+ timeout: Optional[int] = None
430
+ ) -> AsyncIterator[Dict[str, Any]]:
431
+ """
432
+ Watch filesystem for changes via WebSocket.
433
+
434
+ Stream file system events (create, modify, delete, rename) in real-time.
435
+
436
+ Args:
437
+ path: Path to watch (default: /workspace)
438
+ timeout: Connection timeout in seconds
439
+
440
+ Yields:
441
+ Change event dictionaries:
442
+ - {"type": "change", "path": "...", "event": "created", "timestamp": "..."}
443
+ - {"type": "change", "path": "...", "event": "modified", "timestamp": "..."}
444
+ - {"type": "change", "path": "...", "event": "deleted", "timestamp": "..."}
445
+ - {"type": "change", "path": "...", "event": "renamed", "timestamp": "..."}
446
+
447
+ Note:
448
+ Requires websockets library: pip install websockets
449
+
450
+ Example:
451
+ >>> import asyncio
452
+ >>>
453
+ >>> async def watch_files():
454
+ ... sandbox = Sandbox.create(template="code-interpreter")
455
+ ...
456
+ ... # Start watching
457
+ ... async for event in sandbox.files.watch("/workspace"):
458
+ ... print(f"{event['event']}: {event['path']}")
459
+ ...
460
+ ... # Stop after 10 events
461
+ ... if event_count >= 10:
462
+ ... break
463
+ >>>
464
+ >>> asyncio.run(watch_files())
465
+ """
466
+ # Lazy-load WebSocket client from sandbox if needed
467
+ if self._sandbox is not None:
468
+ self._sandbox._ensure_ws_client()
469
+ ws_client = self._sandbox._ws_client
470
+ else:
471
+ raise RuntimeError(
472
+ "WebSocket client not available. "
473
+ "File watching requires websockets library: pip install websockets"
474
+ )
475
+
476
+ # Connect to file watcher endpoint
477
+ async with await ws_client.connect("/files/watch", timeout=timeout) as ws:
478
+ # Send watch request
479
+ await ws_client.send_message(ws, {
480
+ "action": "watch",
481
+ "path": path
482
+ })
483
+
484
+ # Stream change events
485
+ async for message in ws_client.iter_messages(ws):
486
+ yield message
487
+
488
+ def __repr__(self) -> str:
489
+ return f"<Files client={self._client}>"
@@ -0,0 +1,184 @@
1
+ """Async interactive terminal access via WebSocket."""
2
+
3
+ import logging
4
+ from typing import Optional, AsyncIterator, Dict, Any
5
+
6
+ try:
7
+ from websockets.client import WebSocketClientProtocol
8
+ import websockets
9
+ WEBSOCKETS_AVAILABLE = True
10
+ except ImportError:
11
+ WEBSOCKETS_AVAILABLE = False
12
+ WebSocketClientProtocol = Any # type: ignore
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class AsyncTerminal:
18
+ """
19
+ Async interactive terminal resource with PTY support via WebSocket.
20
+
21
+ Provides real-time terminal access to the sandbox for interactive commands.
22
+
23
+ Features:
24
+ - Full PTY support
25
+ - Real-time output streaming
26
+ - Terminal resize support
27
+ - Process exit notifications
28
+
29
+ Example:
30
+ >>> async def interactive_terminal():
31
+ ... sandbox = await AsyncSandbox.create(template="code-interpreter")
32
+ ...
33
+ ... # Connect to terminal
34
+ ... async with await sandbox.terminal.connect() as ws:
35
+ ... # Send command
36
+ ... await sandbox.terminal.send_input(ws, "ls -la\\n")
37
+ ...
38
+ ... # Receive output
39
+ ... async for message in sandbox.terminal.iter_output(ws):
40
+ ... if message['type'] == 'output':
41
+ ... print(message['data'], end='')
42
+ ... elif message['type'] == 'exit':
43
+ ... print(f"\\nProcess exited: {message['code']}")
44
+ ... break
45
+ """
46
+
47
+ def __init__(self, sandbox):
48
+ """
49
+ Initialize AsyncTerminal resource.
50
+
51
+ Args:
52
+ sandbox: Parent AsyncSandbox instance
53
+ """
54
+ if not WEBSOCKETS_AVAILABLE:
55
+ raise ImportError(
56
+ "websockets library is required for terminal features. "
57
+ "Install with: pip install websockets"
58
+ )
59
+
60
+ self._sandbox = sandbox
61
+ self._ws_url = None
62
+ logger.debug("AsyncTerminal resource initialized")
63
+
64
+ async def _get_ws_url(self) -> str:
65
+ """Get WebSocket URL from sandbox."""
66
+ if self._ws_url is None:
67
+ info = await self._sandbox.get_info()
68
+ agent_url = info.public_host.rstrip('/')
69
+ # Convert https:// to wss://
70
+ self._ws_url = agent_url.replace('https://', 'wss://').replace('http://', 'ws://')
71
+ return self._ws_url
72
+
73
+ async def connect(
74
+ self,
75
+ *,
76
+ timeout: Optional[int] = 30
77
+ ) -> WebSocketClientProtocol:
78
+ """
79
+ Connect to interactive terminal.
80
+
81
+ Args:
82
+ timeout: Connection timeout in seconds
83
+
84
+ Returns:
85
+ WebSocket connection (use with async context manager)
86
+
87
+ Example:
88
+ >>> async with await sandbox.terminal.connect() as ws:
89
+ ... await sandbox.terminal.send_input(ws, "echo 'Hello'\\n")
90
+ ... async for msg in sandbox.terminal.iter_output(ws):
91
+ ... print(msg['data'], end='')
92
+ ... if msg['type'] == 'exit':
93
+ ... break
94
+ """
95
+ ws_url = await self._get_ws_url()
96
+
97
+ # Connect to WebSocket (no auth needed - agent handles it)
98
+ ws = await websockets.connect(
99
+ f"{ws_url}/terminal",
100
+ open_timeout=timeout
101
+ )
102
+
103
+ return ws
104
+
105
+ async def send_input(
106
+ self,
107
+ ws: WebSocketClientProtocol,
108
+ data: str
109
+ ) -> None:
110
+ """
111
+ Send input to terminal.
112
+
113
+ Args:
114
+ ws: WebSocket connection
115
+ data: Input data (include \\n for commands)
116
+
117
+ Example:
118
+ >>> await terminal.send_input(ws, "ls -la\\n")
119
+ >>> await terminal.send_input(ws, "cd /workspace\\n")
120
+ """
121
+ import json
122
+ await ws.send(json.dumps({
123
+ "type": "input",
124
+ "data": data
125
+ }))
126
+
127
+ async def resize(
128
+ self,
129
+ ws: WebSocketClientProtocol,
130
+ cols: int,
131
+ rows: int
132
+ ) -> None:
133
+ """
134
+ Resize terminal window.
135
+
136
+ Args:
137
+ ws: WebSocket connection
138
+ cols: Number of columns
139
+ rows: Number of rows
140
+
141
+ Example:
142
+ >>> await terminal.resize(ws, cols=120, rows=40)
143
+ """
144
+ import json
145
+ await ws.send(json.dumps({
146
+ "type": "resize",
147
+ "cols": cols,
148
+ "rows": rows
149
+ }))
150
+
151
+ async def iter_output(
152
+ self,
153
+ ws: WebSocketClientProtocol
154
+ ) -> AsyncIterator[Dict[str, Any]]:
155
+ """
156
+ Iterate over terminal output messages.
157
+
158
+ Args:
159
+ ws: WebSocket connection
160
+
161
+ Yields:
162
+ Message dictionaries:
163
+ - {"type": "output", "data": "..."}
164
+ - {"type": "exit", "code": 0}
165
+
166
+ Example:
167
+ >>> async for message in terminal.iter_output(ws):
168
+ ... if message['type'] == 'output':
169
+ ... print(message['data'], end='')
170
+ ... elif message['type'] == 'exit':
171
+ ... print(f"Exit code: {message['code']}")
172
+ ... break
173
+ """
174
+ import json
175
+ async for message in ws:
176
+ try:
177
+ data = json.loads(message)
178
+ yield data
179
+ except json.JSONDecodeError:
180
+ # Skip invalid messages
181
+ continue
182
+
183
+ def __repr__(self) -> str:
184
+ return f"<AsyncTerminal sandbox={self._sandbox.sandbox_id}>"