boxlite 0.5.7__cp312-cp312-macosx_14_0_arm64.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 boxlite might be problematic. Click here for more details.

boxlite/__init__.py ADDED
@@ -0,0 +1,123 @@
1
+ """
2
+ BoxLite - Lightweight, secure containerization for any environment.
3
+
4
+ Following SQLite philosophy: "BoxLite" for branding, "boxlite" for code APIs.
5
+ """
6
+
7
+ import warnings
8
+
9
+ # Import core Rust API
10
+ try:
11
+ from .boxlite import (
12
+ Options,
13
+ BoxOptions,
14
+ Boxlite,
15
+ Box,
16
+ Execution,
17
+ ExecStdout,
18
+ ExecStderr,
19
+ BoxInfo,
20
+ BoxStateInfo,
21
+ RuntimeMetrics,
22
+ BoxMetrics,
23
+ )
24
+
25
+ __all__ = [
26
+ # Core Rust API
27
+ "Options",
28
+ "BoxOptions",
29
+ "Boxlite",
30
+ "Box",
31
+ "Execution",
32
+ "ExecStdout",
33
+ "ExecStderr",
34
+ "BoxInfo",
35
+ "BoxStateInfo",
36
+ "RuntimeMetrics",
37
+ "BoxMetrics",
38
+ ]
39
+ except ImportError as e:
40
+ warnings.warn(f"BoxLite native extension not available: {e}", ImportWarning)
41
+ __all__ = []
42
+
43
+ # Import Python convenience wrappers (re-exported via __all__)
44
+ try:
45
+ from .simplebox import SimpleBox # noqa: F401
46
+ from .exec import ExecResult # noqa: F401
47
+ from .codebox import CodeBox # noqa: F401
48
+ from .errors import BoxliteError, ExecError, TimeoutError, ParseError # noqa: F401
49
+
50
+ __all__.extend(
51
+ [
52
+ # Python convenience wrappers
53
+ "SimpleBox",
54
+ "CodeBox",
55
+ "ExecResult",
56
+ # Error types
57
+ "BoxliteError",
58
+ "ExecError",
59
+ "TimeoutError",
60
+ "ParseError",
61
+ ]
62
+ )
63
+ except ImportError:
64
+ pass
65
+
66
+ # Specialized containers (re-exported via __all__)
67
+ try:
68
+ from .browserbox import BrowserBox, BrowserBoxOptions # noqa: F401
69
+
70
+ __all__.extend(["BrowserBox", "BrowserBoxOptions"])
71
+ except ImportError:
72
+ pass
73
+
74
+ try:
75
+ from .computerbox import ComputerBox # noqa: F401
76
+
77
+ __all__.extend(["ComputerBox"])
78
+ except ImportError:
79
+ pass
80
+
81
+ try:
82
+ from .interactivebox import InteractiveBox # noqa: F401
83
+
84
+ __all__.extend(["InteractiveBox"])
85
+ except ImportError:
86
+ pass
87
+
88
+ # Sync API (greenlet-based synchronous wrappers, re-exported via __all__)
89
+ # Requires greenlet: pip install boxlite[sync]
90
+ try:
91
+ from .sync_api import ( # noqa: F401
92
+ SyncBoxlite,
93
+ SyncBox,
94
+ SyncExecution,
95
+ SyncExecStdout,
96
+ SyncExecStderr,
97
+ SyncSimpleBox,
98
+ SyncCodeBox,
99
+ )
100
+
101
+ __all__.extend(
102
+ [
103
+ "SyncBoxlite",
104
+ "SyncBox",
105
+ "SyncExecution",
106
+ "SyncExecStdout",
107
+ "SyncExecStderr",
108
+ "SyncSimpleBox",
109
+ "SyncCodeBox",
110
+ ]
111
+ )
112
+ except ImportError:
113
+ # greenlet not installed - sync API not available
114
+ pass
115
+
116
+ # Get version from package metadata
117
+ try:
118
+ from importlib.metadata import version, PackageNotFoundError
119
+
120
+ __version__ = version("boxlite")
121
+ except PackageNotFoundError:
122
+ # Package not installed (e.g., development mode)
123
+ __version__ = "0.0.0+dev"
Binary file
boxlite/browserbox.py ADDED
@@ -0,0 +1,142 @@
1
+ """
2
+ BrowserBox - Secure browser with remote debugging.
3
+
4
+ Provides a minimal, elegant API for running isolated browsers that can be
5
+ controlled from outside using standard tools like Puppeteer or Playwright.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Optional, TYPE_CHECKING
10
+
11
+ from .simplebox import SimpleBox
12
+
13
+ if TYPE_CHECKING:
14
+ from .boxlite import Boxlite
15
+
16
+ __all__ = ["BrowserBox", "BrowserBoxOptions"]
17
+
18
+
19
+ @dataclass
20
+ class BrowserBoxOptions:
21
+ """
22
+ Configuration for BrowserBox.
23
+
24
+ Example:
25
+ >>> opts = BrowserBoxOptions(
26
+ ... browser="chromium",
27
+ ... memory=2048,
28
+ ... cpu=2
29
+ ... )
30
+ >>> async with BrowserBox(opts) as browser:
31
+ ... print(browser.endpoint())
32
+ """
33
+
34
+ browser: str = "chromium" # chromium, firefox, or webkit
35
+ memory: int = 2048 # Memory in MiB
36
+ cpu: int = 2 # Number of CPU cores
37
+
38
+
39
+ class BrowserBox(SimpleBox):
40
+ """
41
+ Secure browser environment with remote debugging.
42
+
43
+ Auto-starts a browser with Chrome DevTools Protocol enabled.
44
+ Connect from outside using Puppeteer, Playwright, Selenium, or DevTools.
45
+
46
+ Usage:
47
+ >>> async with BrowserBox() as browser:
48
+ ... print(f"Connect to: {browser.endpoint()}")
49
+ ... # Use Puppeteer/Playwright from your host to connect
50
+ ... await asyncio.sleep(60)
51
+
52
+ Example with custom options:
53
+ >>> opts = BrowserBoxOptions(browser="firefox", memory=4096)
54
+ >>> async with BrowserBox(opts) as browser:
55
+ ... endpoint = browser.endpoint()
56
+ """
57
+
58
+ # Default Playwright images (with retry logic now!)
59
+ _DEFAULT_IMAGE = "mcr.microsoft.com/playwright:v1.47.2-jammy"
60
+
61
+ # CDP port for each browser type
62
+ _PORTS = {"chromium": 9222, "firefox": 9223, "webkit": 9224}
63
+
64
+ def __init__(
65
+ self,
66
+ options: Optional[BrowserBoxOptions] = None,
67
+ runtime: Optional["Boxlite"] = None,
68
+ **kwargs,
69
+ ):
70
+ """
71
+ Create and auto-start a browser.
72
+
73
+ Args:
74
+ options: Browser configuration (uses defaults if None)
75
+ runtime: Optional runtime instance (uses global default if None)
76
+ **kwargs: Additional configuration options (volumes, env, etc.)
77
+ """
78
+ opts = options or BrowserBoxOptions()
79
+
80
+ self._browser = opts.browser
81
+ self._port = self._PORTS.get(opts.browser, 9222)
82
+
83
+ # Initialize base box
84
+ super().__init__(
85
+ image=self._DEFAULT_IMAGE,
86
+ memory_mib=opts.memory,
87
+ cpus=opts.cpu,
88
+ runtime=runtime,
89
+ **kwargs,
90
+ )
91
+
92
+ async def __aenter__(self):
93
+ """Start browser automatically on context enter."""
94
+ await super().__aenter__()
95
+ await self._start_browser()
96
+ return self
97
+
98
+ async def _start_browser(self):
99
+ """Internal: Start browser with remote debugging."""
100
+ if self._browser == "chromium":
101
+ binary = "/ms-playwright/chromium-*/chrome-linux/chrome"
102
+ cmd = (
103
+ f"{binary} --headless --no-sandbox --disable-dev-shm-usage "
104
+ f"--disable-gpu --remote-debugging-address=0.0.0.0 "
105
+ f"--remote-debugging-port={self._port} "
106
+ f"> /tmp/browser.log 2>&1 &"
107
+ )
108
+ elif self._browser == "firefox":
109
+ binary = "/ms-playwright/firefox-*/firefox/firefox"
110
+ cmd = (
111
+ f"{binary} --headless "
112
+ f"--remote-debugging-port={self._port} "
113
+ f"> /tmp/browser.log 2>&1 &"
114
+ )
115
+ else: # webkit
116
+ cmd = (
117
+ f"playwright run-server --browser webkit "
118
+ f"--port {self._port} > /tmp/browser.log 2>&1 &"
119
+ )
120
+
121
+ # Start browser in background
122
+ await self.exec("sh", "-c", f"nohup {cmd}")
123
+
124
+ # Wait for browser to be ready
125
+ await self.exec("sleep", "3")
126
+
127
+ def endpoint(self) -> str:
128
+ """
129
+ Get the connection endpoint for remote debugging.
130
+
131
+ Returns:
132
+ HTTP endpoint URL for Chrome DevTools Protocol
133
+
134
+ Example:
135
+ >>> async with BrowserBox() as browser:
136
+ ... url = browser.endpoint()
137
+ ... # Use with Puppeteer:
138
+ ... # puppeteer.connect({ browserURL: url })
139
+ ... # Use with Playwright:
140
+ ... # chromium.connect_over_cdp(url)
141
+ """
142
+ return f"http://localhost:{self._port}"
boxlite/codebox.py ADDED
@@ -0,0 +1,120 @@
1
+ """
2
+ CodeBox - Secure Python code execution container.
3
+
4
+ Provides a simple, secure environment for running untrusted Python code.
5
+ """
6
+
7
+ from typing import Optional, TYPE_CHECKING
8
+
9
+ from .simplebox import SimpleBox
10
+
11
+ if TYPE_CHECKING:
12
+ from .boxlite import Boxlite
13
+
14
+
15
+ class CodeBox(SimpleBox):
16
+ """
17
+ Secure container for executing Python code.
18
+
19
+ CodeBox provides an isolated environment for running untrusted Python code
20
+ with built-in safety and result formatting.
21
+
22
+ Usage:
23
+ >>> async with CodeBox() as cb:
24
+ ... result = await cb.run("print('Hello, World!')")
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ image: str = "python:slim",
30
+ memory_mib: Optional[int] = None,
31
+ cpus: Optional[int] = None,
32
+ runtime: Optional["Boxlite"] = None,
33
+ **kwargs,
34
+ ):
35
+ """
36
+ Create a new CodeBox.
37
+
38
+ Args:
39
+ image: Container images with Python (default: python:slim)
40
+ memory_mib: Memory limit in MiB (default: system default)
41
+ cpus: Number of CPU cores (default: system default)
42
+ runtime: Optional runtime instance (uses global default if None)
43
+ **kwargs: Additional configuration options
44
+ """
45
+ super().__init__(image, memory_mib, cpus, runtime, **kwargs)
46
+
47
+ async def run(self, code: str, timeout: Optional[int] = None) -> str:
48
+ """
49
+ Execute Python code in the secure container.
50
+
51
+ Args:
52
+ code: Python code to execute
53
+ timeout: Execution timeout in seconds (not yet implemented)
54
+
55
+ Returns:
56
+ Execution output as a string (stdout + stderr)
57
+
58
+ Example:
59
+ >>> async with CodeBox() as cb:
60
+ ... result = await cb.run("print('Hello, World!')")
61
+ ... print(result)
62
+ Hello, World!
63
+
64
+ Note:
65
+ Uses python3 from the container images.
66
+ For custom Python paths, use exec() directly:
67
+ result = await cb.exec("/path/to/python", "-c", code)
68
+ """
69
+ # Execute Python code using python3 -c
70
+ result = await self.exec("/usr/local/bin/python", "-c", code)
71
+ return result.stdout + result.stderr
72
+
73
+ async def run_script(self, script_path: str) -> str:
74
+ """
75
+ Execute a Python script file in the container.
76
+
77
+ Args:
78
+ script_path: Path to the Python script on the host
79
+
80
+ Returns:
81
+ Execution output as a string
82
+ """
83
+ with open(script_path, "r") as f:
84
+ code = f.read()
85
+ return await self.run(code)
86
+
87
+ async def install_package(self, package: str) -> str:
88
+ """
89
+ Install a Python package in the container using pip.
90
+
91
+ Args:
92
+ package: Package name (e.g., 'requests', 'numpy==1.24.0')
93
+
94
+ Returns:
95
+ Installation output
96
+
97
+ Example:
98
+ >>> async with CodeBox() as cb:
99
+ ... await cb.install_package("requests")
100
+ ... result = await cb.run("import requests; print(requests.__version__)")
101
+ """
102
+ result = await self.exec("pip", "install", package)
103
+ return result.stdout + result.stderr
104
+
105
+ async def install_packages(self, *packages: str) -> str:
106
+ """
107
+ Install multiple Python packages.
108
+
109
+ Args:
110
+ *packages: Package names to install
111
+
112
+ Returns:
113
+ Installation output
114
+
115
+ Example:
116
+ >>> async with CodeBox() as cb:
117
+ ... await cb.install_packages("requests", "numpy", "pandas")
118
+ """
119
+ result = await self.exec("pip", "install", *packages)
120
+ return result.stdout + result.stderr
boxlite/computerbox.py ADDED
@@ -0,0 +1,302 @@
1
+ """
2
+ ComputerBox - Desktop environment with web access.
3
+
4
+ Provides a minimal, elegant API for running isolated desktop environments
5
+ that can be viewed from a browser, with full GUI automation support.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ from typing import Optional, Tuple, TYPE_CHECKING
11
+
12
+ from . import constants as const
13
+ from .errors import ExecError, TimeoutError, ParseError
14
+ from .simplebox import SimpleBox
15
+
16
+ if TYPE_CHECKING:
17
+ from .boxlite import Boxlite
18
+
19
+ __all__ = ["ComputerBox"]
20
+
21
+ logger = logging.getLogger("boxlite.computerbox")
22
+
23
+
24
+ class ComputerBox(SimpleBox):
25
+ """
26
+ Desktop environment accessible via web browser.
27
+
28
+ Auto-starts a full desktop environment with web interface.
29
+ Access the desktop by opening the URL in your browser.
30
+
31
+ Note: Uses HTTPS with self-signed certificate - your browser will show
32
+ a security warning. Click "Advanced" and "Proceed" to access the desktop.
33
+
34
+ Usage:
35
+ >>> async with ComputerBox() as desktop:
36
+ ... await desktop.wait_until_ready()
37
+ ... screenshot = await desktop.screenshot()
38
+
39
+ Example with custom settings:
40
+ >>> async with ComputerBox(memory=4096, cpu=4) as desktop:
41
+ ... await desktop.mouse_move(100, 200)
42
+ ... await desktop.left_click()
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ cpu: int = const.COMPUTERBOX_CPUS,
48
+ memory: int = const.COMPUTERBOX_MEMORY_MIB,
49
+ gui_http_port: int = const.COMPUTERBOX_GUI_HTTP_PORT,
50
+ gui_https_port: int = const.COMPUTERBOX_GUI_HTTPS_PORT,
51
+ runtime: Optional["Boxlite"] = None,
52
+ **kwargs,
53
+ ):
54
+ """
55
+ Create and auto-start a desktop environment.
56
+
57
+ Args:
58
+ cpu: Number of CPU cores (default: 2)
59
+ memory: Memory in MiB (default: 2048)
60
+ gui_http_port: Port for HTTP desktop GUI (default: 3000)
61
+ gui_https_port: Port for HTTPS desktop GUI (default: 3001)
62
+ runtime: Optional runtime instance (uses global default if None)
63
+ **kwargs: Additional configuration options (volumes, etc.)
64
+ """
65
+ user_env = kwargs.pop("env", [])
66
+ default_env = [
67
+ ("DISPLAY", const.COMPUTERBOX_DISPLAY_NUMBER),
68
+ ("DISPLAY_SIZEW", str(const.COMPUTERBOX_DISPLAY_WIDTH)),
69
+ ("DISPLAY_SIZEH", str(const.COMPUTERBOX_DISPLAY_HEIGHT)),
70
+ ("SELKIES_MANUAL_WIDTH", str(const.COMPUTERBOX_DISPLAY_WIDTH)),
71
+ ("SELKIES_MANUAL_HEIGHT", str(const.COMPUTERBOX_DISPLAY_HEIGHT)),
72
+ ("SELKIES_UI_SHOW_SIDEBAR", "false"),
73
+ ]
74
+
75
+ user_ports = kwargs.pop("ports", [])
76
+ default_ports = [
77
+ (gui_http_port, const.COMPUTERBOX_GUI_HTTP_PORT),
78
+ (gui_https_port, const.COMPUTERBOX_GUI_HTTPS_PORT),
79
+ ]
80
+
81
+ super().__init__(
82
+ image=const.COMPUTERBOX_IMAGE,
83
+ memory_mib=memory,
84
+ cpus=cpu,
85
+ runtime=runtime,
86
+ env=default_env + list(user_env),
87
+ ports=default_ports + list(user_ports),
88
+ **kwargs,
89
+ )
90
+
91
+ async def wait_until_ready(self, timeout: int = const.DESKTOP_READY_TIMEOUT):
92
+ """
93
+ Wait until the desktop environment is fully loaded and ready.
94
+
95
+ Args:
96
+ timeout: Maximum time to wait in seconds (default: 60)
97
+
98
+ Raises:
99
+ TimeoutError: If desktop doesn't become ready within timeout period
100
+ """
101
+ logger.info("Waiting for desktop to become ready...")
102
+ import time
103
+
104
+ start_time = time.time()
105
+
106
+ while True:
107
+ elapsed = time.time() - start_time
108
+ if elapsed > timeout:
109
+ raise TimeoutError(
110
+ f"Desktop did not become ready within {timeout} seconds"
111
+ )
112
+
113
+ try:
114
+ exec_result = await self.exec("xwininfo", "-tree", "-root")
115
+ expected_size = f"{const.COMPUTERBOX_DISPLAY_WIDTH}x{const.COMPUTERBOX_DISPLAY_HEIGHT}"
116
+
117
+ if (
118
+ "xfdesktop" in exec_result.stdout
119
+ and expected_size in exec_result.stdout
120
+ ):
121
+ logger.info(f"Desktop ready after {elapsed:.1f} seconds")
122
+ return
123
+
124
+ logger.debug(
125
+ f"Desktop not ready yet (waited {elapsed:.1f}s), retrying..."
126
+ )
127
+ await asyncio.sleep(const.DESKTOP_READY_RETRY_DELAY)
128
+
129
+ except (ExecError, ConnectionError, OSError, asyncio.TimeoutError) as e:
130
+ logger.debug(f"Desktop not ready: {e}, retrying...")
131
+ await asyncio.sleep(const.DESKTOP_READY_RETRY_DELAY)
132
+ except Exception as e:
133
+ logger.error(f"Fatal error in wait_until_ready: {e}")
134
+ raise
135
+
136
+ async def screenshot(self) -> dict:
137
+ """
138
+ Capture a screenshot of the desktop.
139
+
140
+ Returns:
141
+ Dictionary with: data (base64 PNG), width, height, format
142
+ """
143
+ logger.info("Taking screenshot...")
144
+
145
+ python_code = """
146
+ from PIL import ImageGrab
147
+ import io
148
+ import base64
149
+ img = ImageGrab.grab()
150
+ buffer = io.BytesIO()
151
+ img.save(buffer, format="PNG")
152
+ print(base64.b64encode(buffer.getvalue()).decode("utf-8"))
153
+ """
154
+ exec_result = await self.exec("python3", "-c", python_code)
155
+
156
+ if exec_result.exit_code != 0:
157
+ raise ExecError("screenshot()", exec_result.exit_code, exec_result.stderr)
158
+
159
+ return {
160
+ "data": exec_result.stdout.strip(),
161
+ "width": const.COMPUTERBOX_DISPLAY_WIDTH,
162
+ "height": const.COMPUTERBOX_DISPLAY_HEIGHT,
163
+ "format": "png",
164
+ }
165
+
166
+ async def mouse_move(self, x: int, y: int):
167
+ """Move mouse cursor to absolute coordinates."""
168
+ exec_result = await self.exec("xdotool", "mousemove", str(x), str(y))
169
+ if exec_result.exit_code != 0:
170
+ raise ExecError(
171
+ f"mouse_move({x}, {y})", exec_result.exit_code, exec_result.stderr
172
+ )
173
+
174
+ async def left_click(self):
175
+ """Click left mouse button at current position."""
176
+ exec_result = await self.exec("xdotool", "click", "1")
177
+ if exec_result.exit_code != 0:
178
+ raise ExecError("left_click()", exec_result.exit_code, exec_result.stderr)
179
+
180
+ async def right_click(self):
181
+ """Click right mouse button at current position."""
182
+ exec_result = await self.exec("xdotool", "click", "3")
183
+ if exec_result.exit_code != 0:
184
+ raise ExecError("right_click()", exec_result.exit_code, exec_result.stderr)
185
+
186
+ async def middle_click(self):
187
+ """Click middle mouse button at current position."""
188
+ exec_result = await self.exec("xdotool", "click", "2")
189
+ if exec_result.exit_code != 0:
190
+ raise ExecError("middle_click()", exec_result.exit_code, exec_result.stderr)
191
+
192
+ async def double_click(self):
193
+ """Double-click left mouse button at current position."""
194
+ exec_result = await self.exec(
195
+ "xdotool", "click", "--repeat", "2", "--delay", "100", "1"
196
+ )
197
+ if exec_result.exit_code != 0:
198
+ raise ExecError("double_click()", exec_result.exit_code, exec_result.stderr)
199
+
200
+ async def triple_click(self):
201
+ """Triple-click left mouse button at current position."""
202
+ exec_result = await self.exec(
203
+ "xdotool", "click", "--repeat", "3", "--delay", "100", "1"
204
+ )
205
+ if exec_result.exit_code != 0:
206
+ raise ExecError("triple_click()", exec_result.exit_code, exec_result.stderr)
207
+
208
+ async def left_click_drag(self, start_x: int, start_y: int, end_x: int, end_y: int):
209
+ """Drag mouse from start position to end position with left button held."""
210
+ exec_result = await self.exec(
211
+ "xdotool",
212
+ "mousemove",
213
+ str(start_x),
214
+ str(start_y),
215
+ "mousedown",
216
+ "1",
217
+ "sleep",
218
+ "0.1",
219
+ "mousemove",
220
+ str(end_x),
221
+ str(end_y),
222
+ "sleep",
223
+ "0.1",
224
+ "mouseup",
225
+ "1",
226
+ )
227
+ if exec_result.exit_code != 0:
228
+ raise ExecError(
229
+ "left_click_drag()", exec_result.exit_code, exec_result.stderr
230
+ )
231
+
232
+ async def cursor_position(self) -> Tuple[int, int]:
233
+ """Get the current mouse cursor position. Returns (x, y) tuple."""
234
+ exec_result = await self.exec("xdotool", "getmouselocation", "--shell")
235
+ if exec_result.exit_code != 0:
236
+ raise ExecError(
237
+ "cursor_position()", exec_result.exit_code, exec_result.stderr
238
+ )
239
+
240
+ x, y = None, None
241
+ for line in exec_result.stdout.split("\n"):
242
+ line = line.strip()
243
+ if line.startswith("X="):
244
+ x = int(line[2:])
245
+ elif line.startswith("Y="):
246
+ y = int(line[2:])
247
+
248
+ if x is not None and y is not None:
249
+ return (x, y)
250
+ raise ParseError("Failed to parse cursor position from xdotool output")
251
+
252
+ async def type(self, text: str):
253
+ """Type text using the keyboard."""
254
+ exec_result = await self.exec("xdotool", "type", "--", text)
255
+ if exec_result.exit_code != 0:
256
+ raise ExecError("type()", exec_result.exit_code, exec_result.stderr)
257
+
258
+ async def key(self, text: str):
259
+ """Press a special key or key combination (e.g., 'Return', 'ctrl+c')."""
260
+ exec_result = await self.exec("xdotool", "key", text)
261
+ if exec_result.exit_code != 0:
262
+ raise ExecError("key()", exec_result.exit_code, exec_result.stderr)
263
+
264
+ async def scroll(self, x: int, y: int, direction: str, amount: int = 3):
265
+ """
266
+ Scroll at a specific position.
267
+
268
+ Args:
269
+ x, y: Coordinates where to scroll
270
+ direction: 'up', 'down', 'left', or 'right'
271
+ amount: Number of scroll units (default: 3)
272
+ """
273
+ direction_map = {"up": "4", "down": "5", "left": "6", "right": "7"}
274
+ button = direction_map.get(direction.lower())
275
+ if not button:
276
+ raise ValueError(f"Invalid scroll direction: {direction}")
277
+
278
+ exec_result = await self.exec(
279
+ "xdotool",
280
+ "mousemove",
281
+ str(x),
282
+ str(y),
283
+ "click",
284
+ "--repeat",
285
+ str(amount),
286
+ button,
287
+ )
288
+ if exec_result.exit_code != 0:
289
+ raise ExecError("scroll()", exec_result.exit_code, exec_result.stderr)
290
+
291
+ async def get_screen_size(self) -> Tuple[int, int]:
292
+ """Get the screen resolution. Returns (width, height) tuple."""
293
+ exec_result = await self.exec("xdotool", "getdisplaygeometry")
294
+ if exec_result.exit_code != 0:
295
+ raise ExecError(
296
+ "get_screen_size()", exec_result.exit_code, exec_result.stderr
297
+ )
298
+
299
+ parts = exec_result.stdout.strip().split()
300
+ if len(parts) == 2:
301
+ return (int(parts[0]), int(parts[1]))
302
+ raise ParseError("Failed to parse screen size from xdotool output")