grasp-sdk 0.1.8__py3-none-any.whl → 0.2.0a1__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 +110 -0
- examples/grasp_usage.py +111 -0
- examples/test_async_context.py +64 -0
- examples/test_get_replay_screenshots.py +90 -0
- examples/test_grasp_classes.py +80 -0
- examples/test_python_script.py +160 -0
- examples/test_removed_methods.py +80 -0
- examples/test_shutdown_deprecation.py +62 -0
- examples/test_terminal_updates.py +196 -0
- grasp_sdk/__init__.py +131 -239
- grasp_sdk/grasp/__init__.py +26 -0
- grasp_sdk/grasp/browser.py +69 -0
- grasp_sdk/grasp/index.py +122 -0
- grasp_sdk/grasp/server.py +250 -0
- grasp_sdk/grasp/session.py +108 -0
- grasp_sdk/grasp/terminal.py +32 -0
- grasp_sdk/grasp/utils.py +90 -0
- grasp_sdk/models/__init__.py +1 -1
- grasp_sdk/services/__init__.py +8 -1
- grasp_sdk/services/browser.py +92 -63
- grasp_sdk/services/filesystem.py +94 -0
- grasp_sdk/services/sandbox.py +391 -20
- grasp_sdk/services/terminal.py +177 -0
- grasp_sdk/utils/auth.py +6 -8
- {grasp_sdk-0.1.8.dist-info → grasp_sdk-0.2.0a1.dist-info}/METADATA +2 -3
- grasp_sdk-0.2.0a1.dist-info/RECORD +36 -0
- {grasp_sdk-0.1.8.dist-info → grasp_sdk-0.2.0a1.dist-info}/top_level.txt +1 -0
- grasp_sdk/sandbox/bootstrap-chrome-stable.mjs +0 -69
- grasp_sdk/sandbox/chrome-stable.mjs +0 -424
- grasp_sdk/sandbox/chromium.mjs +0 -395
- grasp_sdk/sandbox/http-proxy.mjs +0 -324
- grasp_sdk-0.1.8.dist-info/RECORD +0 -18
- {grasp_sdk-0.1.8.dist-info → grasp_sdk-0.2.0a1.dist-info}/WHEEL +0 -0
- {grasp_sdk-0.1.8.dist-info → grasp_sdk-0.2.0a1.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""GraspBrowser class for browser interface."""
|
|
2
|
+
|
|
3
|
+
import aiohttp
|
|
4
|
+
from typing import Optional, Dict, Any
|
|
5
|
+
|
|
6
|
+
from ..services.browser import CDPConnection
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GraspBrowser:
|
|
10
|
+
"""Browser interface for Grasp session."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, connection: CDPConnection):
|
|
13
|
+
"""Initialize GraspBrowser.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
connection: CDP connection instance
|
|
17
|
+
"""
|
|
18
|
+
self.connection = connection
|
|
19
|
+
|
|
20
|
+
def get_endpoint(self) -> str:
|
|
21
|
+
"""Get browser WebSocket endpoint URL.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
WebSocket URL for browser connection
|
|
25
|
+
"""
|
|
26
|
+
return self.connection.ws_url
|
|
27
|
+
|
|
28
|
+
async def get_current_page_target_info(self) -> Optional[Dict[str, Any]]:
|
|
29
|
+
"""Get current page target information.
|
|
30
|
+
|
|
31
|
+
Warning:
|
|
32
|
+
This method is experimental and may change or be removed in future versions.
|
|
33
|
+
Use with caution in production environments.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Dictionary containing target info and last screenshot, or None if failed
|
|
37
|
+
"""
|
|
38
|
+
host = self.connection.http_url
|
|
39
|
+
api = f"{host}/api/page/info"
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
async with aiohttp.ClientSession() as session:
|
|
43
|
+
async with session.get(api) as response:
|
|
44
|
+
if not response.ok:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
data = await response.json()
|
|
48
|
+
page_info = data.get('pageInfo', {})
|
|
49
|
+
last_screenshot = page_info.get('lastScreenshot')
|
|
50
|
+
session_id = page_info.get('sessionId')
|
|
51
|
+
targets = page_info.get('targets', {})
|
|
52
|
+
|
|
53
|
+
if session_id and session_id in targets:
|
|
54
|
+
target_info = targets[session_id].copy()
|
|
55
|
+
target_info['lastScreenshot'] = last_screenshot
|
|
56
|
+
return target_info
|
|
57
|
+
|
|
58
|
+
return None
|
|
59
|
+
except Exception:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def get_liveview_url(self) -> Optional[str]:
|
|
63
|
+
"""Get liveview URL (TODO: implementation pending).
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Liveview URL or None
|
|
67
|
+
"""
|
|
68
|
+
# TODO: Implement liveview URL generation
|
|
69
|
+
return None
|
grasp_sdk/grasp/index.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
|
|
9
|
+
|
|
10
|
+
class Grasp:
|
|
11
|
+
"""Main Grasp SDK class for creating and managing sessions."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, options: Optional[Dict[str, Any]] = None):
|
|
14
|
+
"""Initialize Grasp SDK.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
options: Configuration options including apiKey
|
|
18
|
+
"""
|
|
19
|
+
if options is None:
|
|
20
|
+
options = {}
|
|
21
|
+
|
|
22
|
+
self.key = options.get('apiKey', os.environ.get('GRASP_KEY', ''))
|
|
23
|
+
self._session: Optional[GraspSession] = None
|
|
24
|
+
self._launch_options: Optional[Dict[str, Any]] = None
|
|
25
|
+
|
|
26
|
+
async def launch(self, options: Dict[str, Any]) -> GraspSession:
|
|
27
|
+
"""Launch a new browser session.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
options: Launch options containing browser configuration
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
GraspSession instance
|
|
34
|
+
"""
|
|
35
|
+
browser_options = options.get('browser', {})
|
|
36
|
+
browser_options['key'] = self.key
|
|
37
|
+
browser_options['keepAliveMS'] = options.get('keepAliveMS', 10000)
|
|
38
|
+
browser_options['timeout'] = options.get('timeout', 900000)
|
|
39
|
+
browser_options['debug'] = options.get('debug', False)
|
|
40
|
+
browser_options['logLevel'] = options.get('logLevel', 'info')
|
|
41
|
+
|
|
42
|
+
server = GraspServer(browser_options)
|
|
43
|
+
cdp_connection = await server.create_browser_task()
|
|
44
|
+
return GraspSession(cdp_connection)
|
|
45
|
+
|
|
46
|
+
async def __aenter__(self) -> GraspSession:
|
|
47
|
+
"""异步上下文管理器入口,启动会话。
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
GraspSession 实例
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
RuntimeError: 如果已经有活跃的会话
|
|
54
|
+
"""
|
|
55
|
+
if self._session is not None:
|
|
56
|
+
raise RuntimeError('Session already active. Close existing session before creating a new one.')
|
|
57
|
+
|
|
58
|
+
if self._launch_options is None:
|
|
59
|
+
raise RuntimeError('No launch options provided. Use grasp.launch(options) as context manager.')
|
|
60
|
+
|
|
61
|
+
self._session = await self.launch(self._launch_options)
|
|
62
|
+
return self._session
|
|
63
|
+
|
|
64
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
65
|
+
"""异步上下文管理器退出,清理会话。
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
exc_type: 异常类型
|
|
69
|
+
exc_val: 异常值
|
|
70
|
+
exc_tb: 异常回溯
|
|
71
|
+
"""
|
|
72
|
+
if self._session is not None:
|
|
73
|
+
await self._session.close()
|
|
74
|
+
self._session = None
|
|
75
|
+
self._launch_options = None
|
|
76
|
+
|
|
77
|
+
def launch_context(self, options: Dict[str, Any]) -> 'Grasp':
|
|
78
|
+
"""设置启动选项并返回自身,用于上下文管理器。
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
options: 启动选项
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
自身实例,用于 async with 语法
|
|
85
|
+
|
|
86
|
+
Example:
|
|
87
|
+
async with grasp.launch_context({
|
|
88
|
+
'browser': {
|
|
89
|
+
'type': 'chromium',
|
|
90
|
+
'headless': True,
|
|
91
|
+
'timeout': 30000
|
|
92
|
+
}
|
|
93
|
+
}) as session:
|
|
94
|
+
# 使用 session
|
|
95
|
+
pass
|
|
96
|
+
"""
|
|
97
|
+
self._launch_options = options
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
async def connect(self, session_id: str) -> GraspSession:
|
|
101
|
+
"""Connect to an existing session.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
session_id: ID of the session to connect to
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
GraspSession instance
|
|
108
|
+
"""
|
|
109
|
+
# Check if server already exists
|
|
110
|
+
from . import _servers
|
|
111
|
+
server = _servers.get(session_id)
|
|
112
|
+
cdp_connection = None
|
|
113
|
+
|
|
114
|
+
if server:
|
|
115
|
+
cdp_connection = server.browser_service.get_cdp_connection() if server.browser_service else None
|
|
116
|
+
|
|
117
|
+
if not cdp_connection:
|
|
118
|
+
# Create new server with no timeout for existing sessions
|
|
119
|
+
server = GraspServer({'timeout': 0})
|
|
120
|
+
cdp_connection = await server.connect_browser_task(session_id)
|
|
121
|
+
|
|
122
|
+
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,108 @@
|
|
|
1
|
+
"""GraspSession class for session management."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import websockets
|
|
5
|
+
from .browser import GraspBrowser
|
|
6
|
+
from .terminal import GraspTerminal
|
|
7
|
+
from ..services.browser import CDPConnection
|
|
8
|
+
from ..services.filesystem import FileSystemService
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GraspSession:
|
|
12
|
+
"""Main session interface providing access to browser, terminal, and files."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, connection: CDPConnection):
|
|
15
|
+
"""Initialize GraspSession.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
connection: CDP connection instance
|
|
19
|
+
"""
|
|
20
|
+
self.connection = connection
|
|
21
|
+
self.browser = GraspBrowser(connection)
|
|
22
|
+
self.terminal = GraspTerminal(connection)
|
|
23
|
+
|
|
24
|
+
# Create files service
|
|
25
|
+
connection_id = connection.id
|
|
26
|
+
try:
|
|
27
|
+
from . import _servers
|
|
28
|
+
server = _servers[connection_id]
|
|
29
|
+
if server.sandbox is None:
|
|
30
|
+
raise RuntimeError('Sandbox is not available for file system service')
|
|
31
|
+
self.files = FileSystemService(server.sandbox, connection)
|
|
32
|
+
except (ImportError, KeyError) as e:
|
|
33
|
+
# In test environment or when server is not available, create a mock files service
|
|
34
|
+
print(f"Warning: Files service not available: {e}")
|
|
35
|
+
raise e
|
|
36
|
+
|
|
37
|
+
# WebSocket connection for keep-alive
|
|
38
|
+
self._ws = None
|
|
39
|
+
self._keep_alive_task = None
|
|
40
|
+
|
|
41
|
+
# Initialize WebSocket connection and start keep-alive
|
|
42
|
+
print('create session...')
|
|
43
|
+
asyncio.create_task(self._initialize_websocket())
|
|
44
|
+
|
|
45
|
+
async def _initialize_websocket(self) -> None:
|
|
46
|
+
"""Initialize WebSocket connection and start keep-alive."""
|
|
47
|
+
try:
|
|
48
|
+
self._ws = await websockets.connect(self.connection.ws_url)
|
|
49
|
+
await self._keep_alive()
|
|
50
|
+
except Exception as e:
|
|
51
|
+
print(f"Failed to initialize WebSocket connection: {e}")
|
|
52
|
+
|
|
53
|
+
async def _keep_alive(self) -> None:
|
|
54
|
+
"""Keep WebSocket connection alive with periodic ping."""
|
|
55
|
+
if self._ws is None:
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
async def ping_loop():
|
|
59
|
+
while self._ws and self._ws.close_code is None:
|
|
60
|
+
try:
|
|
61
|
+
await self._ws.ping()
|
|
62
|
+
await asyncio.sleep(10) # 10 seconds interval, same as TypeScript version
|
|
63
|
+
except Exception as e:
|
|
64
|
+
print(f"Keep-alive ping failed: {e}")
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
self._keep_alive_task = asyncio.create_task(ping_loop())
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def id(self) -> str:
|
|
71
|
+
"""Get session ID.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Session ID
|
|
75
|
+
"""
|
|
76
|
+
return self.connection.id
|
|
77
|
+
|
|
78
|
+
async def close(self) -> None:
|
|
79
|
+
"""Close the session and cleanup resources."""
|
|
80
|
+
# Stop keep-alive task
|
|
81
|
+
if self._keep_alive_task:
|
|
82
|
+
self._keep_alive_task.cancel()
|
|
83
|
+
try:
|
|
84
|
+
await self._keep_alive_task
|
|
85
|
+
except asyncio.CancelledError:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
# Close WebSocket connection with timeout
|
|
89
|
+
if self._ws and self._ws.close_code is None:
|
|
90
|
+
try:
|
|
91
|
+
# Add timeout to prevent hanging on close
|
|
92
|
+
await asyncio.wait_for(self._ws.close(), timeout=5.0)
|
|
93
|
+
print('✅ WebSocket closed successfully')
|
|
94
|
+
except asyncio.TimeoutError:
|
|
95
|
+
print('⚠️ WebSocket close timeout, forcing termination')
|
|
96
|
+
# Force close if timeout
|
|
97
|
+
if hasattr(self._ws, 'transport') and self._ws.transport:
|
|
98
|
+
self._ws.transport.close()
|
|
99
|
+
except Exception as e:
|
|
100
|
+
print(f"Failed to close WebSocket connection: {e}")
|
|
101
|
+
|
|
102
|
+
# Cleanup server resources
|
|
103
|
+
try:
|
|
104
|
+
from . import _servers
|
|
105
|
+
await _servers[self.id].cleanup()
|
|
106
|
+
except (ImportError, KeyError) as e:
|
|
107
|
+
# In test environment or when server is not available, skip cleanup
|
|
108
|
+
print(f"Warning: Server cleanup not available: {e}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""GraspTerminal class for terminal interface."""
|
|
2
|
+
|
|
3
|
+
from ..services.browser import CDPConnection
|
|
4
|
+
from ..services.terminal import TerminalService
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GraspTerminal:
|
|
8
|
+
"""Terminal interface for Grasp session."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, connection: CDPConnection):
|
|
11
|
+
"""Initialize GraspTerminal.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
connection: CDP connection instance
|
|
15
|
+
"""
|
|
16
|
+
self.connection = connection
|
|
17
|
+
|
|
18
|
+
def create(self) -> TerminalService:
|
|
19
|
+
"""Create a new terminal service instance.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
TerminalService instance
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
RuntimeError: If sandbox is not available
|
|
26
|
+
"""
|
|
27
|
+
connection_id = self.connection.id
|
|
28
|
+
from . import _servers
|
|
29
|
+
server = _servers[connection_id]
|
|
30
|
+
if server.sandbox is None:
|
|
31
|
+
raise RuntimeError('Sandbox is not available')
|
|
32
|
+
return TerminalService(server.sandbox, self.connection)
|
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
|
@@ -43,7 +43,7 @@ class ICommandOptions(TypedDict):
|
|
|
43
43
|
|
|
44
44
|
class IScriptOptions(TypedDict):
|
|
45
45
|
"""Script execution options interface."""
|
|
46
|
-
type: str # Required: Script type: 'cjs' for CommonJS, 'esm' for ES Modules
|
|
46
|
+
type: str # Required: Script type: 'cjs' for CommonJS, 'esm' for ES Modules, 'py' for Python
|
|
47
47
|
cwd: NotRequired[str] # Working directory
|
|
48
48
|
timeoutMs: NotRequired[int] # Timeout in milliseconds
|
|
49
49
|
background: NotRequired[bool] # Run in background
|
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
|
+
]
|