grasp-sdk 0.1.9__py3-none-any.whl → 0.2.0b1__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,127 @@
1
+ """Main Grasp class for SDK initialization."""
2
+
3
+ import os
4
+ from typing import Dict, Optional, Any
5
+
6
+ from .server import GraspServer
7
+ from .session import GraspSession
8
+ from ..utils.config import get_config
9
+
10
+
11
+ class Grasp:
12
+ """Main Grasp SDK class for creating and managing sessions."""
13
+
14
+ def __init__(self, options: Optional[Dict[str, Any]] = None):
15
+ """Initialize Grasp SDK.
16
+
17
+ Args:
18
+ options: Configuration options including apiKey
19
+ """
20
+ if options is None:
21
+ options = {}
22
+
23
+ self.key = options.get('apiKey', os.environ.get('GRASP_KEY', ''))
24
+ self._session: Optional[GraspSession] = None
25
+ self._launch_options: Optional[Dict[str, Any]] = None
26
+
27
+ async def launch(self, options: Dict[str, Any]) -> GraspSession:
28
+ """Launch a new browser session.
29
+
30
+ Args:
31
+ options: Launch options containing browser configuration
32
+
33
+ Returns:
34
+ GraspSession instance
35
+ """
36
+ config = get_config()
37
+ browser_options = options.get('browser', {})
38
+ browser_options['key'] = self.key or config['sandbox']['key']
39
+ browser_options['keepAliveMS'] = options.get('keepAliveMS', config['sandbox']['keepAliveMs'])
40
+ browser_options['timeout'] = options.get('timeout', config['sandbox']['timeout'])
41
+ browser_options['debug'] = options.get('debug', config['sandbox']['debug'])
42
+ browser_options['logLevel'] = options.get('logLevel', config['logger']['level'])
43
+
44
+ server = GraspServer(browser_options)
45
+ cdp_connection = await server.create_browser_task()
46
+ return GraspSession(cdp_connection)
47
+
48
+ async def __aenter__(self) -> GraspSession:
49
+ """异步上下文管理器入口,启动会话。
50
+
51
+ Returns:
52
+ GraspSession 实例
53
+
54
+ Raises:
55
+ RuntimeError: 如果已经有活跃的会话
56
+ """
57
+ if self._session is not None:
58
+ raise RuntimeError('Session already active. Close existing session before creating a new one.')
59
+
60
+ if self._launch_options is None:
61
+ raise RuntimeError('No launch options provided. Use grasp.launch(options) as context manager.')
62
+
63
+ self._session = await self.launch(self._launch_options)
64
+ return self._session
65
+
66
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
67
+ """异步上下文管理器退出,清理会话。
68
+
69
+ Args:
70
+ exc_type: 异常类型
71
+ exc_val: 异常值
72
+ exc_tb: 异常回溯
73
+ """
74
+ if self._session is not None:
75
+ await self._session.close()
76
+ self._session = None
77
+ self._launch_options = None
78
+
79
+ def launch_context(self, options: Dict[str, Any]) -> 'Grasp':
80
+ """设置启动选项并返回自身,用于上下文管理器。
81
+
82
+ Args:
83
+ options: 启动选项
84
+
85
+ Returns:
86
+ 自身实例,用于 async with 语法
87
+
88
+ Example:
89
+ async with grasp.launch_context({
90
+ 'browser': {
91
+ 'type': 'chromium',
92
+ 'headless': True,
93
+ 'timeout': 30000
94
+ }
95
+ }) as session:
96
+ # 使用 session
97
+ pass
98
+ """
99
+ self._launch_options = options
100
+ return self
101
+
102
+ async def connect(self, session_id: str) -> GraspSession:
103
+ """Connect to an existing session.
104
+
105
+ Args:
106
+ session_id: ID of the session to connect to
107
+
108
+ Returns:
109
+ GraspSession instance
110
+ """
111
+ # Check if server already exists
112
+ from . import _servers
113
+ server = _servers.get(session_id)
114
+ cdp_connection = None
115
+
116
+ if server:
117
+ cdp_connection = server.browser_service.get_cdp_connection() if server.browser_service else None
118
+
119
+ if not cdp_connection:
120
+ # Create new server with no timeout for existing sessions
121
+ server = GraspServer({
122
+ 'timeout': 0,
123
+ 'key': self.key,
124
+ })
125
+ cdp_connection = await server.connect_browser_task(session_id)
126
+
127
+ return GraspSession(cdp_connection)
@@ -0,0 +1,250 @@
1
+ """GraspServer class for browser automation."""
2
+
3
+ import os
4
+ from typing import Dict, Optional, Any
5
+
6
+ from ..utils.logger import init_logger, get_logger
7
+ from ..utils.config import get_config
8
+ from ..services.browser import BrowserService, CDPConnection
9
+ from ..services.sandbox import SandboxService
10
+ from ..models import (
11
+ ISandboxConfig,
12
+ IBrowserConfig,
13
+ SandboxStatus,
14
+ )
15
+
16
+
17
+ class GraspServer:
18
+ """Main Grasp E2B class for browser automation."""
19
+
20
+ def __init__(self, sandbox_config: Optional[Dict[str, Any]] = None):
21
+ """Initialize GraspServer with configuration.
22
+
23
+ Args:
24
+ sandbox_config: Optional sandbox configuration overrides
25
+ """
26
+ if sandbox_config is None:
27
+ sandbox_config = {}
28
+
29
+ # Extract browser-specific options
30
+ browser_type = sandbox_config.pop('type', 'chromium')
31
+ headless = sandbox_config.pop('headless', True)
32
+ adblock = sandbox_config.pop('adblock', False)
33
+ logLevel = sandbox_config.pop('logLevel', '')
34
+ keepAliveMS = sandbox_config.pop('keepAliveMS', 0)
35
+
36
+ # Set default log level
37
+ if not logLevel:
38
+ logLevel = 'debug' if sandbox_config.get('debug', False) else 'info'
39
+
40
+ self.__browser_type = browser_type
41
+
42
+ # Create browser task
43
+ self.__browser_config = {
44
+ 'headless': headless,
45
+ 'envs': {
46
+ 'ADBLOCK': 'true' if adblock else 'false',
47
+ 'KEEP_ALIVE_MS': str(keepAliveMS),
48
+ }
49
+ }
50
+
51
+ config = get_config()
52
+ config['sandbox'].update(sandbox_config)
53
+ self.config = config
54
+
55
+ logger = config['logger']
56
+ logger['level'] = logLevel
57
+
58
+ # Initialize logger first
59
+ init_logger(logger)
60
+ self.logger = get_logger().child('GraspServer')
61
+
62
+ self.browser_service: Optional[BrowserService] = None
63
+
64
+ self.logger.info('GraspServer initialized')
65
+
66
+ async def __aenter__(self):
67
+ connection = await self.create_browser_task()
68
+
69
+ # Register server
70
+ # if connection['id']:
71
+ # _servers[connection['id']] = self
72
+
73
+ return connection
74
+
75
+ async def __aexit__(self, exc_type, exc, tb):
76
+ if self.browser_service and self.browser_service.id:
77
+ service_id = self.browser_service.id
78
+ self.logger.info(f'Closing browser service {service_id}')
79
+ from . import _servers
80
+ await _servers[service_id].cleanup()
81
+
82
+ @property
83
+ def sandbox(self) -> Optional[SandboxService]:
84
+ """Get the underlying sandbox service.
85
+
86
+ Returns:
87
+ SandboxService instance or None
88
+ """
89
+ return self.browser_service.get_sandbox() if self.browser_service else None
90
+
91
+ def get_status(self) -> Optional[SandboxStatus]:
92
+ """Get current sandbox status.
93
+
94
+ Returns:
95
+ Sandbox status or None
96
+ """
97
+ return self.sandbox.get_status() if self.sandbox else None
98
+
99
+ def get_sandbox_id(self) -> Optional[str]:
100
+ """Get sandbox ID.
101
+
102
+ Returns:
103
+ Sandbox ID or None
104
+ """
105
+ return self.sandbox.get_sandbox_id() if self.sandbox else None
106
+
107
+ async def create_browser_task(
108
+ self,
109
+ ) -> CDPConnection:
110
+ """Create and launch a browser task.
111
+
112
+ Args:
113
+ browser_type: Type of browser to launch
114
+ config: Browser configuration overrides
115
+
116
+ Returns:
117
+ Dictionary containing browser connection info
118
+
119
+ Raises:
120
+ RuntimeError: If browser service is already initialized
121
+ """
122
+ if self.browser_service:
123
+ raise RuntimeError('Browser service can only be initialized once')
124
+
125
+ config = self.__browser_config
126
+ browser_type = self.__browser_type
127
+
128
+ if config is None:
129
+ config = {}
130
+
131
+ # Create base browser config
132
+ browser_config: IBrowserConfig = {
133
+ 'headless': True,
134
+ 'launchTimeout': 30000,
135
+ 'args': [
136
+ '--disable-web-security',
137
+ '--disable-features=VizDisplayCompositor',
138
+ ],
139
+ 'envs': {},
140
+ }
141
+
142
+ # Apply user config overrides with type safety
143
+ if 'headless' in config:
144
+ browser_config['headless'] = config['headless']
145
+ if 'launchTimeout' in config:
146
+ browser_config['launchTimeout'] = config['launchTimeout']
147
+ if 'args' in config:
148
+ browser_config['args'] = config['args']
149
+ if 'envs' in config:
150
+ browser_config['envs'] = config['envs']
151
+
152
+ self.browser_service = BrowserService(
153
+ self.config['sandbox'],
154
+ browser_config
155
+ )
156
+ await self.browser_service.initialize(browser_type)
157
+
158
+ # Register server
159
+ from . import _servers
160
+ _servers[str(self.browser_service.id)] = self
161
+ self.logger.info("🚀 Browser service initialized", {
162
+ 'id': self.browser_service.id,
163
+ })
164
+
165
+ self.logger.info('🌐 Launching Chromium browser with CDP...')
166
+ cdp_connection = await self.browser_service.launch_browser()
167
+
168
+ self.logger.info('✅ Browser launched successfully!')
169
+ self.logger.debug(
170
+ f'CDP Connection Info (wsUrl: {cdp_connection.ws_url}, httpUrl: {cdp_connection.http_url})'
171
+ )
172
+
173
+ return cdp_connection
174
+
175
+ async def connect_browser_task(self, session_id: str) -> CDPConnection:
176
+ """Connect to an existing browser session.
177
+
178
+ Args:
179
+ session_id: ID of the session to connect to
180
+
181
+ Returns:
182
+ CDPConnection instance for the existing session
183
+
184
+ Raises:
185
+ RuntimeError: If browser service is already initialized
186
+ """
187
+ if self.browser_service:
188
+ raise RuntimeError('Browser service can only be initialized once')
189
+
190
+ config = self.__browser_config
191
+ browser_type = self.__browser_type
192
+
193
+ if config is None:
194
+ config = {}
195
+
196
+ # Create base browser config
197
+ browser_config: IBrowserConfig = {
198
+ 'headless': True,
199
+ 'launchTimeout': 30000,
200
+ 'args': [
201
+ '--disable-web-security',
202
+ '--disable-features=VizDisplayCompositor',
203
+ ],
204
+ 'envs': {},
205
+ }
206
+
207
+ # Apply user config overrides with type safety
208
+ if 'headless' in config:
209
+ browser_config['headless'] = config['headless']
210
+ if 'launchTimeout' in config:
211
+ browser_config['launchTimeout'] = config['launchTimeout']
212
+ if 'args' in config:
213
+ browser_config['args'] = config['args']
214
+ if 'envs' in config:
215
+ browser_config['envs'] = config['envs']
216
+
217
+ self.browser_service = BrowserService(
218
+ self.config['sandbox'],
219
+ browser_config
220
+ )
221
+
222
+ # Connect to existing session
223
+ connection = await self.browser_service.connect(session_id)
224
+
225
+ # Register server
226
+ from . import _servers
227
+ _servers[connection.id] = self
228
+
229
+ return connection
230
+
231
+ async def cleanup(self) -> None:
232
+ """Cleanup resources.
233
+
234
+ Returns:
235
+ Promise that resolves when cleanup is complete
236
+ """
237
+ self.logger.info('Starting cleanup process')
238
+
239
+ try:
240
+ if self.browser_service:
241
+ id = self.browser_service.id
242
+ await self.browser_service.cleanup()
243
+ if id:
244
+ from . import _servers
245
+ del _servers[id]
246
+
247
+ self.logger.info('Cleanup completed successfully')
248
+ except Exception as error:
249
+ self.logger.error(f'Cleanup failed: {error}')
250
+ raise
@@ -0,0 +1,146 @@
1
+ """GraspSession class for session management."""
2
+
3
+ import asyncio
4
+ import websockets
5
+ from typing import Optional
6
+ from .browser import GraspBrowser
7
+ from ..services.terminal import TerminalService
8
+ from ..services.browser import CDPConnection
9
+ from ..services.filesystem import FileSystemService
10
+ from ..utils.logger import get_logger
11
+
12
+
13
+ class GraspSession:
14
+ """Main session interface providing access to browser, terminal, and files."""
15
+
16
+ def __init__(self, connection: CDPConnection):
17
+ """Initialize GraspSession.
18
+
19
+ Args:
20
+ connection: CDP connection instance
21
+ """
22
+ self.connection = connection
23
+ self.browser = GraspBrowser(connection, self)
24
+
25
+ # Create files service
26
+ connection_id = connection.id
27
+ try:
28
+ from .utils import _servers
29
+ server = _servers[connection_id]
30
+ if server.sandbox is None:
31
+ raise RuntimeError('Sandbox is not available for file system service')
32
+ self.files = FileSystemService(server.sandbox, connection)
33
+ self.terminal = TerminalService(server.sandbox, connection)
34
+ except (ImportError, KeyError) as e:
35
+ # In test environment or when server is not available, create a mock files service
36
+ self.logger.debug(f"Warning: Files service not available: {e}")
37
+ raise e
38
+
39
+ # WebSocket connection for keep-alive
40
+ self._ws = None
41
+ self._is_closed = False
42
+
43
+ self.logger = get_logger().child('GraspSession')
44
+
45
+ # Initialize WebSocket connection and start keep-alive
46
+ # self.logger.debug('create session...')
47
+ asyncio.create_task(self._initialize_websocket())
48
+
49
+ async def _initialize_websocket(self) -> None:
50
+ """Initialize WebSocket connection and start keep-alive."""
51
+ try:
52
+ self._ws = await websockets.connect(self.connection.ws_url)
53
+ if not self._is_closed:
54
+ await self._keep_alive()
55
+ else:
56
+ await self._ws.close()
57
+ except Exception as e:
58
+ self.logger.debug(f"Failed to initialize WebSocket connection: {e}")
59
+
60
+ async def _keep_alive(self) -> None:
61
+ """Keep WebSocket connection alive with periodic ping."""
62
+ ws = self._ws
63
+ await asyncio.sleep(10) # 10 seconds interval, same as TypeScript version
64
+ if ws and ws.close_code is None:
65
+ try:
66
+ # Send ping and wait for pong response
67
+ pong_waiter = ws.ping()
68
+ await pong_waiter
69
+ # Recursively call keep_alive after receiving pong
70
+ await self._keep_alive()
71
+ except Exception as e:
72
+ self.logger.debug(f"Keep-alive ping failed: {e}")
73
+
74
+ @property
75
+ def id(self) -> str:
76
+ """Get session ID.
77
+
78
+ Returns:
79
+ Session ID
80
+ """
81
+ return self.connection.id
82
+
83
+ def is_running(self) -> bool:
84
+ """Check if the session is currently running.
85
+
86
+ Returns:
87
+ True if the session is running, False otherwise
88
+ """
89
+ try:
90
+ from .utils import _servers
91
+ from ..models import SandboxStatus
92
+ server = _servers[self.id]
93
+ if server.sandbox is None:
94
+ return False
95
+ status = server.sandbox.get_status()
96
+ return status == SandboxStatus.RUNNING
97
+ except (ImportError, KeyError) as e:
98
+ self.logger.error(f"Failed to check running status: {e}")
99
+ return False
100
+
101
+ def get_host(self, port: int) -> Optional[str]:
102
+ """Get the external host address for a specific port.
103
+
104
+ Args:
105
+ port: Port number to get host for
106
+
107
+ Returns:
108
+ External host address or None if sandbox not available
109
+ """
110
+ try:
111
+ from .utils import _servers
112
+ server = _servers[self.id]
113
+ if server.sandbox is None:
114
+ self.logger.warn('Cannot get host: sandbox not available')
115
+ return None
116
+ return server.sandbox.get_sandbox_host(port)
117
+ except (ImportError, KeyError) as e:
118
+ self.logger.error(f"Failed to get host: {e}")
119
+ return None
120
+
121
+ async def close(self) -> None:
122
+ """Close the session and cleanup resources."""
123
+ # Set closed flag to prevent new keep-alive cycles
124
+ self._is_closed = True
125
+
126
+ # Close WebSocket connection with timeout
127
+ if self._ws and self._ws.close_code is None:
128
+ try:
129
+ # Add timeout to prevent hanging on close
130
+ await asyncio.wait_for(self._ws.close(), timeout=5.0)
131
+ self.logger.debug('✅ WebSocket closed successfully')
132
+ except asyncio.TimeoutError:
133
+ self.logger.debug('⚠️ WebSocket close timeout, forcing termination')
134
+ # Force close if timeout
135
+ if hasattr(self._ws, 'transport') and self._ws.transport:
136
+ self._ws.transport.close()
137
+ except Exception as e:
138
+ self.logger.error(f"Failed to close WebSocket connection: {e}")
139
+
140
+ # Cleanup server resources
141
+ try:
142
+ from .utils import _servers
143
+ await _servers[self.id].cleanup()
144
+ except (ImportError, KeyError) as e:
145
+ # In test environment or when server is not available, skip cleanup
146
+ self.logger.error(f"Warning: Server cleanup not available: {e}")
@@ -0,0 +1,90 @@
1
+ """Utility functions for Grasp SDK."""
2
+
3
+ import os
4
+ import asyncio
5
+ import signal
6
+ from typing import Dict, Optional, List
7
+ import warnings
8
+
9
+ from ..utils.logger import get_logger
10
+ from .server import GraspServer
11
+ from .session import GraspSession
12
+
13
+ # Global server registry
14
+ _servers: Dict[str, GraspServer] = {}
15
+
16
+ async def _graceful_shutdown(reason: str = 'SIGTERM') -> None:
17
+ """Gracefully shutdown all servers.
18
+
19
+ Args:
20
+ reason: Reason for shutdown
21
+ """
22
+ logger = get_logger().child('shutdown')
23
+ logger.info(f'Graceful shutdown initiated: {reason}')
24
+
25
+ if not _servers:
26
+ logger.info('No active servers to shutdown')
27
+ return
28
+
29
+ logger.info(f'Shutting down {len(_servers)} active servers...')
30
+
31
+ # Create cleanup tasks for all servers
32
+ cleanup_tasks = []
33
+ for server_id, server in _servers.items():
34
+ logger.info(f'Scheduling cleanup for server {server_id}')
35
+ cleanup_tasks.append(server.cleanup())
36
+
37
+ # Wait for all cleanups to complete
38
+ try:
39
+ await asyncio.gather(*cleanup_tasks, return_exceptions=True)
40
+ logger.info('All servers cleaned up successfully')
41
+ except Exception as error:
42
+ logger.error(f'Error during cleanup: {error}')
43
+
44
+ # Clear the servers registry
45
+ _servers.clear()
46
+ logger.info('Graceful shutdown completed')
47
+
48
+
49
+ def _setup_signal_handlers() -> None:
50
+ """Setup signal handlers for graceful shutdown."""
51
+ def signal_handler(signum, frame):
52
+ signal_name = signal.Signals(signum).name
53
+ asyncio.create_task(_graceful_shutdown(signal_name))
54
+
55
+ # Register signal handlers
56
+ signal.signal(signal.SIGTERM, signal_handler)
57
+ signal.signal(signal.SIGINT, signal_handler)
58
+
59
+
60
+ async def shutdown(connection: Optional[str] = None) -> None:
61
+ """Shutdown Grasp servers (deprecated).
62
+
63
+ Args:
64
+ connection: Optional connection ID to shutdown specific server
65
+
66
+ Deprecated:
67
+ This function is deprecated. Use server.cleanup() or context managers instead.
68
+ """
69
+ warnings.warn(
70
+ "shutdown() is deprecated. Use server.cleanup() or context managers instead.",
71
+ DeprecationWarning,
72
+ stacklevel=2
73
+ )
74
+
75
+ logger = get_logger().child('shutdown')
76
+
77
+ if connection:
78
+ # Shutdown specific connection
79
+ if connection in _servers:
80
+ logger.info(f'Shutting down server {connection}')
81
+ await _servers[connection].cleanup()
82
+ else:
83
+ logger.warn(f'Server {connection} not found')
84
+ else:
85
+ # Shutdown all servers
86
+ await _graceful_shutdown('USER_SHUTDOWN')
87
+
88
+
89
+ # Setup signal handlers when module is imported
90
+ _setup_signal_handlers()
@@ -23,6 +23,7 @@ class ISandboxConfig(TypedDict):
23
23
  timeout: int # Required: Default timeout in milliseconds
