boxlite 0.4.4__cp314-cp314t-manylinux_2_28_x86_64.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,92 @@
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 os
8
+ import warnings
9
+ from pathlib import Path
10
+
11
+ # Import core Rust API
12
+ try:
13
+ from .boxlite import (
14
+ Options,
15
+ BoxOptions,
16
+ Boxlite,
17
+ Box,
18
+ Execution,
19
+ ExecStdout,
20
+ ExecStderr,
21
+ BoxInfo,
22
+ RuntimeMetrics,
23
+ BoxMetrics,
24
+ )
25
+
26
+ __all__ = [
27
+ # Core Rust API
28
+ "Options",
29
+ "BoxOptions",
30
+ "Boxlite",
31
+ "Box",
32
+ "Execution",
33
+ "ExecStdout",
34
+ "ExecStderr",
35
+ "BoxInfo",
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
44
+ try:
45
+ from .simplebox import SimpleBox
46
+ from .exec import ExecResult
47
+ from .codebox import CodeBox
48
+ from .errors import BoxliteError, ExecError, TimeoutError, ParseError
49
+
50
+ __all__.extend([
51
+ # Python convenience wrappers
52
+ "SimpleBox",
53
+ "CodeBox",
54
+ "ExecResult",
55
+ # Error types
56
+ "BoxliteError",
57
+ "ExecError",
58
+ "TimeoutError",
59
+ "ParseError",
60
+ ])
61
+ except ImportError:
62
+ pass
63
+
64
+ # Specialized containers
65
+ try:
66
+ from .browserbox import BrowserBox, BrowserBoxOptions
67
+
68
+ __all__.extend(["BrowserBox", "BrowserBoxOptions"])
69
+ except ImportError:
70
+ pass
71
+
72
+ try:
73
+ from .computerbox import ComputerBox
74
+
75
+ __all__.extend(["ComputerBox"])
76
+ except ImportError:
77
+ pass
78
+
79
+ try:
80
+ from .interactivebox import InteractiveBox
81
+
82
+ __all__.extend(["InteractiveBox"])
83
+ except ImportError:
84
+ pass
85
+
86
+ # Get version from package metadata
87
+ try:
88
+ from importlib.metadata import version, PackageNotFoundError
89
+ __version__ = version("boxlite")
90
+ except PackageNotFoundError:
91
+ # Package not installed (e.g., development mode)
92
+ __version__ = "0.0.0+dev"
boxlite/browserbox.py ADDED
@@ -0,0 +1,138 @@
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, StreamType
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
+ browser: str = "chromium" # chromium, firefox, or webkit
34
+ memory: int = 2048 # Memory in MiB
35
+ cpu: int = 2 # Number of CPU cores
36
+
37
+
38
+ class BrowserBox(SimpleBox):
39
+ """
40
+ Secure browser environment with remote debugging.
41
+
42
+ Auto-starts a browser with Chrome DevTools Protocol enabled.
43
+ Connect from outside using Puppeteer, Playwright, Selenium, or DevTools.
44
+
45
+ Usage:
46
+ >>> async with BrowserBox() as browser:
47
+ ... print(f"Connect to: {browser.endpoint()}")
48
+ ... # Use Puppeteer/Playwright from your host to connect
49
+ ... await asyncio.sleep(60)
50
+
51
+ Example with custom options:
52
+ >>> opts = BrowserBoxOptions(browser="firefox", memory=4096)
53
+ >>> async with BrowserBox(opts) as browser:
54
+ ... endpoint = browser.endpoint()
55
+ """
56
+
57
+ # Default Playwright images (with retry logic now!)
58
+ _DEFAULT_IMAGE = "mcr.microsoft.com/playwright:v1.47.2-jammy"
59
+
60
+ # CDP port for each browser type
61
+ _PORTS = {"chromium": 9222, "firefox": 9223, "webkit": 9224}
62
+
63
+ def __init__(self, options: Optional[BrowserBoxOptions] = None,
64
+ runtime: Optional['Boxlite'] = None,
65
+ **kwargs):
66
+ """
67
+ Create and auto-start a browser.
68
+
69
+ Args:
70
+ options: Browser configuration (uses defaults if None)
71
+ runtime: Optional runtime instance (uses global default if None)
72
+ **kwargs: Additional configuration options (volumes, env, etc.)
73
+ """
74
+ opts = options or BrowserBoxOptions()
75
+
76
+ self._browser = opts.browser
77
+ self._port = self._PORTS.get(opts.browser, 9222)
78
+
79
+ # Initialize base box
80
+ super().__init__(
81
+ image=self._DEFAULT_IMAGE,
82
+ memory_mib=opts.memory,
83
+ cpus=opts.cpu,
84
+ runtime=runtime,
85
+ **kwargs
86
+ )
87
+
88
+ async def __aenter__(self):
89
+ """Start browser automatically on context enter."""
90
+ await super().__aenter__()
91
+ await self._start_browser()
92
+ return self
93
+
94
+ async def _start_browser(self):
95
+ """Internal: Start browser with remote debugging."""
96
+ if self._browser == "chromium":
97
+ binary = "/ms-playwright/chromium-*/chrome-linux/chrome"
98
+ cmd = (
99
+ f"{binary} --headless --no-sandbox --disable-dev-shm-usage "
100
+ f"--disable-gpu --remote-debugging-address=0.0.0.0 "
101
+ f"--remote-debugging-port={self._port} "
102
+ f"> /tmp/browser.log 2>&1 &"
103
+ )
104
+ elif self._browser == "firefox":
105
+ binary = "/ms-playwright/firefox-*/firefox/firefox"
106
+ cmd = (
107
+ f"{binary} --headless "
108
+ f"--remote-debugging-port={self._port} "
109
+ f"> /tmp/browser.log 2>&1 &"
110
+ )
111
+ else: # webkit
112
+ cmd = (
113
+ f"playwright run-server --browser webkit "
114
+ f"--port {self._port} > /tmp/browser.log 2>&1 &"
115
+ )
116
+
117
+ # Start browser in background
118
+ await self.exec("sh", "-c", f"nohup {cmd}")
119
+
120
+ # Wait for browser to be ready
121
+ await self.exec("sleep", "3")
122
+
123
+ def endpoint(self) -> str:
124
+ """
125
+ Get the connection endpoint for remote debugging.
126
+
127
+ Returns:
128
+ HTTP endpoint URL for Chrome DevTools Protocol
129
+
130
+ Example:
131
+ >>> async with BrowserBox() as browser:
132
+ ... url = browser.endpoint()
133
+ ... # Use with Puppeteer:
134
+ ... # puppeteer.connect({ browserURL: url })
135
+ ... # Use with Playwright:
136
+ ... # chromium.connect_over_cdp(url)
137
+ """
138
+ 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,267 @@
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
+ start_time = time.time()
104
+
105
+ while True:
106
+ elapsed = time.time() - start_time
107
+ if elapsed > timeout:
108
+ raise TimeoutError(f"Desktop did not become ready within {timeout} seconds")
109
+
110
+ try:
111
+ exec_result = await self.exec("xwininfo", "-tree", "-root")
112
+ expected_size = f'{const.COMPUTERBOX_DISPLAY_WIDTH}x{const.COMPUTERBOX_DISPLAY_HEIGHT}'
113
+
114
+ if 'xfdesktop' in exec_result.stdout and expected_size in exec_result.stdout:
115
+ logger.info(f"Desktop ready after {elapsed:.1f} seconds")
116
+ return
117
+
118
+ logger.debug(f"Desktop not ready yet (waited {elapsed:.1f}s), retrying...")
119
+ await asyncio.sleep(const.DESKTOP_READY_RETRY_DELAY)
120
+
121
+ except (ExecError, ConnectionError, OSError, asyncio.TimeoutError) as e:
122
+ logger.debug(f"Desktop not ready: {e}, retrying...")
123
+ await asyncio.sleep(const.DESKTOP_READY_RETRY_DELAY)
124
+ except Exception as e:
125
+ logger.error(f"Fatal error in wait_until_ready: {e}")
126
+ raise
127
+
128
+ async def screenshot(self) -> dict:
129
+ """
130
+ Capture a screenshot of the desktop.
131
+
132
+ Returns:
133
+ Dictionary with: data (base64 PNG), width, height, format
134
+ """
135
+ logger.info("Taking screenshot...")
136
+
137
+ python_code = '''
138
+ from PIL import ImageGrab
139
+ import io
140
+ import base64
141
+ img = ImageGrab.grab()
142
+ buffer = io.BytesIO()
143
+ img.save(buffer, format="PNG")
144
+ print(base64.b64encode(buffer.getvalue()).decode("utf-8"))
145
+ '''
146
+ exec_result = await self.exec("python3", "-c", python_code)
147
+
148
+ if exec_result.exit_code != 0:
149
+ raise ExecError("screenshot()", exec_result.exit_code, exec_result.stderr)
150
+
151
+ return {
152
+ "data": exec_result.stdout.strip(),
153
+ "width": const.COMPUTERBOX_DISPLAY_WIDTH,
154
+ "height": const.COMPUTERBOX_DISPLAY_HEIGHT,
155
+ "format": "png"
156
+ }
157
+
158
+ async def mouse_move(self, x: int, y: int):
159
+ """Move mouse cursor to absolute coordinates."""
160
+ exec_result = await self.exec("xdotool", "mousemove", str(x), str(y))
161
+ if exec_result.exit_code != 0:
162
+ raise ExecError(f"mouse_move({x}, {y})", exec_result.exit_code, exec_result.stderr)
163
+
164
+ async def left_click(self):
165
+ """Click left mouse button at current position."""
166
+ exec_result = await self.exec("xdotool", "click", "1")
167
+ if exec_result.exit_code != 0:
168
+ raise ExecError("left_click()", exec_result.exit_code, exec_result.stderr)
169
+
170
+ async def right_click(self):
171
+ """Click right mouse button at current position."""
172
+ exec_result = await self.exec("xdotool", "click", "3")
173
+ if exec_result.exit_code != 0:
174
+ raise ExecError("right_click()", exec_result.exit_code, exec_result.stderr)
175
+
176
+ async def middle_click(self):
177
+ """Click middle mouse button at current position."""
178
+ exec_result = await self.exec("xdotool", "click", "2")
179
+ if exec_result.exit_code != 0:
180
+ raise ExecError("middle_click()", exec_result.exit_code, exec_result.stderr)
181
+
182
+ async def double_click(self):
183
+ """Double-click left mouse button at current position."""
184
+ exec_result = await self.exec("xdotool", "click", "--repeat", "2", "--delay", "100", "1")
185
+ if exec_result.exit_code != 0:
186
+ raise ExecError("double_click()", exec_result.exit_code, exec_result.stderr)
187
+
188
+ async def triple_click(self):
189
+ """Triple-click left mouse button at current position."""
190
+ exec_result = await self.exec("xdotool", "click", "--repeat", "3", "--delay", "100", "1")
191
+ if exec_result.exit_code != 0:
192
+ raise ExecError("triple_click()", exec_result.exit_code, exec_result.stderr)
193
+
194
+ async def left_click_drag(self, start_x: int, start_y: int, end_x: int, end_y: int):
195
+ """Drag mouse from start position to end position with left button held."""
196
+ exec_result = await self.exec(
197
+ "xdotool",
198
+ "mousemove", str(start_x), str(start_y),
199
+ "mousedown", "1",
200
+ "sleep", "0.1",
201
+ "mousemove", str(end_x), str(end_y),
202
+ "sleep", "0.1",
203
+ "mouseup", "1"
204
+ )
205
+ if exec_result.exit_code != 0:
206
+ raise ExecError("left_click_drag()", exec_result.exit_code, exec_result.stderr)
207
+
208
+ async def cursor_position(self) -> Tuple[int, int]:
209
+ """Get the current mouse cursor position. Returns (x, y) tuple."""
210
+ exec_result = await self.exec("xdotool", "getmouselocation", "--shell")
211
+ if exec_result.exit_code != 0:
212
+ raise ExecError("cursor_position()", exec_result.exit_code, exec_result.stderr)
213
+
214
+ x, y = None, None
215
+ for line in exec_result.stdout.split('\n'):
216
+ line = line.strip()
217
+ if line.startswith('X='):
218
+ x = int(line[2:])
219
+ elif line.startswith('Y='):
220
+ y = int(line[2:])
221
+
222
+ if x is not None and y is not None:
223
+ return (x, y)
224
+ raise ParseError("Failed to parse cursor position from xdotool output")
225
+
226
+ async def type(self, text: str):
227
+ """Type text using the keyboard."""
228
+ exec_result = await self.exec("xdotool", "type", "--", text)
229
+ if exec_result.exit_code != 0:
230
+ raise ExecError("type()", exec_result.exit_code, exec_result.stderr)
231
+
232
+ async def key(self, text: str):
233
+ """Press a special key or key combination (e.g., 'Return', 'ctrl+c')."""
234
+ exec_result = await self.exec("xdotool", "key", text)
235
+ if exec_result.exit_code != 0:
236
+ raise ExecError("key()", exec_result.exit_code, exec_result.stderr)
237
+
238
+ async def scroll(self, x: int, y: int, direction: str, amount: int = 3):
239
+ """
240
+ Scroll at a specific position.
241
+
242
+ Args:
243
+ x, y: Coordinates where to scroll
244
+ direction: 'up', 'down', 'left', or 'right'
245
+ amount: Number of scroll units (default: 3)
246
+ """
247
+ direction_map = {"up": "4", "down": "5", "left": "6", "right": "7"}
248
+ button = direction_map.get(direction.lower())
249
+ if not button:
250
+ raise ValueError(f"Invalid scroll direction: {direction}")
251
+
252
+ exec_result = await self.exec(
253
+ "xdotool", "mousemove", str(x), str(y), "click", "--repeat", str(amount), button
254
+ )
255
+ if exec_result.exit_code != 0:
256
+ raise ExecError("scroll()", exec_result.exit_code, exec_result.stderr)
257
+
258
+ async def get_screen_size(self) -> Tuple[int, int]:
259
+ """Get the screen resolution. Returns (width, height) tuple."""
260
+ exec_result = await self.exec("xdotool", "getdisplaygeometry")
261
+ if exec_result.exit_code != 0:
262
+ raise ExecError("get_screen_size()", exec_result.exit_code, exec_result.stderr)
263
+
264
+ parts = exec_result.stdout.strip().split()
265
+ if len(parts) == 2:
266
+ return (int(parts[0]), int(parts[1]))
267
+ raise ParseError("Failed to parse screen size from xdotool output")
boxlite/constants.py ADDED
@@ -0,0 +1,25 @@
1
+ """
2
+ Centralized constants for BoxLite Python SDK.
3
+ """
4
+
5
+ # Default VM resources
6
+ DEFAULT_CPUS = 1
7
+ DEFAULT_MEMORY_MIB = 2048
8
+
9
+ # ComputerBox defaults (higher resources for desktop)
10
+ COMPUTERBOX_CPUS = 2
11
+ COMPUTERBOX_MEMORY_MIB = 2048
12
+ COMPUTERBOX_IMAGE = "lscr.io/linuxserver/webtop:ubuntu-xfce"
13
+
14
+ # ComputerBox display settings
15
+ COMPUTERBOX_DISPLAY_NUMBER = ":1"
16
+ COMPUTERBOX_DISPLAY_WIDTH = 1024
17
+ COMPUTERBOX_DISPLAY_HEIGHT = 768
18
+
19
+ # ComputerBox network ports (webtop defaults)
20
+ COMPUTERBOX_GUI_HTTP_PORT = 3000
21
+ COMPUTERBOX_GUI_HTTPS_PORT = 3001
22
+
23
+ # Timeouts (seconds)
24
+ DESKTOP_READY_TIMEOUT = 60
25
+ DESKTOP_READY_RETRY_DELAY = 0.5
boxlite/errors.py ADDED
@@ -0,0 +1,38 @@
1
+ """
2
+ BoxLite error types.
3
+
4
+ Provides a hierarchy of exceptions for different failure modes.
5
+ """
6
+
7
+ __all__ = ['BoxliteError', 'ExecError', 'TimeoutError', 'ParseError']
8
+
9
+
10
+ class BoxliteError(Exception):
11
+ """Base exception for all boxlite errors."""
12
+ pass
13
+
14
+
15
+ class ExecError(BoxliteError):
16
+ """
17
+ Raised when a command execution fails (non-zero exit code).
18
+
19
+ Attributes:
20
+ command: The command that failed
21
+ exit_code: The non-zero exit code
22
+ stderr: Standard error output from the command
23
+ """
24
+ def __init__(self, command: str, exit_code: int, stderr: str):
25
+ self.command = command
26
+ self.exit_code = exit_code
27
+ self.stderr = stderr
28
+ super().__init__(f"Command '{command}' failed with exit code {exit_code}: {stderr}")
29
+
30
+
31
+ class TimeoutError(BoxliteError):
32
+ """Raised when an operation times out."""
33
+ pass
34
+
35
+
36
+ class ParseError(BoxliteError):
37
+ """Raised when output parsing fails."""
38
+ pass
boxlite/exec.py ADDED
@@ -0,0 +1,26 @@
1
+ """
2
+ Execution API - Simple interface for command execution.
3
+
4
+ Provides Docker-like API for executing commands in boxes.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+
9
+ __all__ = [
10
+ 'ExecResult',
11
+ ]
12
+
13
+
14
+ @dataclass
15
+ class ExecResult:
16
+ """
17
+ Result from a command execution.
18
+
19
+ Attributes:
20
+ exit_code: Exit code from the command (negative if terminated by signal)
21
+ stdout: Standard output as string
22
+ stderr: Standard error as string
23
+ """
24
+ exit_code: int
25
+ stdout: str
26
+ stderr: str
@@ -0,0 +1,297 @@
1
+ """
2
+ InteractiveBox - Interactive terminal sessions with PTY support.
3
+
4
+ Provides automatic PTY-based interactive sessions, similar to `docker exec -it`.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ import os
10
+ import sys
11
+ import termios
12
+ import tty
13
+ from typing import Optional, TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from .runtime import Boxlite
17
+
18
+ # Configure logger
19
+ logger = logging.getLogger("boxlite.interactivebox")
20
+
21
+
22
+ class InteractiveBox:
23
+ """
24
+ Interactive box with automatic PTY and terminal forwarding.
25
+
26
+ When used as a context manager, automatically:
27
+ 1. Auto-detects terminal size (like Docker)
28
+ 2. Starts a shell with PTY
29
+ 3. Sets local terminal to cbreak mode
30
+ 4. Forwards stdin/stdout bidirectionally
31
+ 5. Restores terminal mode on exit
32
+
33
+ Example:
34
+ async with InteractiveBox(image="alpine:latest") as box:
35
+ # You're now in an interactive shell!
36
+ # Type commands, see output in real-time
37
+ # Type "exit" to close
38
+ pass
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ image: str,
44
+ shell: str = "/bin/sh",
45
+ tty: Optional[bool] = None,
46
+ memory_mib: Optional[int] = None,
47
+ cpus: Optional[int] = None,
48
+ runtime: Optional['Boxlite'] = None,
49
+ name: Optional[str] = None,
50
+ auto_remove: bool = True,
51
+ **kwargs
52
+ ):
53
+ """
54
+ Create interactive box.
55
+
56
+ Args:
57
+ image: Container image to use
58
+ shell: Shell to run (default: /bin/sh)
59
+ tty: Control terminal I/O forwarding behavior:
60
+ - None (default): Auto-detect - forward I/O if stdin is a TTY
61
+ - True: Force I/O forwarding (manual interactive mode)
62
+ - False: No I/O forwarding (programmatic control only)
63
+ memory_mib: Memory limit in MiB
64
+ cpus: Number of CPU cores
65
+ runtime: Optional runtime instance (uses global default if None)
66
+ name: Optional name for the box (must be unique)
67
+ auto_remove: Remove box when stopped (default: True)
68
+ **kwargs: Additional configuration options (working_dir, env)
69
+ """
70
+ try:
71
+ from .boxlite import Boxlite, BoxOptions
72
+ except ImportError as e:
73
+ raise ImportError(
74
+ f"BoxLite native extension not found: {e}. "
75
+ "Please install with: pip install boxlite"
76
+ )
77
+
78
+ # Use provided runtime or get default
79
+ if runtime is None:
80
+ self._runtime = Boxlite.default()
81
+ else:
82
+ self._runtime = runtime
83
+
84
+ # Create box options
85
+ box_opts = BoxOptions(
86
+ image=image,
87
+ cpus=cpus,
88
+ memory_mib=memory_mib,
89
+ auto_remove=auto_remove,
90
+ **kwargs
91
+ )
92
+
93
+ # Create box directly (no SimpleBox wrapper)
94
+ self._box = self._runtime.create(box_opts, name=name)
95
+
96
+ # Store interactive config
97
+ self._shell = shell
98
+ self._env = kwargs.get('env', [])
99
+
100
+ # Determine TTY mode: None = auto-detect, True = force, False = disable
101
+ if tty is None:
102
+ self._tty = sys.stdin.isatty()
103
+ else:
104
+ self._tty = tty
105
+
106
+ # Interactive state
107
+ self._old_tty_settings = None
108
+ self._io_task = None
109
+ self._execution = None
110
+ self._stdin = None
111
+ self._stdout = None
112
+ self._stderr = None
113
+ self._exited = None # Event to signal process exit
114
+
115
+ @property
116
+ def id(self) -> str:
117
+ """Get the box ID."""
118
+ return self._box.id
119
+
120
+ async def __aenter__(self):
121
+ """Start box and enter interactive TTY session."""
122
+ # Start box directly
123
+ await self._box.__aenter__()
124
+
125
+ # Start shell with PTY
126
+ self._execution = await self._start_interactive_shell()
127
+
128
+ # Get stdin/stdout/stderr ONCE (can only be called once due to .take())
129
+ self._stdin = self._execution.stdin()
130
+ self._stdout = self._execution.stdout()
131
+ self._stderr = self._execution.stderr()
132
+
133
+ # Only set cbreak mode and start forwarding if tty=True
134
+ if self._tty:
135
+ stdin_fd = sys.stdin.fileno()
136
+ self._old_tty_settings = termios.tcgetattr(stdin_fd)
137
+ tty.setraw(sys.stdin.fileno(), when=termios.TCSANOW)
138
+
139
+ # Create exit event for graceful shutdown
140
+ self._exited = asyncio.Event()
141
+
142
+ # Start bidirectional I/O forwarding using gather (more Pythonic)
143
+ self._io_task = asyncio.gather(
144
+ self._forward_stdin(),
145
+ self._forward_output(),
146
+ self._forward_stderr(),
147
+ self._wait_for_exit(),
148
+ return_exceptions=True
149
+ )
150
+ else:
151
+ # No I/O forwarding, just wait for execution
152
+ self._io_task = self._wait_for_exit()
153
+
154
+ return self
155
+
156
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
157
+ # Restore terminal settings
158
+ if self._old_tty_settings is not None:
159
+ try:
160
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._old_tty_settings)
161
+ except Exception as e:
162
+ logger.error(f"Caught exception on TTY settings: {e}")
163
+
164
+ """Exit interactive session and restore terminal."""
165
+ # Wait for I/O task to complete (or cancel if needed)
166
+ if hasattr(self, '_io_task') and self._io_task is not None:
167
+ try:
168
+ # Give it a moment to finish naturally
169
+ await asyncio.wait_for(self._io_task, timeout=3)
170
+ logger.info("Closing interactive shell (I/O tasks finished).")
171
+ self._io_task = None
172
+ except asyncio.TimeoutError:
173
+ # If it doesn't finish, that's ok - box is shutting down anyway
174
+ logger.error("Timeout waiting for I/O tasks to finish, cancelling...")
175
+ except Exception as e:
176
+ # Ignore other exceptions during cleanup
177
+ logger.error(f"Caught exception on exit: {e}")
178
+
179
+ # Shutdown box directly
180
+ return await self._box.__aexit__(exc_type, exc_val, exc_tb)
181
+
182
+ async def wait(self):
183
+ await self._execution.wait()
184
+
185
+ async def _start_interactive_shell(self):
186
+ """Start shell with PTY (internal)."""
187
+ # Execute shell with PTY using simplified boolean API
188
+ # Terminal size is auto-detected (like Docker)
189
+ execution = await self._box.exec(
190
+ self._shell,
191
+ args=[],
192
+ env=self._env,
193
+ tty=True, # Simple boolean - auto-detects terminal size
194
+ )
195
+
196
+ return execution
197
+
198
+ async def _forward_stdin(self):
199
+ """Forward stdin to PTY (internal)."""
200
+ try:
201
+ if self._stdin is None:
202
+ return
203
+
204
+ # Forward stdin in chunks
205
+ loop = asyncio.get_event_loop()
206
+ while not self._exited.is_set():
207
+ # Read from stdin with timeout to check exit event
208
+ try:
209
+ read_task = loop.run_in_executor(None, os.read, sys.stdin.fileno(), 1024)
210
+ # Wait for either stdin data or exit event
211
+ done, pending = await asyncio.wait(
212
+ [asyncio.ensure_future(read_task),
213
+ asyncio.ensure_future(self._exited.wait())],
214
+ return_when=asyncio.FIRST_COMPLETED
215
+ )
216
+
217
+ # Cancel pending tasks
218
+ for task in pending:
219
+ task.cancel()
220
+
221
+ # Check if we exited
222
+ if self._exited.is_set():
223
+ logger.info("Closing interactive shell (stdin forwarding).")
224
+ break
225
+
226
+ # Get the data from completed read task
227
+ for task in done:
228
+ if task.exception() is None:
229
+ data = task.result()
230
+ if isinstance(data, bytes) and data:
231
+ await self._stdin.send_input(data)
232
+ elif not data:
233
+ # EOF
234
+ return
235
+
236
+ except asyncio.CancelledError:
237
+ break
238
+
239
+ except asyncio.CancelledError:
240
+ logger.info("Cancelling interactive shell (stdin forwarding).")
241
+ except Exception as e:
242
+ logger.error(f"Caught exception on stdin: {e}")
243
+
244
+ async def _forward_output(self):
245
+ """Forward PTY output to stdout (internal)."""
246
+ try:
247
+ if self._stdout is None:
248
+ return
249
+
250
+ # Forward all output to stdout
251
+ async for chunk in self._stdout:
252
+ # Write directly to stdout (bypass print buffering)
253
+ if isinstance(chunk, bytes):
254
+ sys.stdout.buffer.write(chunk)
255
+ else:
256
+ sys.stdout.buffer.write(chunk.encode('utf-8', errors='replace'))
257
+ sys.stdout.buffer.flush()
258
+
259
+ logger.info("\nOutput forwarding ended.")
260
+
261
+ except asyncio.CancelledError:
262
+ logger.error("Cancelling interactive shell (stdout forwarding).")
263
+ except Exception as e:
264
+ logger.error(f"\nError forwarding output: {e}", file=sys.stderr)
265
+
266
+ async def _forward_stderr(self):
267
+ """Forward PTY stderr to stderr (internal)."""
268
+ try:
269
+ if self._stderr is None:
270
+ return
271
+
272
+ # Forward all error output to stderr
273
+ async for chunk in self._stderr:
274
+ # Write directly to stderr (bypass print buffering)
275
+ if isinstance(chunk, bytes):
276
+ sys.stderr.buffer.write(chunk)
277
+ else:
278
+ sys.stderr.buffer.write(chunk.encode('utf-8', errors='replace'))
279
+ sys.stderr.buffer.flush()
280
+
281
+ logger.info("\nStderr forwarding ended.")
282
+
283
+ except asyncio.CancelledError:
284
+ logger.error("Cancelling interactive shell (stderr forwarding).")
285
+ except Exception as e:
286
+ logger.error(f"\nError forwarding stderr: {e}", file=sys.stderr)
287
+
288
+ async def _wait_for_exit(self):
289
+ """Wait for the shell to exit (internal)."""
290
+ try:
291
+ await self._execution.wait()
292
+ except Exception:
293
+ pass # Ignore errors, cleanup will happen in __aexit__
294
+ finally:
295
+ # Signal other tasks to stop
296
+ if self._exited:
297
+ self._exited.set()
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
boxlite/runtime/mke2fs ADDED
Binary file
boxlite/simplebox.py ADDED
@@ -0,0 +1,213 @@
1
+ """
2
+ SimpleBox - Foundation for specialized container types.
3
+
4
+ Provides common functionality for all specialized boxes (CodeBox, BrowserBox, etc.)
5
+ """
6
+
7
+ import logging
8
+ from enum import IntEnum
9
+ from typing import Optional
10
+
11
+ from .exec import ExecResult
12
+
13
+ logger = logging.getLogger("boxlite.simplebox")
14
+
15
+ __all__ = ['SimpleBox']
16
+
17
+
18
+ class StreamType(IntEnum):
19
+ """Stream type for command execution output."""
20
+ STDOUT = 1
21
+ STDERR = 2
22
+
23
+
24
+ class SimpleBox:
25
+ """
26
+ Base class for specialized container types.
27
+
28
+ This class encapsulates the common patterns:
29
+ 1. Async context manager support
30
+ 2. Automatic runtime lifecycle management
31
+ 3. Stdio blocking mode restoration
32
+
33
+ Subclasses should override:
34
+ - _create_box_options(): Return BoxOptions for their specific use case
35
+ - Add domain-specific methods (e.g., CodeBox.run(), BrowserBox.navigate())
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ image: str,
41
+ memory_mib: Optional[int] = None,
42
+ cpus: Optional[int] = None,
43
+ runtime: Optional['Boxlite'] = None,
44
+ name: Optional[str] = None,
45
+ auto_remove: bool = True,
46
+ **kwargs
47
+ ):
48
+ """
49
+ Create a specialized box.
50
+
51
+ Args:
52
+ image: Container images to use
53
+ memory_mib: Memory limit in MiB
54
+ cpus: Number of CPU cores
55
+ runtime: Optional runtime instance (uses global default if None)
56
+ name: Optional name for the box (must be unique)
57
+ auto_remove: Remove box when stopped (default: True)
58
+ **kwargs: Additional configuration options
59
+ """
60
+ try:
61
+ from .boxlite import Boxlite
62
+ except ImportError as e:
63
+ raise ImportError(
64
+ f"BoxLite native extension not found: {e}. "
65
+ "Please install with: pip install boxlite"
66
+ )
67
+
68
+ # Use provided runtime or get Rust's global default
69
+ if runtime is None:
70
+ self._runtime = Boxlite.default()
71
+ else:
72
+ self._runtime = runtime
73
+
74
+ # Create box using subclass-defined options
75
+ try:
76
+ from .boxlite import BoxOptions
77
+ except ImportError as e:
78
+ raise ImportError(
79
+ f"BoxLite native extension not found: {e}. "
80
+ "Please install with: pip install boxlite"
81
+ )
82
+
83
+ box_opts = BoxOptions(
84
+ image=image,
85
+ cpus=cpus,
86
+ memory_mib=memory_mib,
87
+ auto_remove=auto_remove,
88
+ **kwargs
89
+ )
90
+ self._name = name
91
+ self._box = self._runtime.create(box_opts, name=name)
92
+
93
+ async def __aenter__(self):
94
+ """Async context manager entry - delegates to Box.__aenter__."""
95
+ await self._box.__aenter__()
96
+ return self
97
+
98
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
99
+ """Async context manager exit - delegates to Box.__aexit__ (returns awaitable)."""
100
+ return await self._box.__aexit__(exc_type, exc_val, exc_tb)
101
+
102
+ @property
103
+ def id(self) -> str:
104
+ """Get the box ID."""
105
+ return self._box.id
106
+
107
+ def info(self):
108
+ """Get box information."""
109
+ return self._box.info()
110
+
111
+ async def exec(
112
+ self,
113
+ cmd: str,
114
+ *args: str,
115
+ env: Optional[dict[str, str]] = None,
116
+ ) -> ExecResult:
117
+ """
118
+ Execute a command in the box and return the result.
119
+
120
+ Args:
121
+ cmd: Command to execute (e.g., 'ls', 'python')
122
+ *args: Arguments to the command (e.g., '-l', '-a')
123
+ env: Environment variables (default: guest's default environment)
124
+
125
+ Returns:
126
+ ExecResult with exit_code and output
127
+
128
+ Examples:
129
+ Simple execution::
130
+
131
+ result = await box.exec('ls', '-l', '-a')
132
+ print(f"Exit code: {result.exit_code}")
133
+ print(f"Stdout: {result.stdout}")
134
+ print(f"Stderr: {result.stderr}")
135
+
136
+ With environment variables::
137
+
138
+ result = await box.exec('env', env={'FOO': 'bar'})
139
+ print(result.stdout)
140
+ """
141
+
142
+ arg_list = list(args) if args else None
143
+ # Convert env dict to list of tuples if provided
144
+ env_list = list(env.items()) if env else None
145
+
146
+ # Execute via Rust (returns PyExecution)
147
+ execution = await self._box.exec(cmd, arg_list, env_list)
148
+
149
+ # Get streams from Rust execution
150
+ try:
151
+ stdout = execution.stdout()
152
+ except Exception as e:
153
+ logger.error(f"take stdout err: {e}")
154
+ stdout = None
155
+
156
+ try:
157
+ stderr = execution.stderr()
158
+ except Exception as e:
159
+ logger.error(f"take stderr err: {e}")
160
+ stderr = None
161
+
162
+ # Collect stdout and stderr separately
163
+ stdout_lines = []
164
+ stderr_lines = []
165
+
166
+ # Read stdout
167
+ if stdout:
168
+ logger.debug("collecting stdout")
169
+ try:
170
+ async for line in stdout:
171
+ if isinstance(line, bytes):
172
+ stdout_lines.append(line.decode('utf-8', errors='replace'))
173
+ else:
174
+ stdout_lines.append(line)
175
+ except Exception as e:
176
+ logger.error(f"collecting stdout err: {e}")
177
+ pass
178
+
179
+ # Read stderr
180
+ if stderr:
181
+ logger.debug("collecting stderr")
182
+ try:
183
+ async for line in stderr:
184
+ if isinstance(line, bytes):
185
+ stderr_lines.append(line.decode('utf-8', errors='replace'))
186
+ else:
187
+ stderr_lines.append(line)
188
+ except Exception as e:
189
+ logger.error(f"collecting stderr err: {e}")
190
+ pass
191
+
192
+ # Combine lines
193
+ stdout = ''.join(stdout_lines)
194
+ stderr = ''.join(stderr_lines)
195
+
196
+ try:
197
+ exec_result = await execution.wait()
198
+ exit_code = exec_result.exit_code
199
+ except Exception as e:
200
+ logger.error(f"failed to wait execution: {e}")
201
+ exit_code = -1
202
+
203
+ logger.debug(f"exec finish, exit_code: {exit_code}")
204
+
205
+ return ExecResult(exit_code=exit_code, stdout=stdout, stderr=stderr)
206
+
207
+ def shutdown(self):
208
+ """
209
+ Shutdown the box and release resources.
210
+
211
+ Note: Usually not needed as context manager handles cleanup.
212
+ """
213
+ self._box.shutdown()
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: boxlite
3
+ Version: 0.4.4
4
+ Requires-Dist: pytest>=7.0 ; extra == 'dev'
5
+ Requires-Dist: pytest-asyncio>=0.21 ; extra == 'dev'
6
+ Provides-Extra: dev
7
+ Summary: Python bindings for Boxlite runtime
8
+ Author: Dorian Zheng
9
+ License-Expression: Apache-2.0
10
+ Requires-Python: >=3.10
@@ -0,0 +1,20 @@
1
+ boxlite-0.4.4.dist-info/METADATA,sha256=aMoMYaLm5ixjzgjdnwfYUZ6CKWsmaODXqPcXgKR5sRs,289
2
+ boxlite-0.4.4.dist-info/WHEEL,sha256=HAJMEU09F1HWyVYPOgBd7WyBb6phaxzQDlxPs_WDVX4,111
3
+ boxlite/computerbox.py,sha256=bMFGyK2Gl0w3sw3TJmF6Cl5u2GlaMhSC4WVVbHMGsMg,10566
4
+ boxlite/codebox.py,sha256=buppZbGh2_jd5syCXx-xFUMQWvo7DRAMXo5unEAgIZY,3677
5
+ boxlite/constants.py,sha256=lIQkb5AHiqef_q9g23szDdzuCNpY8xBWSD0qD52Oc-U,615
6
+ boxlite/runtime/debugfs,sha256=GjQo8rlE26aGRB5CidwkbLtvPujq2-Q5teeECpmwP24,3377888
7
+ boxlite/runtime/boxlite-shim,sha256=ds_BTrdL0n3py4AZwj1a8FN9wDKGHxjRfm5IuImIyHQ,4739016
8
+ boxlite/runtime/libkrunfw.so.4,sha256=hIcMTKV11rLcBBRH1bh91tUbq2Q2hLVKaO3iUwDmSG8,19007440
9
+ boxlite/runtime/boxlite-guest,sha256=ig_PCWeGjFaJ7ezIhBYAeIAK6Cn_-zhSu7r_2oLBFKE,12374472
10
+ boxlite/runtime/libkrun.so.1.15.1,sha256=bD-jL1ggIC_QGV0DVi7Nx_b0hu8RBT7ATwzesXGFLWI,6197041
11
+ boxlite/runtime/mke2fs,sha256=tSlI9SmM2jiIkrkncq_dQOvjeFdAviIW8mGe0by3Ya0,2833808
12
+ boxlite/browserbox.py,sha256=UX4yxZsnbFHgyLhS9-OC58AG1pecHpUwbxlO6leiV98,4477
13
+ boxlite/__init__.py,sha256=2wYQEKJc86jXJIwWmIX1s1OiE3FcRs6k29NzCikcm74,1990
14
+ boxlite/simplebox.py,sha256=OIuoEhorcJMefc1_Bc-5RkWvytTg-0V-5joD9EZMqiQ,6497
15
+ boxlite/errors.py,sha256=vuW2uZvkrX9GGvE3SKIogO8r1rBxUplit_3F2CSxglg,957
16
+ boxlite/exec.py,sha256=UWoLBnKUPx93ueHC2E3KVU7evaj06TIb1pHhuShZwbM,507
17
+ boxlite/interactivebox.py,sha256=4Ffaf-u7m46bIRgHQ61qcLbmgWgUFkmQDLxXykzNy3g,10598
18
+ boxlite/runtime/libgvproxy.so,sha256=ok2SyLNAtxvX7hW3c7KKvlvA2xvJ1vGqVym94dLx9Qs,17123393
19
+ boxlite/boxlite.cpython-314t-x86_64-linux-gnu.so,sha256=iKnZpkxA_KIcmj6VrzBGP5g36upW4KoeUeWpJTwQuLA,18906624
20
+ boxlite-0.4.4.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: maturin (1.10.2)
3
+ Root-Is-Purelib: false
4
+ Tag: cp314-cp314t-manylinux_2_28_x86_64
5
+