grasp-sdk 0.1.0__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 grasp-sdk might be problematic. Click here for more details.

@@ -0,0 +1,583 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Sandbox service for managing E2B sandbox lifecycle and operations.
4
+
5
+ This module provides Python implementation of the sandbox service,
6
+ equivalent to the TypeScript version in src/services/sandbox.service.ts
7
+ """
8
+
9
+ import asyncio
10
+ import os
11
+ import tempfile
12
+ import time
13
+ from typing import Dict, Any, Optional, Union, List, Callable
14
+ from pathlib import Path
15
+ from e2b import AsyncSandbox
16
+
17
+ # Type definitions for E2B SDK
18
+ from typing import Any as CommandHandle, Any as CommandResult
19
+
20
+ from ..models import ISandboxConfig, SandboxStatus, IScriptOptions, ICommandOptions
21
+ from ..utils.logger import Logger, get_logger
22
+ from ..utils.config import get_config
23
+ from ..utils.auth import verify
24
+
25
+
26
+ class CommandEventEmitter:
27
+ """
28
+ Extended event emitter for background command execution.
29
+ Emits 'stdout', 'stderr', and 'exit' events.
30
+ """
31
+
32
+ def __init__(self, handle: Optional[Any] = None):
33
+ self.handle = handle
34
+ self._callbacks: Dict[str, List[Callable]] = {
35
+ 'stdout': [],
36
+ 'stderr': [],
37
+ 'exit': [],
38
+ 'error': []
39
+ }
40
+
41
+ def set_handle(self, handle: Any) -> None:
42
+ """Set the command handle (used internally)."""
43
+ self.handle = handle
44
+
45
+ def on(self, event: str, callback: Callable) -> None:
46
+ """Register event listener."""
47
+ if event not in self._callbacks:
48
+ self._callbacks[event] = []
49
+ self._callbacks[event].append(callback)
50
+
51
+ def emit(self, event: str, *args) -> None:
52
+ """Emit event to all registered listeners."""
53
+ if event in self._callbacks:
54
+ for callback in self._callbacks[event]:
55
+ try:
56
+ callback(*args)
57
+ except Exception as e:
58
+ # Ignore callback errors to prevent breaking the emitter
59
+ pass
60
+
61
+ async def wait(self) -> Any:
62
+ """Wait for command to complete."""
63
+ while not self.handle:
64
+ await asyncio.sleep(0.1)
65
+ return await self.handle.wait()
66
+
67
+ async def kill(self) -> None:
68
+ """Kill the running command."""
69
+ if not self.handle:
70
+ raise RuntimeError('Command handle not set')
71
+ await self.handle.kill()
72
+
73
+ def get_handle(self) -> Optional[Any]:
74
+ """Get the original command handle."""
75
+ return self.handle
76
+
77
+
78
+ class SandboxService:
79
+ """
80
+ E2B Sandbox service for managing sandbox lifecycle and operations.
81
+ """
82
+
83
+ def __init__(self, config: ISandboxConfig):
84
+ self.config = config
85
+ self.sandbox: Optional[Any] = None
86
+ self.status: SandboxStatus = SandboxStatus.STOPPED
87
+ self.logger = self._get_default_logger()
88
+
89
+ # Default working directory
90
+ self.DEFAULT_WORKING_DIRECTORY = '/home/user'
91
+
92
+ def _get_default_logger(self) -> Logger:
93
+ """Gets or creates a default logger instance."""
94
+ try:
95
+ return get_logger().child('SandboxService')
96
+ except Exception:
97
+ # If logger is not initialized, create a default one
98
+ from ..utils.logger import Logger
99
+ default_logger = Logger({
100
+ 'level': 'debug' if self.config.get('debug', False) else 'info',
101
+ 'console': True,
102
+ })
103
+ return default_logger.child('SandboxService')
104
+
105
+ @property
106
+ def id(self) -> Optional[str]:
107
+ """Get sandbox ID."""
108
+ return self.sandbox.sandbox_id if self.sandbox else None
109
+
110
+ @property
111
+ def workspace(self) -> str:
112
+ """Get workspace ID from API key."""
113
+ return self.config['key'][3:19]
114
+
115
+ @property
116
+ def is_debug(self) -> bool:
117
+ """Check if debug mode is enabled."""
118
+ return bool(self.config.get('debug', False))
119
+
120
+ @property
121
+ def timeout(self) -> int:
122
+ """Get timeout value."""
123
+ return self.config['timeout']
124
+
125
+ async def create_sandbox(self) -> None:
126
+ """
127
+ Creates and starts a new sandbox.
128
+
129
+ Raises:
130
+ RuntimeError: If sandbox creation fails
131
+ """
132
+
133
+ try:
134
+ self.status = SandboxStatus.CREATING
135
+ self.logger.info('Creating Grasp sandbox', {
136
+ 'templateId': self.config['templateId'],
137
+ })
138
+
139
+ # Verify authentication
140
+ res = await verify(self.config)
141
+ if not res['success']:
142
+ raise RuntimeError('Authorization failed.')
143
+
144
+ api_key = res['data']['token']
145
+
146
+ # Create sandbox
147
+
148
+ # Use Sandbox constructor directly (e2b SDK 1.5.3+)
149
+ self.sandbox = await AsyncSandbox.create(
150
+ template=self.config['templateId'],
151
+ api_key=api_key,
152
+ timeout=self.config['timeout'] // 1000 # Convert ms to seconds
153
+ )
154
+
155
+ self.status = SandboxStatus.RUNNING
156
+ self.logger.info('Grasp sandbox created successfully', {
157
+ 'sandboxId': getattr(self.sandbox, 'sandbox_id', 'unknown'),
158
+ })
159
+
160
+ except Exception as error:
161
+ self.status = SandboxStatus.ERROR
162
+ self.logger.error('Failed to create Grasp sandbox', str(error))
163
+ raise RuntimeError(f'Failed to create sandbox: {error}')
164
+
165
+ async def run_command(
166
+ self,
167
+ command: str,
168
+ options: Optional[ICommandOptions] = None,
169
+ quiet: bool = False
170
+ ) -> Union[Any, CommandEventEmitter, None]:
171
+ """
172
+ Runs a command in the sandbox.
173
+
174
+ Args:
175
+ command: Command to execute
176
+ options: Execution options
177
+ quiet: Suppress error logging
178
+
179
+ Returns:
180
+ CommandResult for synchronous execution,
181
+ CommandEventEmitter for background execution,
182
+ or None for nohup execution
183
+
184
+ Raises:
185
+ RuntimeError: If sandbox is not running or command fails
186
+ """
187
+ if not self.sandbox or self.status != SandboxStatus.RUNNING:
188
+ raise RuntimeError('Sandbox is not running. Call create_sandbox() first.')
189
+
190
+ if options is None:
191
+ options = {}
192
+
193
+ cwd = options.get('cwd', self.DEFAULT_WORKING_DIRECTORY)
194
+ timeout_ms = options.get('timeout', self.config['timeout'])
195
+ use_nohup = options.get('nohup', False)
196
+ in_background = options.get('inBackground', False)
197
+ envs = options.get('envs', {})
198
+
199
+ # print(f'command: {command}')
200
+ # print(f'🚀 options {options}')
201
+
202
+ try:
203
+ self.logger.debug('Running command in sandbox', {
204
+ 'command': command,
205
+ 'cwd': cwd,
206
+ 'timeout': timeout_ms,
207
+ 'nohup': use_nohup,
208
+ 'background': in_background
209
+ })
210
+
211
+ if not in_background:
212
+ # Foreground execution
213
+ final_command = command
214
+ if use_nohup:
215
+ # Create log directory
216
+ await self.sandbox.commands.run('mkdir -p ~/logs/grasp')
217
+
218
+ # Generate log file name using sandbox id
219
+ log_file = f'~/logs/grasp/log-{self.id}.log'
220
+ final_command = f'nohup {command} > {log_file} 2>&1 &'
221
+
222
+ if hasattr(self.sandbox, 'commands'):
223
+ result = await self.sandbox.commands.run(
224
+ final_command,
225
+ cwd=cwd,
226
+ timeout=timeout_ms,
227
+ envs=envs,
228
+ background=in_background,
229
+ )
230
+ else:
231
+ raise RuntimeError('Sandbox commands interface not available')
232
+
233
+ if hasattr(result, 'error') and result.error:
234
+ self.logger.error(result.stderr)
235
+
236
+ return None if use_nohup else result
237
+
238
+ else:
239
+ # Background execution
240
+ if use_nohup:
241
+ # Create log directory
242
+ await self.sandbox.commands.run('mkdir -p ~/logs/grasp')
243
+
244
+ # Generate log file name using sandbox id
245
+ log_file = f'~/logs/grasp/log-{self.id}.log'
246
+ nohup_command = f'nohup {command} > {log_file} 2>&1 &'
247
+ print(f"💬 {nohup_command}")
248
+ await self.sandbox.commands.run(
249
+ nohup_command,
250
+ cwd=cwd,
251
+ timeout=timeout_ms // 1000,
252
+ envs=envs,
253
+ background=in_background,
254
+ )
255
+ return None
256
+
257
+ # Create CommandEventEmitter for background execution
258
+ event_emitter = CommandEventEmitter()
259
+
260
+ # Schedule background execution
261
+ async def _execute_background():
262
+ try:
263
+ if self.sandbox and hasattr(self.sandbox, 'commands'):
264
+ commands_attr = getattr(self.sandbox, 'commands', None)
265
+ if not commands_attr:
266
+ raise RuntimeError("Sandbox commands not available")
267
+ handle = await commands_attr.run(
268
+ command,
269
+ cwd=cwd,
270
+ timeout=timeout_ms // 1000,
271
+ background=True,
272
+ envs=envs,
273
+ on_stdout=lambda data: (
274
+ self.logger.debug('Command stdout', {'data': data}),
275
+ event_emitter.emit('stdout', data)
276
+ )[1],
277
+ on_stderr=lambda data: (
278
+ self.logger.debug('Command stderr', {'data': data}),
279
+ event_emitter.emit('stderr', data)
280
+ )[1]
281
+ )
282
+
283
+ event_emitter.set_handle(handle)
284
+ else:
285
+ raise RuntimeError("Sandbox or commands not available")
286
+
287
+ # Wait for command completion (for non-nohup commands)
288
+ if not use_nohup:
289
+ try:
290
+ result = await handle.wait()
291
+ event_emitter.emit('exit', result.exit_code, result)
292
+ except Exception as error:
293
+ event_emitter.emit('error', error)
294
+
295
+ except Exception as error:
296
+ event_emitter.emit('error', error)
297
+
298
+ # Start background execution
299
+ asyncio.create_task(_execute_background())
300
+ return event_emitter
301
+
302
+ except Exception as error:
303
+ if not quiet:
304
+ self.logger.error('Command execution failed', str(error))
305
+
306
+ # Return error result as dict
307
+ return {
308
+ 'exit_code': 1,
309
+ 'stdout': '',
310
+ 'stderr': str(error)
311
+ }
312
+
313
+ async def run_script(
314
+ self,
315
+ code: str,
316
+ options: Optional[IScriptOptions] = None
317
+ ) -> Union[Any, CommandEventEmitter]:
318
+ """
319
+ Runs JavaScript code in the sandbox.
320
+
321
+ Args:
322
+ code: JavaScript code to execute
323
+ options: Script execution options
324
+
325
+ Returns:
326
+ CommandResult for synchronous execution,
327
+ CommandEventEmitter for background execution
328
+
329
+ Raises:
330
+ RuntimeError: If sandbox is not running or script execution fails
331
+ """
332
+ if not self.sandbox or self.status != SandboxStatus.RUNNING:
333
+ raise RuntimeError('Sandbox is not running. Call create_sandbox() first.')
334
+
335
+ if options is None:
336
+ options = {'type': 'cjs'}
337
+
338
+ try:
339
+ # Generate temporary file name in working directory
340
+ timestamp = int(time.time() * 1000)
341
+ extension = 'mjs' if options['type'] == 'esm' else 'js'
342
+ working_dir = options.get('cwd', self.DEFAULT_WORKING_DIRECTORY)
343
+ script_path = f'{working_dir}/script_{timestamp}.{extension}'
344
+
345
+ self.logger.debug('Running JavaScript code in sandbox', {
346
+ 'type': options['type'],
347
+ 'scriptPath': script_path,
348
+ 'codeLength': len(code),
349
+ })
350
+
351
+ # Write code to temporary file
352
+ await self.sandbox.files.write(script_path, code)
353
+
354
+ # Choose execution command based on type
355
+ pre_command = options.get('preCommand', '')
356
+ command = f'{pre_command}node {script_path}'
357
+
358
+ # Set environment variables
359
+ envs = options.get('envs', {})
360
+ envs['PLAYWRIGHT_BROWSERS_PATH'] = '0'
361
+
362
+ # Prepare command options
363
+ cmd_options: Optional[Any] = {
364
+ 'cwd': options.get('cwd'),
365
+ 'timeout': options.get('timeoutMs'),
366
+ 'inBackground': options.get('background', False),
367
+ 'nohup': options.get('nohup', False),
368
+ 'envs': envs,
369
+ }
370
+
371
+ # Execute the script
372
+ result = await self.run_command(command, cmd_options)
373
+
374
+ # Cleanup temporary file (if not background execution)
375
+ if not options.get('background', False):
376
+ try:
377
+ await self.run_command(f'rm -f {script_path}')
378
+ except Exception as cleanup_error:
379
+ self.logger.warn('Failed to cleanup script file', {
380
+ 'scriptPath': script_path,
381
+ 'error': cleanup_error,
382
+ })
383
+
384
+ return result
385
+
386
+ except Exception as error:
387
+ self.logger.error('Script execution failed', str(error))
388
+ raise RuntimeError(f'Failed to execute script: {error}')
389
+
390
+ def _is_binary_file(self, file_path: str) -> bool:
391
+ """
392
+ Check if a file is binary based on its extension.
393
+
394
+ Args:
395
+ file_path: File path to check
396
+
397
+ Returns:
398
+ True if file is binary, false otherwise
399
+ """
400
+ binary_extensions = {
401
+ '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.ico',
402
+ '.pdf', '.zip', '.tar', '.gz', '.rar', '.7z',
403
+ '.mp3', '.mp4', '.avi', '.mov', '.wmv', '.flv',
404
+ '.exe', '.dll', '.so', '.dylib',
405
+ '.bin', '.dat', '.db', '.sqlite'
406
+ }
407
+
408
+ ext = Path(file_path).suffix.lower()
409
+ return ext in binary_extensions
410
+
411
+ async def copy_file_from_sandbox(self, remote_path: str, local_path: str) -> None:
412
+ """
413
+ Copy file from sandbox to local filesystem.
414
+
415
+ Args:
416
+ remote_path: File path in sandbox
417
+ local_path: Local destination path
418
+
419
+ Raises:
420
+ RuntimeError: If sandbox is not running or copy fails
421
+ """
422
+ if not self.sandbox or self.status != SandboxStatus.RUNNING:
423
+ raise RuntimeError('Sandbox is not running. Call create_sandbox() first.')
424
+
425
+ try:
426
+ self.logger.debug('Copying file from sandbox', {
427
+ 'remotePath': remote_path,
428
+ 'localPath': local_path,
429
+ })
430
+
431
+ # Check if file is binary
432
+ is_binary = self._is_binary_file(remote_path)
433
+
434
+ if is_binary:
435
+ # For binary files, read as bytes
436
+ file_content = await self.sandbox.files.read(remote_path, format='bytes')
437
+ with open(local_path, 'wb') as f:
438
+ f.write(file_content)
439
+ else:
440
+ # For text files, read as string
441
+ file_content = await self.sandbox.files.read(remote_path, format='text')
442
+ with open(local_path, 'w', encoding='utf-8') as f:
443
+ f.write(file_content)
444
+
445
+ self.logger.info(f'File copied from sandbox: {remote_path} -> {local_path}')
446
+
447
+ except Exception as error:
448
+ self.logger.error(f'Failed to copy file: {error}')
449
+ raise
450
+
451
+ async def upload_file_to_sandbox(self, local_path: str, remote_path: str) -> None:
452
+ """
453
+ Uploads a file to the sandbox.
454
+
455
+ Args:
456
+ local_path: Local file path
457
+ remote_path: Destination path in sandbox
458
+
459
+ Raises:
460
+ RuntimeError: If sandbox is not running or upload fails
461
+ """
462
+ if not self.sandbox or self.status != SandboxStatus.RUNNING:
463
+ raise RuntimeError('Sandbox is not running. Call create_sandbox() first.')
464
+
465
+ try:
466
+ self.logger.debug('Uploading file to sandbox', {
467
+ 'localPath': local_path,
468
+ 'remotePath': remote_path,
469
+ })
470
+
471
+ with open(local_path, 'rb') as f:
472
+ file_content = f.read()
473
+
474
+ await self.sandbox.files.write(remote_path, file_content)
475
+
476
+ self.logger.debug('File uploaded successfully', {
477
+ 'localPath': local_path,
478
+ 'remotePath': remote_path,
479
+ })
480
+
481
+ except Exception as error:
482
+ self.logger.error('Failed to upload file to sandbox', str(error))
483
+ raise RuntimeError(f'Failed to upload file: {error}')
484
+
485
+ async def list_files(self, path: str) -> List[str]:
486
+ """
487
+ Lists files in a sandbox directory.
488
+
489
+ Args:
490
+ path: Directory path in sandbox
491
+
492
+ Returns:
493
+ List of filenames
494
+
495
+ Raises:
496
+ RuntimeError: If sandbox is not running or listing fails
497
+ """
498
+ if not self.sandbox or self.status != SandboxStatus.RUNNING:
499
+ raise RuntimeError('Sandbox is not running. Call create_sandbox() first.')
500
+
501
+ try:
502
+ self.logger.debug('Listing files in sandbox', {'path': path})
503
+
504
+ result = await self.run_command(f'ls -la "{path}"')
505
+
506
+ if hasattr(result, 'exit_code') and getattr(result, 'exit_code', 0) != 0:
507
+ raise RuntimeError(f'Failed to list files: {getattr(result, "stderr", "")}')
508
+
509
+ # Parse ls output to extract filenames
510
+ stdout = getattr(result, 'stdout', '') if result else ''
511
+ if stdout:
512
+ lines = stdout.split('\n')
513
+ files = []
514
+
515
+ for line in lines[1:]: # Skip first line (total)
516
+ line = line.strip()
517
+ if line:
518
+ parts = line.split()
519
+ if len(parts) >= 9: # Valid ls -la output
520
+ filename = parts[-1]
521
+ if filename not in ('.', '..'):
522
+ files.append(filename)
523
+
524
+ return files
525
+
526
+ return []
527
+
528
+ except Exception as error:
529
+ self.logger.error('Failed to list files', str(error))
530
+ raise RuntimeError(f'Failed to list files: {error}')
531
+
532
+ def get_status(self) -> SandboxStatus:
533
+ """Gets the current sandbox status."""
534
+ return self.status
535
+
536
+ def get_sandbox_id(self) -> Optional[str]:
537
+ """Gets sandbox ID if available."""
538
+ return getattr(self.sandbox, 'sandbox_id', None) if self.sandbox else None
539
+
540
+ def get_sandbox_host(self, port: int) -> Optional[str]:
541
+ """
542
+ Gets the external host address for a specific port.
543
+
544
+ Args:
545
+ port: Port number to get host for
546
+
547
+ Returns:
548
+ External host address or None if sandbox not available
549
+ """
550
+ if not self.sandbox or self.status != SandboxStatus.RUNNING:
551
+ self.logger.warn('Cannot get sandbox host: sandbox not running')
552
+ return None
553
+
554
+ try:
555
+ host = self.sandbox.get_host(port)
556
+ self.logger.debug('Got sandbox host for port', {'port': port, 'host': host})
557
+ return host
558
+ except Exception as error:
559
+ self.logger.error('Failed to get sandbox host', {'port': port, 'error': error})
560
+ return None
561
+
562
+ async def destroy(self) -> None:
563
+ """
564
+ Destroys the sandbox and cleans up resources.
565
+
566
+ Raises:
567
+ RuntimeError: If sandbox destruction fails
568
+ """
569
+ if self.sandbox:
570
+ try:
571
+ self.logger.info('Destroying Grasp sandbox', {
572
+ 'sandboxId': getattr(self.sandbox, 'sandbox_id', 'unknown'),
573
+ })
574
+
575
+ await self.sandbox.kill()
576
+ self.sandbox = None
577
+ self.status = SandboxStatus.STOPPED
578
+
579
+ self.logger.info('Grasp sandbox destroyed successfully')
580
+
581
+ except Exception as error:
582
+ self.logger.error('Failed to destroy sandbox', str(error))
583
+ raise RuntimeError(f'Failed to destroy sandbox: {error}')
@@ -0,0 +1,31 @@
1
+ """Utility modules for Grasp SDK Python implementation.
2
+
3
+ This package contains utility functions and classes for configuration,
4
+ logging, authentication, and other common functionality."""
5
+
6
+ # Import all utility modules
7
+ from .config import get_config, get_sandbox_config, get_browser_config, DEFAULT_CONFIG
8
+ from .logger import Logger, init_logger, get_logger, debug, info, warn, error
9
+ from .auth import verify, login, AuthError, KeyVerificationError, AuthManager
10
+
11
+ __all__ = [
12
+ # Configuration
13
+ 'get_config',
14
+ 'get_sandbox_config',
15
+ 'get_browser_config',
16
+ 'DEFAULT_CONFIG',
17
+ # Logging
18
+ 'Logger',
19
+ 'init_logger',
20
+ 'get_logger',
21
+ 'debug',
22
+ 'info',
23
+ 'warn',
24
+ 'error',
25
+ # Authentication
26
+ 'verify',
27
+ 'login',
28
+ 'AuthError',
29
+ 'KeyVerificationError',
30
+ 'AuthManager',
31
+ ]