24
24
  workspace: NotRequired[str] # Optional: Grasp workspace ID
25
25
  debug: NotRequired[bool] # Optional: Enable debug mode for detailed logging
26
+ keepAliveMs: NotRequired[int] # Optional: Keep-alive time in milliseconds
26
27
 
27
28
 
28
29
  class IBrowserConfig(TypedDict):
@@ -35,19 +36,23 @@ class IBrowserConfig(TypedDict):
35
36
 
36
37
  class ICommandOptions(TypedDict):
37
38
  """Command execution options interface."""
38
- inBackground: NotRequired[bool] # Whether to run command in background
39
- timeout: NotRequired[int] # Timeout in milliseconds
39
+ inBackground: NotRequired[bool] # Whether to run command in background (corresponds to 'background' in TypeScript)
40
+ timeout_ms: NotRequired[int] # Timeout in milliseconds (corresponds to 'timeoutMs' in TypeScript)
40
41
  cwd: NotRequired[str] # Working directory
41
- nohup: NotRequired[bool] # Whether to use nohup for command execution
42
+ user: NotRequired[str] # User to run the command as (default: 'user')
43
+ envs: NotRequired[Dict[str, str]] # Environment variables for the command
44
+ nohup: NotRequired[bool] # Whether to use nohup for command execution (Grasp-specific extension)
45
+ # Note: onStdout and onStderr callbacks are handled differently in Python implementation
42
46
 
