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/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,44 @@
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
+
13
+ pass
14
+
15
+
16
+ class ExecError(BoxliteError):
17
+ """
18
+ Raised when a command execution fails (non-zero exit code).
19
+
20
+ Attributes:
21
+ command: The command that failed
22
+ exit_code: The non-zero exit code
23
+ stderr: Standard error output from the command
24
+ """
25
+
26
+ def __init__(self, command: str, exit_code: int, stderr: str):
27
+ self.command = command
28
+ self.exit_code = exit_code
29
+ self.stderr = stderr
30
+ super().__init__(
31
+ f"Command '{command}' failed with exit code {exit_code}: {stderr}"
32
+ )
33
+
34
+
35
+ class TimeoutError(BoxliteError):
36
+ """Raised when an operation times out."""
37
+
38
+ pass
39
+
40
+
41
+ class ParseError(BoxliteError):
42
+ """Raised when output parsing fails."""
43
+
44
+ pass
boxlite/exec.py ADDED
@@ -0,0 +1,27 @@
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
+
25
+ exit_code: int
26
+ stdout: str
27
+ stderr: str
@@ -0,0 +1,287 @@
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
+ from .simplebox import SimpleBox
16
+
17
+ if TYPE_CHECKING:
18
+ from .boxlite import Boxlite
19
+
20
+ # Configure logger
21
+ logger = logging.getLogger("boxlite.interactivebox")
22
+
23
+
24
+ class InteractiveBox(SimpleBox):
25
+ """
26
+ Interactive box with automatic PTY and terminal forwarding.
27
+
28
+ When used as a context manager, automatically:
29
+ 1. Auto-detects terminal size (for PTY)
30
+ 2. Starts a shell with PTY
31
+ 3. Sets local terminal to cbreak mode
32
+ 4. Forwards stdin/stdout bidirectionally
33
+ 5. Restores terminal mode on exit
34
+
35
+ Example:
36
+ async with InteractiveBox(image="alpine:latest") as box:
37
+ # You're now in an interactive shell!
38
+ # Type commands, see output in real-time
39
+ # Type "exit" to close
40
+ pass
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ image: str,
46
+ shell: str = "/bin/sh",
47
+ tty: Optional[bool] = None,
48
+ memory_mib: Optional[int] = None,
49
+ cpus: Optional[int] = None,
50
+ runtime: Optional["Boxlite"] = None,
51
+ name: Optional[str] = None,
52
+ auto_remove: bool = True,
53
+ **kwargs,
54
+ ):
55
+ """
56
+ Create interactive box.
57
+
58
+ Args:
59
+ image: Container image to use
60
+ shell: Shell to run (default: /bin/sh)
61
+ tty: Control terminal I/O forwarding behavior:
62
+ - None (default): Auto-detect - forward I/O if stdin is a TTY
63
+ - True: Force I/O forwarding (manual interactive mode)
64
+ - False: No I/O forwarding (programmatic control only)
65
+ memory_mib: Memory limit in MiB
66
+ cpus: Number of CPU cores
67
+ runtime: Optional runtime instance (uses global default if None)
68
+ name: Optional name for the box (must be unique)
69
+ auto_remove: Remove box when stopped (default: True)
70
+ **kwargs: Additional configuration options (working_dir, env)
71
+ """
72
+ # Initialize base class (handles runtime, BoxOptions, _box, _started)
73
+ super().__init__(
74
+ image=image,
75
+ memory_mib=memory_mib,
76
+ cpus=cpus,
77
+ runtime=runtime,
78
+ name=name,
79
+ auto_remove=auto_remove,
80
+ **kwargs,
81
+ )
82
+
83
+ # InteractiveBox-specific config
84
+ self._shell = shell
85
+ self._env = kwargs.get("env", [])
86
+
87
+ # Determine TTY mode: None = auto-detect, True = force, False = disable
88
+ self._tty = sys.stdin.isatty() if tty is None else tty
89
+
90
+ # Interactive state
91
+ self._old_tty_settings = None
92
+ self._io_task = None
93
+ self._execution = None
94
+ self._stdin = None
95
+ self._stdout = None
96
+ self._stderr = None
97
+ self._exited = None # Event to signal process exit
98
+
99
+ # id property inherited from SimpleBox
100
+
101
+ async def __aenter__(self):
102
+ """Start box and enter interactive TTY session."""
103
+ if self._started:
104
+ return self
105
+
106
+ # Create and start box (via parent)
107
+ await super().__aenter__()
108
+
109
+ # Start shell with PTY
110
+ self._execution = await self._start_interactive_shell()
111
+
112
+ # Get stdin/stdout/stderr ONCE (can only be called once due to .take())
113
+ self._stdin = self._execution.stdin()
114
+ self._stdout = self._execution.stdout()
115
+ self._stderr = self._execution.stderr()
116
+
117
+ # Only set cbreak mode and start forwarding if tty=True
118
+ if self._tty:
119
+ stdin_fd = sys.stdin.fileno()
120
+ self._old_tty_settings = termios.tcgetattr(stdin_fd)
121
+ tty.setraw(sys.stdin.fileno(), when=termios.TCSANOW)
122
+
123
+ # Create exit event for graceful shutdown
124
+ self._exited = asyncio.Event()
125
+
126
+ # Start bidirectional I/O forwarding using gather (more Pythonic)
127
+ self._io_task = asyncio.gather(
128
+ self._forward_stdin(),
129
+ self._forward_output(),
130
+ self._forward_stderr(),
131
+ self._wait_for_exit(),
132
+ return_exceptions=True,
133
+ )
134
+ else:
135
+ # No I/O forwarding, just wait for execution
136
+ self._io_task = self._wait_for_exit()
137
+
138
+ return self
139
+
140
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
141
+ # Restore terminal settings
142
+ if self._old_tty_settings is not None:
143
+ try:
144
+ termios.tcsetattr(
145
+ sys.stdin.fileno(), termios.TCSADRAIN, self._old_tty_settings
146
+ )
147
+ except Exception as e:
148
+ logger.error(f"Caught exception on TTY settings: {e}")
149
+
150
+ """Exit interactive session and restore terminal."""
151
+ # Wait for I/O task to complete (or cancel if needed)
152
+ if hasattr(self, "_io_task") and self._io_task is not None:
153
+ try:
154
+ # Give it a moment to finish naturally
155
+ await asyncio.wait_for(self._io_task, timeout=3)
156
+ logger.info("Closing interactive shell (I/O tasks finished).")
157
+ self._io_task = None
158
+ except asyncio.TimeoutError:
159
+ # If it doesn't finish, that's ok - box is shutting down anyway
160
+ logger.error("Timeout waiting for I/O tasks to finish, cancelling...")
161
+ except Exception as e:
162
+ # Ignore other exceptions during cleanup
163
+ logger.error(f"Caught exception on exit: {e}")
164
+
165
+ # Shutdown box (via parent)
166
+ return await super().__aexit__(exc_type, exc_val, exc_tb)
167
+
168
+ async def wait(self):
169
+ await self._execution.wait()
170
+
171
+ async def _start_interactive_shell(self):
172
+ """Start shell with PTY (internal)."""
173
+ # Execute shell with PTY using simplified boolean API
174
+ # Terminal size is auto-detected (like Docker)
175
+ execution = await self._box.exec(
176
+ self._shell,
177
+ args=[],
178
+ env=self._env,
179
+ tty=True, # Simple boolean - auto-detects terminal size
180
+ )
181
+
182
+ return execution
183
+
184
+ async def _forward_stdin(self):
185
+ """Forward stdin to PTY (internal)."""
186
+ try:
187
+ if self._stdin is None:
188
+ return
189
+
190
+ # Forward stdin in chunks
191
+ loop = asyncio.get_event_loop()
192
+ while not self._exited.is_set():
193
+ # Read from stdin with timeout to check exit event
194
+ try:
195
+ read_task = loop.run_in_executor(
196
+ None, os.read, sys.stdin.fileno(), 1024
197
+ )
198
+ # Wait for either stdin data or exit event
199
+ done, pending = await asyncio.wait(
200
+ [
201
+ asyncio.ensure_future(read_task),
202
+ asyncio.ensure_future(self._exited.wait()),
203
+ ],
204
+ return_when=asyncio.FIRST_COMPLETED,
205
+ )
206
+
207
+ # Cancel pending tasks
208
+ for task in pending:
209
+ task.cancel()
210
+
211
+ # Check if we exited
212
+ if self._exited.is_set():
213
+ logger.info("Closing interactive shell (stdin forwarding).")
214
+ break
215
+
216
+ # Get the data from completed read task
217
+ for task in done:
218
+ if task.exception() is None:
219
+ data = task.result()
220
+ if isinstance(data, bytes) and data:
221
+ await self._stdin.send_input(data)
222
+ elif not data:
223
+ # EOF
224
+ return
225
+
226
+ except asyncio.CancelledError:
227
+ break
228
+
229
+ except asyncio.CancelledError:
230
+ logger.info("Cancelling interactive shell (stdin forwarding).")
231
+ except Exception as e:
232
+ logger.error(f"Caught exception on stdin: {e}")
233
+
234
+ async def _forward_output(self):
235
+ """Forward PTY output to stdout (internal)."""
236
+ try:
237
+ if self._stdout is None:
238
+ return
239
+
240
+ # Forward all output to stdout
241
+ async for chunk in self._stdout:
242
+ # Write directly to stdout (bypass print buffering)
243
+ if isinstance(chunk, bytes):
244
+ sys.stdout.buffer.write(chunk)
245
+ else:
246
+ sys.stdout.buffer.write(chunk.encode("utf-8", errors="replace"))
247
+ sys.stdout.buffer.flush()
248
+
249
+ logger.info("\nOutput forwarding ended.")
250
+
251
+ except asyncio.CancelledError:
252
+ logger.error("Cancelling interactive shell (stdout forwarding).")
253
+ except Exception as e:
254
+ logger.error(f"\nError forwarding output: {e}", file=sys.stderr)
255
+
256
+ async def _forward_stderr(self):
257
+ """Forward PTY stderr to stderr (internal)."""
258
+ try:
259
+ if self._stderr is None:
260
+ return
261
+
262
+ # Forward all error output to stderr
263
+ async for chunk in self._stderr:
264
+ # Write directly to stderr (bypass print buffering)
265
+ if isinstance(chunk, bytes):
266
+ sys.stderr.buffer.write(chunk)
267
+ else:
268
+ sys.stderr.buffer.write(chunk.encode("utf-8", errors="replace"))
269
+ sys.stderr.buffer.flush()
270
+
271
+ logger.info("\nStderr forwarding ended.")
272
+
273
+ except asyncio.CancelledError:
274
+ logger.error("Cancelling interactive shell (stderr forwarding).")
275
+ except Exception as e:
276
+ logger.error(f"\nError forwarding stderr: {e}", file=sys.stderr)
277
+
278
+ async def _wait_for_exit(self):
279
+ """Wait for the shell to exit (internal)."""
280
+ try:
281
+ await self._execution.wait()
282
+ except Exception:
283
+ pass # Ignore errors, cleanup will happen in __aexit__
284
+ finally:
285
+ # Signal other tasks to stop
286
+ if self._exited:
287
+ 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,256 @@
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, TYPE_CHECKING
10
+
11
+ from .exec import ExecResult
12
+
13
+ if TYPE_CHECKING:
14
+ from .boxlite import Boxlite
15
+
16
+ logger = logging.getLogger("boxlite.simplebox")
17
+
18
+ __all__ = ["SimpleBox"]
19
+
20
+
21
+ class StreamType(IntEnum):
22
+ """Stream type for command execution output."""
23
+
24
+ STDOUT = 1
25
+ STDERR = 2
26
+
27
+
28
+ class SimpleBox:
29
+ """
30
+ Base class for specialized container types.
31
+
32
+ This class encapsulates the common patterns:
33
+ 1. Async context manager support
34
+ 2. Automatic runtime lifecycle management
35
+ 3. Stdio blocking mode restoration
36
+
37
+ Subclasses should override:
38
+ - _create_box_options(): Return BoxOptions for their specific use case
39
+ - Add domain-specific methods (e.g., CodeBox.run(), BrowserBox.navigate())
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ image: str,
45
+ memory_mib: Optional[int] = None,
46
+ cpus: Optional[int] = None,
47
+ runtime: Optional["Boxlite"] = None,
48
+ name: Optional[str] = None,
49
+ auto_remove: bool = True,
50
+ **kwargs,
51
+ ):
52
+ """
53
+ Create a specialized box.
54
+
55
+ Args:
56
+ image: Container images to use
57
+ memory_mib: Memory limit in MiB
58
+ cpus: Number of CPU cores
59
+ runtime: Optional runtime instance (uses global default if None)
60
+ name: Optional name for the box (must be unique)
61
+ auto_remove: Remove box when stopped (default: True)
62
+ **kwargs: Additional configuration options
63
+
64
+ Note: The box is not actually created until entering the async context manager.
65
+ Use `async with SimpleBox(...) as box:` to create and start the box.
66
+ """
67
+ try:
68
+ from .boxlite import Boxlite, BoxOptions
69
+ except ImportError as e:
70
+ raise ImportError(
71
+ f"BoxLite native extension not found: {e}. "
72
+ "Please install with: pip install boxlite"
73
+ )
74
+
75
+ # Use provided runtime or get Rust's global default
76
+ if runtime is None:
77
+ self._runtime = Boxlite.default()
78
+ else:
79
+ self._runtime = runtime
80
+
81
+ # Store box options for deferred creation in __aenter__
82
+ self._box_options = BoxOptions(
83
+ image=image,
84
+ cpus=cpus,
85
+ memory_mib=memory_mib,
86
+ auto_remove=auto_remove,
87
+ **kwargs,
88
+ )
89
+ self._name = name
90
+ self._box = None
91
+ self._started = False
92
+
93
+ async def __aenter__(self):
94
+ """Async context manager entry - creates and starts the box.
95
+
96
+ This method is idempotent - calling it multiple times is safe.
97
+ All initialization logic lives here; start() is just an alias.
98
+ """
99
+ if self._started:
100
+ return self
101
+ self._box = await self._runtime.create(self._box_options, name=self._name)
102
+ await self._box.__aenter__()
103
+ self._started = True
104
+ return self
105
+
106
+ async def start(self):
107
+ """
108
+ Explicitly create and start the box.
109
+
110
+ Alternative to using context manager. Allows::
111
+
112
+ box = SimpleBox(image="alpine:latest")
113
+ await box.start()
114
+ await box.exec("echo", "hello")
115
+
116
+ Returns:
117
+ self for method chaining
118
+ """
119
+ return await self.__aenter__()
120
+
121
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
122
+ """Async context manager exit - delegates to Box.__aexit__ (returns awaitable)."""
123
+ return await self._box.__aexit__(exc_type, exc_val, exc_tb)
124
+
125
+ @property
126
+ def id(self) -> str:
127
+ """Get the box ID."""
128
+ if not self._started:
129
+ raise RuntimeError(
130
+ "Box not started. Use 'async with SimpleBox(...) as box:' "
131
+ "or call 'await box.start()' first."
132
+ )
133
+ return self._box.id
134
+
135
+ def info(self):
136
+ """Get box information."""
137
+ if not self._started:
138
+ raise RuntimeError(
139
+ "Box not started. Use 'async with SimpleBox(...) as box:' "
140
+ "or call 'await box.start()' first."
141
+ )
142
+ return self._box.info()
143
+
144
+ async def exec(
145
+ self,
146
+ cmd: str,
147
+ *args: str,
148
+ env: Optional[dict[str, str]] = None,
149
+ ) -> ExecResult:
150
+ """
151
+ Execute a command in the box and return the result.
152
+
153
+ Args:
154
+ cmd: Command to execute (e.g., 'ls', 'python')
155
+ *args: Arguments to the command (e.g., '-l', '-a')
156
+ env: Environment variables (default: guest's default environment)
157
+
158
+ Returns:
159
+ ExecResult with exit_code and output
160
+
161
+ Examples:
162
+ Simple execution::
163
+
164
+ result = await box.exec('ls', '-l', '-a')
165
+ print(f"Exit code: {result.exit_code}")
166
+ print(f"Stdout: {result.stdout}")
167
+ print(f"Stderr: {result.stderr}")
168
+
169
+ With environment variables::
170
+
171
+ result = await box.exec('env', env={'FOO': 'bar'})
172
+ print(result.stdout)
173
+ """
174
+ if not self._started:
175
+ raise RuntimeError(
176
+ "Box not started. Use 'async with SimpleBox(...) as box:' "
177
+ "or call 'await box.start()' first."
178
+ )
179
+
180
+ arg_list = list(args) if args else None
181
+ # Convert env dict to list of tuples if provided
182
+ env_list = list(env.items()) if env else None
183
+
184
+ # Execute via Rust (returns PyExecution)
185
+ execution = await self._box.exec(cmd, arg_list, env_list)
186
+
187
+ # Get streams from Rust execution
188
+ try:
189
+ stdout = execution.stdout()
190
+ except Exception as e:
191
+ logger.error(f"take stdout err: {e}")
192
+ stdout = None
193
+
194
+ try:
195
+ stderr = execution.stderr()
196
+ except Exception as e:
197
+ logger.error(f"take stderr err: {e}")
198
+ stderr = None
199
+
200
+ # Collect stdout and stderr separately
201
+ stdout_lines = []
202
+ stderr_lines = []
203
+
204
+ # Read stdout
205
+ if stdout:
206
+ logger.debug("collecting stdout")
207
+ try:
208
+ async for line in stdout:
209
+ if isinstance(line, bytes):
210
+ stdout_lines.append(line.decode("utf-8", errors="replace"))
211
+ else:
212
+ stdout_lines.append(line)
213
+ except Exception as e:
214
+ logger.error(f"collecting stdout err: {e}")
215
+ pass
216
+
217
+ # Read stderr
218
+ if stderr:
219
+ logger.debug("collecting stderr")
220
+ try:
221
+ async for line in stderr:
222
+ if isinstance(line, bytes):
223
+ stderr_lines.append(line.decode("utf-8", errors="replace"))
224
+ else:
225
+ stderr_lines.append(line)
226
+ except Exception as e:
227
+ logger.error(f"collecting stderr err: {e}")
228
+ pass
229
+
230
+ # Combine lines
231
+ stdout = "".join(stdout_lines)
232
+ stderr = "".join(stderr_lines)
233
+
234
+ try:
235
+ exec_result = await execution.wait()
236
+ exit_code = exec_result.exit_code
237
+ except Exception as e:
238
+ logger.error(f"failed to wait execution: {e}")
239
+ exit_code = -1
240
+
241
+ logger.debug(f"exec finish, exit_code: {exit_code}")
242
+
243
+ return ExecResult(exit_code=exit_code, stdout=stdout, stderr=stderr)
244
+
245
+ def shutdown(self):
246
+ """
247
+ Shutdown the box and release resources.
248
+
249
+ Note: Usually not needed as context manager handles cleanup.
250
+ """
251
+ if not self._started:
252
+ raise RuntimeError(
253
+ "Box not started. Use 'async with SimpleBox(...) as box:' "
254
+ "or call 'await box.start()' first."
255
+ )
256
+ self._box.shutdown()