containerbox 0.1.0__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.
@@ -0,0 +1,3 @@
1
+ from containerbox.session import SandboxError, SandboxResult, SandboxSession
2
+
3
+ __all__ = ["SandboxError", "SandboxResult", "SandboxSession"]
@@ -0,0 +1,393 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import shlex
5
+ import tarfile
6
+ import time
7
+ from contextlib import suppress
8
+ from dataclasses import dataclass
9
+ from pathlib import Path, PurePosixPath
10
+ from typing import Sequence
11
+
12
+ import docker
13
+ from docker.errors import DockerException
14
+ from docker.models.containers import Container
15
+
16
+
17
+ Command = str | Sequence[str]
18
+
19
+
20
+ class SandboxError(RuntimeError):
21
+ pass
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class SandboxResult:
26
+ stdout: str
27
+ stderr: str
28
+ exit_code: int
29
+ timed_out: bool
30
+ duration_ms: int
31
+
32
+ @property
33
+ def ok(self) -> bool:
34
+ return self.exit_code == 0 and not self.timed_out
35
+
36
+
37
+ class _DockerBackend:
38
+ def __init__(
39
+ self,
40
+ *,
41
+ image: str,
42
+ command: Command,
43
+ workdir: str,
44
+ memory: str | int | None = None,
45
+ cpus: float | None = None,
46
+ network: bool,
47
+ ) -> None:
48
+ self.image = image
49
+ self.command = command
50
+ self.workdir = workdir
51
+ self.memory = memory
52
+ self.cpus = cpus
53
+ self.network = network
54
+ self.client: docker.DockerClient | None = None
55
+ self.container: Container | None = None
56
+
57
+ def create(self) -> None:
58
+ self.client = docker.from_env()
59
+ options = {
60
+ "image": self.image,
61
+ "command": self.command,
62
+ "detach": True,
63
+ "working_dir": self.workdir,
64
+ "network_disabled": not self.network,
65
+ "security_opt": ["no-new-privileges"],
66
+ }
67
+ if self.memory is not None:
68
+ options["mem_limit"] = self.memory
69
+ if self.cpus is not None:
70
+ options["nano_cpus"] = int(self.cpus * 1_000_000_000)
71
+
72
+ self.container = self.client.containers.create(**options)
73
+
74
+ def start(self) -> None:
75
+ container = self.require_container()
76
+ container.reload()
77
+ if container.status != "running":
78
+ container.start()
79
+
80
+ def exec(self, cmd: Command, *, workdir: str, user: str | None = None):
81
+ return self.require_container().exec_run(
82
+ cmd,
83
+ workdir=workdir,
84
+ user=user or "",
85
+ stdout=True,
86
+ stderr=True,
87
+ demux=True,
88
+ )
89
+
90
+ def put_archive(self, path: str, data: bytes) -> None:
91
+ self.require_container().put_archive(path, data)
92
+
93
+ def get_archive(self, path: str) -> bytes:
94
+ stream, _ = self.require_container().get_archive(path)
95
+ return b"".join(stream)
96
+
97
+ def close(self) -> None:
98
+ try:
99
+ if self.container is not None:
100
+ with suppress(DockerException):
101
+ self.container.reload()
102
+ if self.container.status == "running":
103
+ self.container.stop(timeout=1)
104
+ with suppress(DockerException):
105
+ self.container.remove(force=True)
106
+ self.container = None
107
+ finally:
108
+ if self.client is not None:
109
+ self.client.close()
110
+ self.client = None
111
+
112
+ def require_container(self) -> Container:
113
+ if self.container is None:
114
+ raise RuntimeError("SandboxSession must be used as a context manager")
115
+ return self.container
116
+
117
+
118
+ class SandboxSession:
119
+ def __init__(
120
+ self,
121
+ image: str = "ubuntu:24.04",
122
+ *,
123
+ runtime: str | None = "docker",
124
+ workdir: str = "/workspace",
125
+ session_timeout: int | None = None,
126
+ memory: str | int | None = "256m",
127
+ cpus: float | None = 1.0,
128
+ network: bool = False,
129
+ user: str | None = "1000:1000",
130
+ command: Command = ("sleep", "infinity"),
131
+ ) -> None:
132
+ if runtime not in (None, "docker"):
133
+ raise ValueError("Only the Docker runtime is supported")
134
+
135
+ self.runtime = runtime
136
+ self.workdir = self._workdir_path(workdir)
137
+ self.session_timeout = session_timeout
138
+ self._started_at: float | None = None
139
+ self.user = user
140
+ self._workspace_ready = False
141
+ self._backend = _DockerBackend(
142
+ image=image,
143
+ command=command,
144
+ workdir=self.workdir,
145
+ memory=memory,
146
+ cpus=cpus,
147
+ network=network,
148
+ )
149
+
150
+ @property
151
+ def is_open(self) -> bool:
152
+ return self._backend.container is not None
153
+
154
+ def open(self) -> SandboxSession:
155
+ if self.is_open:
156
+ return self
157
+
158
+ try:
159
+ self._backend.create()
160
+ self._started_at = time.monotonic()
161
+ except DockerException as exc:
162
+ self._backend.close()
163
+ raise SandboxError(f"failed to create sandbox: {exc}") from exc
164
+ return self
165
+
166
+ def close(self) -> None:
167
+ self._backend.close()
168
+ self._started_at = None
169
+ self._workspace_ready = False
170
+
171
+ def __enter__(self) -> SandboxSession:
172
+ return self.open()
173
+
174
+ def __exit__(self, *_: object) -> None:
175
+ self.close()
176
+
177
+ def exec(self, command: Command, *, timeout: int | None = None) -> SandboxResult:
178
+ self._check_session_timeout()
179
+ self._start()
180
+ cmd = self._with_timeout(command, timeout)
181
+ started = time.perf_counter()
182
+
183
+ try:
184
+ result = self._backend.exec(cmd, workdir=self.workdir, user=self.user)
185
+ except DockerException as exc:
186
+ raise SandboxError(f"failed to execute command: {exc}") from exc
187
+
188
+ duration_ms = round((time.perf_counter() - started) * 1000)
189
+ stdout, stderr = self._decode(result.output)
190
+ exit_code = result.exit_code or 0
191
+
192
+ return SandboxResult(
193
+ stdout=stdout,
194
+ stderr=stderr,
195
+ exit_code=exit_code,
196
+ timed_out=exit_code == 124,
197
+ duration_ms=duration_ms,
198
+ )
199
+
200
+ def run_code(
201
+ self,
202
+ code: str,
203
+ *,
204
+ filename: str = "main.py",
205
+ command: Command | None = None,
206
+ timeout: int | None = None,
207
+ ) -> SandboxResult:
208
+ self._check_session_timeout()
209
+ target = self._container_path(filename)
210
+ self._put_bytes(code.encode(), target)
211
+ return self.exec(command or ["python", PurePosixPath(target).name], timeout=timeout)
212
+
213
+ def upload(self, src: str | Path, dest: str | None = None) -> str:
214
+ self._check_session_timeout()
215
+ self._start()
216
+ src_path = Path(src)
217
+ if not src_path.exists():
218
+ raise FileNotFoundError(src_path)
219
+
220
+ target = self._container_path(dest or src_path.name)
221
+ archive = self._tar_path(src_path, PurePosixPath(target).name)
222
+ self._mkdir(str(PurePosixPath(target).parent))
223
+
224
+ try:
225
+ self._backend.put_archive(str(PurePosixPath(target).parent), archive)
226
+ except DockerException as exc:
227
+ raise SandboxError(f"failed to upload {src_path}: {exc}") from exc
228
+
229
+ self._chown(target)
230
+ return target
231
+
232
+ def download(self, src: str, dest: str | Path | None = None) -> bytes | Path:
233
+ self._check_session_timeout()
234
+ self._start()
235
+ source = self._container_path(src)
236
+
237
+ try:
238
+ archive = self._backend.get_archive(source)
239
+ except DockerException as exc:
240
+ raise SandboxError(f"failed to download {source}: {exc}") from exc
241
+
242
+ if dest is None:
243
+ return self._single_file_bytes(archive, source)
244
+
245
+ dest_path = Path(dest)
246
+ self._extract_archive(archive, dest_path)
247
+ return dest_path
248
+
249
+ def _start(self) -> None:
250
+ self._check_session_timeout()
251
+ try:
252
+ self._backend.start()
253
+ if not self._workspace_ready:
254
+ self._mkdir(self.workdir)
255
+ self._chown(self.workdir)
256
+ self._workspace_ready = True
257
+ except DockerException as exc:
258
+ raise SandboxError(f"failed to start sandbox: {exc}") from exc
259
+
260
+ def _mkdir(self, path: str) -> None:
261
+ self._backend.exec(["sh", "-lc", f"mkdir -p {shlex.quote(path)}"], workdir="/", user="root")
262
+
263
+ def _chown(self, path: str) -> None:
264
+ if self.user:
265
+ self._backend.exec(
266
+ ["sh", "-lc", f"chown -R {shlex.quote(self.user)} {shlex.quote(path)} || true"],
267
+ workdir="/",
268
+ user="root",
269
+ )
270
+
271
+ def _put_bytes(self, content: bytes, dest: str) -> None:
272
+ self._start()
273
+ dest_path = PurePosixPath(dest)
274
+ self._mkdir(str(dest_path.parent))
275
+ archive = io.BytesIO()
276
+
277
+ with tarfile.open(fileobj=archive, mode="w") as tar:
278
+ info = tarfile.TarInfo(dest_path.name)
279
+ info.size = len(content)
280
+ info.mode = 0o644
281
+ tar.addfile(info, io.BytesIO(content))
282
+
283
+ try:
284
+ self._backend.put_archive(str(dest_path.parent), archive.getvalue())
285
+ except DockerException as exc:
286
+ raise SandboxError(f"failed to write {dest}: {exc}") from exc
287
+
288
+ self._chown(dest)
289
+
290
+ def _with_timeout(self, command: Command, timeout: int | None) -> Command:
291
+ if isinstance(command, str):
292
+ if timeout and timeout > 0:
293
+ return ["timeout", f"{timeout}s", "sh", "-lc", command]
294
+ return ["sh", "-lc", command]
295
+ if not timeout or timeout <= 0:
296
+ return command
297
+ return ["timeout", f"{timeout}s", *command]
298
+
299
+ def _check_session_timeout(self) -> None:
300
+ if self.session_timeout is None or self._started_at is None:
301
+ return
302
+ if time.monotonic() - self._started_at <= self.session_timeout:
303
+ return
304
+
305
+ self._backend.close()
306
+ raise SandboxError(f"sandbox session timed out after {self.session_timeout}s")
307
+
308
+ def _container_path(self, path: str) -> str:
309
+ value = PurePosixPath(path)
310
+ if ".." in value.parts:
311
+ raise ValueError(f"path traversal is not allowed: {path}")
312
+ root = PurePosixPath(self.workdir)
313
+ if value.is_absolute():
314
+ if value != root and root not in value.parents:
315
+ raise ValueError(f"path must stay inside {self.workdir}: {path}")
316
+ else:
317
+ value = root / value
318
+ return value.as_posix()
319
+
320
+ @staticmethod
321
+ def _workdir_path(path: str) -> str:
322
+ value = PurePosixPath(path)
323
+ if not value.is_absolute() or ".." in value.parts:
324
+ raise ValueError("workdir must be an absolute container path")
325
+ return value.as_posix()
326
+
327
+ @staticmethod
328
+ def _decode(output: object) -> tuple[str, str]:
329
+ if not isinstance(output, tuple):
330
+ return "", ""
331
+
332
+ stdout, stderr = output
333
+ return (
334
+ (stdout or b"").decode("utf-8", errors="replace"),
335
+ (stderr or b"").decode("utf-8", errors="replace"),
336
+ )
337
+
338
+ @staticmethod
339
+ def _tar_path(src: Path, arcname: str) -> bytes:
340
+ archive = io.BytesIO()
341
+ with tarfile.open(fileobj=archive, mode="w") as tar:
342
+ tar.add(src, arcname=arcname)
343
+ return archive.getvalue()
344
+
345
+ @staticmethod
346
+ def _safe_members(tar: tarfile.TarFile) -> list[tarfile.TarInfo]:
347
+ members = []
348
+ for member in tar.getmembers():
349
+ path = PurePosixPath(member.name)
350
+ if path.is_absolute() or ".." in path.parts or member.issym() or member.islnk():
351
+ continue
352
+ members.append(member)
353
+ return members
354
+
355
+ def _single_file_bytes(self, archive: bytes, src: str) -> bytes:
356
+ with tarfile.open(fileobj=io.BytesIO(archive), mode="r") as tar:
357
+ files = [member for member in self._safe_members(tar) if member.isfile()]
358
+ if len(files) != 1:
359
+ raise SandboxError(f"{src} is not a single file")
360
+ file_obj = tar.extractfile(files[0])
361
+ if file_obj is None:
362
+ raise SandboxError(f"failed to read {src}")
363
+ return file_obj.read()
364
+
365
+ def _extract_archive(self, archive: bytes, dest: Path) -> None:
366
+ with tarfile.open(fileobj=io.BytesIO(archive), mode="r") as tar:
367
+ members = self._safe_members(tar)
368
+ files = [member for member in members if member.isfile()]
369
+
370
+ if len(members) == 1 and len(files) == 1 and not dest.is_dir():
371
+ dest.parent.mkdir(parents=True, exist_ok=True)
372
+ file_obj = tar.extractfile(files[0])
373
+ if file_obj is None:
374
+ raise SandboxError(f"failed to extract {files[0].name}")
375
+ dest.write_bytes(file_obj.read())
376
+ return
377
+
378
+ dest.mkdir(parents=True, exist_ok=True)
379
+ root = dest.resolve()
380
+ for member in members:
381
+ target = (root / member.name).resolve()
382
+ if root not in (target, *target.parents):
383
+ continue
384
+ if member.isdir():
385
+ target.mkdir(parents=True, exist_ok=True)
386
+ elif member.isfile():
387
+ target.parent.mkdir(parents=True, exist_ok=True)
388
+ file_obj = tar.extractfile(member)
389
+ if file_obj is not None:
390
+ target.write_bytes(file_obj.read())
391
+
392
+
393
+ __all__ = ["SandboxError", "SandboxResult", "SandboxSession"]
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: containerbox
3
+ Version: 0.1.0
4
+ Summary: Minimal Docker-only sandbox for executing code
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: code-execution,docker,sandbox
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: >=3.13
16
+ Requires-Dist: docker>=7.1.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: build>=1.2.2.post1; extra == 'dev'
19
+ Requires-Dist: twine>=6.2.0; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # ContainerBox
23
+
24
+ Minimal Docker-only sandbox for running generated code.
25
+
26
+ ## Usage
27
+
28
+ Use the context manager when the sandbox belongs to one block of work:
29
+
30
+ ```python
31
+ from containerbox import SandboxSession
32
+
33
+ with SandboxSession() as session:
34
+ result = session.exec("echo hi")
35
+ print(result.stdout)
36
+ ```
37
+
38
+ Use manual lifecycle when you need to pass the same sandbox across functions or modules:
39
+
40
+ ```python
41
+ from containerbox import SandboxSession
42
+
43
+ session = SandboxSession("python:3.13-slim")
44
+ session.open()
45
+
46
+ try:
47
+ result = session.run_code("print('hi')", timeout=5)
48
+ print(result.stdout)
49
+ finally:
50
+ session.close()
51
+ ```
52
+
53
+ When using manual lifecycle, always call `close()`. The context manager does this for you; manual mode makes cleanup your responsibility.
54
+
55
+ ## API
56
+
57
+ ```python
58
+ with SandboxSession(
59
+ image="ubuntu:24.04",
60
+ runtime="docker",
61
+ session_timeout=300,
62
+ memory="256m",
63
+ cpus=1.0,
64
+ network=False,
65
+ ) as session:
66
+ result = session.exec("echo ready", timeout=10)
67
+ ```
68
+
69
+ For Python code, use a Python image:
70
+
71
+ ```python
72
+ with SandboxSession("python:3.13-slim") as session:
73
+ result = session.run_code("print('hi')", timeout=5)
74
+ ```
75
+
76
+ `SandboxResult` contains:
77
+
78
+ - `stdout`
79
+ - `stderr`
80
+ - `exit_code`
81
+ - `timed_out`
82
+ - `duration_ms`
83
+
84
+ ## Files
85
+
86
+ ```python
87
+ with SandboxSession("python:3.13-slim") as session:
88
+ session.upload("local_data.csv")
89
+ result = session.run_code("print(open('local_data.csv').read())")
90
+ session.download("main.py", "downloaded_main.py")
91
+ ```
92
+
93
+ ## Custom Image
94
+
95
+ ```bash
96
+ docker build -f tests/Dockerfile.node -t containerbox-node-extra:test .
97
+ ```
98
+
99
+ ```python
100
+ from containerbox import SandboxSession
101
+
102
+ code = """
103
+ const { slug } = require("slugify-mini");
104
+ console.log(slug("Hello from Custom Node Image!"));
105
+ """
106
+
107
+ with SandboxSession("containerbox-node-extra:test") as session:
108
+ result = session.run_code(code, filename="main.js", command=["node", "main.js"])
109
+ print(result.stdout)
110
+ ```
@@ -0,0 +1,6 @@
1
+ containerbox/__init__.py,sha256=qVNM7p6RKsJfqWTXigHcsD31GaccjoEPhlQWQInysKw,140
2
+ containerbox/session.py,sha256=vhL2PSQU7ELdkmn_Zu9a0tYWFhS2BkbCd1d7OcxEDt0,13185
3
+ containerbox-0.1.0.dist-info/METADATA,sha256=LJycIUs0FAiTp2B6zfy7LrSeMqKyk70Fbzgsqto-SP0,2701
4
+ containerbox-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ containerbox-0.1.0.dist-info/licenses/LICENSE,sha256=yhNNY3nSNkRshU7C02GKHyGFjNntj15AAdhWPkOYNUs,1082
6
+ containerbox-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ContainerBox contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.