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.
- examples/example_async_context.py +122 -0
- examples/example_binary_file_support.py +127 -0
- examples/example_grasp_usage.py +82 -0
- examples/example_readfile_usage.py +136 -0
- examples/grasp_terminal.py +111 -0
- examples/grasp_usage.py +111 -0
- examples/test_async_context.py +64 -0
- examples/test_grasp_classes.py +79 -0
- examples/test_python_script.py +160 -0
- examples/test_removed_methods.py +80 -0
- examples/test_terminal_updates.py +196 -0
- grasp_sdk/__init__.py +129 -239
- grasp_sdk/grasp/__init__.py +24 -0
- grasp_sdk/grasp/browser.py +162 -0
- grasp_sdk/grasp/index.py +127 -0
- grasp_sdk/grasp/server.py +250 -0
- grasp_sdk/grasp/session.py +146 -0
- grasp_sdk/grasp/utils.py +90 -0
- grasp_sdk/models/__init__.py +11 -6
- grasp_sdk/services/__init__.py +8 -1
- grasp_sdk/services/browser.py +68 -28
- grasp_sdk/services/filesystem.py +123 -0
- grasp_sdk/services/sandbox.py +404 -26
- grasp_sdk/services/terminal.py +182 -0
- grasp_sdk/utils/__init__.py +1 -2
- grasp_sdk/utils/auth.py +6 -8
- grasp_sdk/utils/config.py +2 -32
- {grasp_sdk-0.1.9.dist-info → grasp_sdk-0.2.0b1.dist-info}/METADATA +2 -3
- grasp_sdk-0.2.0b1.dist-info/RECORD +33 -0
- {grasp_sdk-0.1.9.dist-info → grasp_sdk-0.2.0b1.dist-info}/top_level.txt +1 -0
- grasp_sdk-0.1.9.dist-info/RECORD +0 -14
- {grasp_sdk-0.1.9.dist-info → grasp_sdk-0.2.0b1.dist-info}/WHEEL +0 -0
- {grasp_sdk-0.1.9.dist-info → grasp_sdk-0.2.0b1.dist-info}/entry_points.txt +0 -0
grasp_sdk/grasp/index.py
ADDED
|
@@ -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}")
|
grasp_sdk/grasp/utils.py
ADDED
|
@@ -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()
|
grasp_sdk/models/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
timeout_ms: NotRequired[int] # Timeout in milliseconds
|
|
49
53
|
background: NotRequired[bool] # Run in background
|
|
50
|
-
|
|
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
|
|
grasp_sdk/services/__init__.py
CHANGED
|
@@ -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__ = [
|
|
10
|
+
__all__ = [
|
|
11
|
+
'SandboxService', 'CommandEventEmitter',
|
|
12
|
+
'BrowserService', 'CDPConnection',
|
|
13
|
+
'TerminalService', 'Commands', 'StreamableCommandResult',
|
|
14
|
+
'FileSystemService'
|
|
15
|
+
]
|