43
47
 
44
48
  class IScriptOptions(TypedDict):
45
49
  """Script execution options interface."""
46
- type: str # Required: Script type: 'cjs' for CommonJS, 'esm' for ES Modules
50
+ type: str # Required: Script type: 'cjs' for CommonJS, 'esm' for ES Modules, 'py' for Python
47
51
  cwd: NotRequired[str] # Working directory
48
- timeoutMs: NotRequired[int] # Timeout in milliseconds
52
+ timeout_ms: NotRequired[int] # Timeout in milliseconds
49
53
  background: NotRequired[bool] # Run in background
50
- nohup: NotRequired[bool] # Use nohup for background execution
54
+ user: NotRequired[str] # User to run the script as (default: 'user')
55
+ nohup: NotRequired[bool] # Use nohup for background execution (Grasp-specific extension)
51
56
  envs: NotRequired[Dict[str, str]] # The environment variables
52
57
  preCommand: NotRequired[str] # Pre command
53
58
 
@@ -4,5 +4,12 @@ This module contains core services for sandbox and browser management."""
4
4
 
5
5
  from .sandbox import SandboxService, CommandEventEmitter
6
6
  from .browser import BrowserService, CDPConnection
7
+ from .terminal import TerminalService, Commands, StreamableCommandResult
8
+ from .filesystem import FileSystemService
7
9
 
8
- __all__ = ['SandboxService', 'CommandEventEmitter', 'BrowserService', 'CDPConnection']
10
+ __all__ = [
11
+ 'SandboxService', 'CommandEventEmitter',
12
+ 'BrowserService', 'CDPConnection',
13
+ 'TerminalService', 'Commands', 'StreamableCommandResult',
14
+ 'FileSystemService'
15
+ ]