pilldrops 1.0.0__tar.gz

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,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: pilldrops
3
+ Version: 1.0.0
4
+ Summary: Python SDK for PillDrops — AI-native sandboxes (secure code execution + app hosting)
5
+ Home-page: https://pilldrops.com
6
+ Classifier: Development Status :: 4 - Beta
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python :: 3
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: requests>=2.28
12
+ Dynamic: classifier
13
+ Dynamic: description
14
+ Dynamic: description-content-type
15
+ Dynamic: home-page
16
+ Dynamic: requires-dist
17
+ Dynamic: requires-python
18
+ Dynamic: summary
19
+
20
+ Create sandboxes, run commands and code, upload directories, launch app servers with preview URLs, and manage lifecycle — from Python. `import pilldrops` is canonical; `import sandforge` ships as a compatibility alias.
@@ -0,0 +1,24 @@
1
+ """PillDrops — Python SDK for AI-native sandboxes (pilldrops.com).
2
+
3
+ from pilldrops import Sandbox
4
+
5
+ with Sandbox.create(template="python-3.11") as sb:
6
+ print(sb.run("python -c 'print(2+2)'").stdout) # 4
7
+ sb.upload("./app", "/app") # push a directory
8
+ r = sb.run("cd /app && python main.py")
9
+
10
+ # Run an app server and get a public preview URL:
11
+ with Sandbox.create(template="node-20", start="npm run dev", ports=[3000]) as app:
12
+ print(app.url) # https://.../preview/<id>/3000/
13
+
14
+ Credentials + endpoint resolve from (in order): explicit args, PILLDROPS_API_URL/
15
+ PILLDROPS_API_KEY env (SANDFORGE_/E2B_/SANDBOX_ accepted as fallbacks), then
16
+ ~/.sandforge/config.json (written by `pilldrops login`).
17
+
18
+ `import sandforge` and `import yoursandbox` keep working — they ship in the same
19
+ distribution as compatibility aliases of this package.
20
+ """
21
+ from sandforge.sandbox import Sandbox, ExecResult, Templates, config, build_image
22
+
23
+ __all__ = ["Sandbox", "ExecResult", "Templates", "config", "build_image"]
24
+ __version__ = "1.0.0"
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: pilldrops
3
+ Version: 1.0.0
4
+ Summary: Python SDK for PillDrops — AI-native sandboxes (secure code execution + app hosting)
5
+ Home-page: https://pilldrops.com
6
+ Classifier: Development Status :: 4 - Beta
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python :: 3
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: requests>=2.28
12
+ Dynamic: classifier
13
+ Dynamic: description
14
+ Dynamic: description-content-type
15
+ Dynamic: home-page
16
+ Dynamic: requires-dist
17
+ Dynamic: requires-python
18
+ Dynamic: summary
19
+
20
+ Create sandboxes, run commands and code, upload directories, launch app servers with preview URLs, and manage lifecycle — from Python. `import pilldrops` is canonical; `import sandforge` ships as a compatibility alias.
@@ -0,0 +1,13 @@
1
+ setup.py
2
+ pilldrops/__init__.py
3
+ pilldrops.egg-info/PKG-INFO
4
+ pilldrops.egg-info/SOURCES.txt
5
+ pilldrops.egg-info/dependency_links.txt
6
+ pilldrops.egg-info/requires.txt
7
+ pilldrops.egg-info/top_level.txt
8
+ sandforge/__init__.py
9
+ sandforge/sandbox.py
10
+ tests/test_integration.py
11
+ tests/test_sandbox.py
12
+ yoursandbox/__init__.py
13
+ yoursandbox/sandbox.py
@@ -0,0 +1 @@
1
+ requests>=2.28
@@ -0,0 +1,3 @@
1
+ pilldrops
2
+ sandforge
3
+ yoursandbox
@@ -0,0 +1,21 @@
1
+ """SandForge — Python SDK for AI-native sandboxes.
2
+
3
+ from sandforge import Sandbox
4
+
5
+ with Sandbox.create(template="python-3.11") as sb:
6
+ print(sb.run("python -c 'print(2+2)'").stdout) # 4
7
+ sb.upload("./app", "/app") # push a directory
8
+ r = sb.run("cd /app && python main.py")
9
+
10
+ # Run an app server and get a public preview URL:
11
+ with Sandbox.create(template="node-20", start="npm run dev", ports=[3000]) as app:
12
+ print(app.url) # https://.../preview/<id>/3000/
13
+
14
+ Credentials + endpoint resolve from (in order): explicit args, SANDFORGE_API_URL/
15
+ SANDFORGE_API_KEY env (E2B_/SANDBOX_ accepted as fallbacks), then ~/.sandforge/config.json
16
+ (written by `sandforge login`).
17
+ """
18
+ from .sandbox import Sandbox, ExecResult, Templates, config, build_image
19
+
20
+ __all__ = ["Sandbox", "ExecResult", "Templates", "config", "build_image"]
21
+ __version__ = "1.0.0"
@@ -0,0 +1,292 @@
1
+ """SandForge sandbox client. See package docstring for usage."""
2
+ import io
3
+ import json
4
+ import os
5
+ import tarfile
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional, Union
10
+
11
+ import requests
12
+
13
+ _CONFIG_PATH = os.environ.get("SANDFORGE_CONFIG") or str(Path.home() / ".sandforge" / "config.json")
14
+
15
+
16
+ def config() -> Dict[str, str]:
17
+ """Resolve (api_url, key) from env then ~/.sandforge/config.json.
18
+
19
+ Env wins over the config file so CI can override a developer login. E2B_/
20
+ SANDBOX_ names are accepted so code written against those keeps working.
21
+ """
22
+ url = (os.environ.get("PILLDROPS_API_URL") or os.environ.get("SANDFORGE_API_URL")
23
+ or os.environ.get("E2B_API_URL") or os.environ.get("SANDBOX_API_URL") or "")
24
+ key = (os.environ.get("PILLDROPS_API_KEY") or os.environ.get("SANDFORGE_API_KEY")
25
+ or os.environ.get("E2B_API_KEY") or os.environ.get("SANDBOX_API_KEY") or "")
26
+ if not (url and key):
27
+ try:
28
+ with open(_CONFIG_PATH) as f:
29
+ c = json.load(f)
30
+ url = url or c.get("api_url", "")
31
+ key = key or c.get("key", "")
32
+ except (OSError, ValueError):
33
+ pass
34
+ return {"api_url": url.rstrip("/"), "key": key}
35
+
36
+
37
+ @dataclass
38
+ class ExecResult:
39
+ stdout: str
40
+ stderr: str
41
+ exit_code: int
42
+ truncated: bool = False
43
+
44
+ @property
45
+ def ok(self) -> bool:
46
+ return self.exit_code == 0
47
+
48
+ def check(self) -> "ExecResult":
49
+ """Raise if the command failed (like subprocess.check_call)."""
50
+ if not self.ok:
51
+ raise RuntimeError(f"command exited {self.exit_code}:\n{self.stderr or self.stdout}")
52
+ return self
53
+
54
+
55
+ class _Filesystem:
56
+ def __init__(self, sb: "Sandbox"):
57
+ self._sb = sb
58
+
59
+ def write(self, path: str, content: Union[str, bytes]) -> None:
60
+ if isinstance(content, str):
61
+ content = content.encode()
62
+ self._sb._req("POST", f"/v1/sandboxes/{self._sb.id}/files", params={"path": path},
63
+ data=content, headers={"Content-Type": "application/octet-stream"})
64
+
65
+ def read(self, path: str) -> str:
66
+ r = self._sb._req("GET", f"/v1/sandboxes/{self._sb.id}/files", params={"path": path})
67
+ return r.text
68
+
69
+ def list(self, path: str = "/") -> List[Dict]:
70
+ r = self._sb._req("GET", f"/v1/sandboxes/{self._sb.id}/filesystem/list", params={"path": path})
71
+ body = r.json()
72
+ return body.get("entries", []) if isinstance(body, dict) else body
73
+
74
+
75
+ class Sandbox:
76
+ """A running sandbox. Prefer `Sandbox.create(...)` and use it as a context
77
+ manager so it is always killed."""
78
+
79
+ def __init__(self, sandbox_id: str, session: requests.Session, base: str, info: Optional[dict] = None):
80
+ self.id = sandbox_id
81
+ self._s = session
82
+ self._base = base
83
+ self._info = info or {}
84
+ self.files = _Filesystem(self)
85
+
86
+ # ── construction ─────────────────────────────────────────────────────────
87
+ @classmethod
88
+ def create(
89
+ cls,
90
+ template: str = "python-3.11",
91
+ *,
92
+ image: Optional[str] = None,
93
+ start: Optional[str] = None,
94
+ ports: Optional[List[int]] = None,
95
+ env: Optional[Union[Dict[str, str], List[str]]] = None,
96
+ vcpu: int = 0,
97
+ mem_mb: int = 0,
98
+ storage_mb: int = 0,
99
+ timeout: int = 0,
100
+ api_url: Optional[str] = None,
101
+ api_key: Optional[str] = None,
102
+ ) -> "Sandbox":
103
+ cfg = config()
104
+ base = (api_url or cfg["api_url"]).rstrip("/")
105
+ key = api_key or cfg["key"]
106
+ if not base:
107
+ raise RuntimeError("no API URL — set SANDFORGE_API_URL or run `sandforge login`")
108
+ s = requests.Session()
109
+ if key:
110
+ s.headers["Authorization"] = f"Bearer {key}"
111
+
112
+ body: Dict[str, object] = {"template": template}
113
+ if image:
114
+ body["image"] = image
115
+ if start:
116
+ body["start"] = start
117
+ if ports:
118
+ body["ports"] = list(ports)
119
+ if env:
120
+ body["env"] = ([f"{k}={v}" for k, v in env.items()] if isinstance(env, dict) else list(env))
121
+ if vcpu:
122
+ body["vcpu"] = vcpu
123
+ if mem_mb:
124
+ body["memory_mb"] = mem_mb
125
+ if storage_mb:
126
+ body["storage_mb"] = storage_mb
127
+ if timeout:
128
+ body["timeout"] = timeout
129
+
130
+ r = s.post(f"{base}/v1/sandboxes", json=body, timeout=180)
131
+ if r.status_code == 401:
132
+ raise PermissionError("invalid API key — run `sandforge login`")
133
+ if not r.ok:
134
+ raise RuntimeError(f"create failed ({r.status_code}): {r.text}")
135
+ data = r.json()
136
+ return cls(data["id"], s, base, info=data)
137
+
138
+ # ── properties ───────────────────────────────────────────────────────────
139
+ @property
140
+ def url(self) -> Optional[str]:
141
+ """Public preview URL for the app port (None until a port is published)."""
142
+ return self._info.get("url") or self._info.get("app_url")
143
+
144
+ @property
145
+ def template(self) -> str:
146
+ return self._info.get("template", "")
147
+
148
+ @property
149
+ def status(self) -> str:
150
+ return self._info.get("status") or self._info.get("state", "")
151
+
152
+ # ── execution ────────────────────────────────────────────────────────────
153
+ def run(self, cmd: str, cwd: str = "", timeout: int = 60) -> ExecResult:
154
+ r = self._req("POST", f"/v1/sandboxes/{self.id}/exec",
155
+ json={"cmd": cmd, "cwd": cwd, "timeout": timeout}, timeout=timeout + 20)
156
+ d = r.json()
157
+ return ExecResult(d.get("stdout", ""), d.get("stderr", ""),
158
+ d.get("exit_code", -1), d.get("truncated", False))
159
+
160
+ def run_code(self, code: str, lang: str = "python") -> ExecResult:
161
+ """Write a snippet to a workspace file and run it — the E2B-style convenience."""
162
+ ext = {"python": "py", "node": "js", "bash": "sh"}.get(lang, "txt")
163
+ runner = {"python": "python3", "node": "node", "bash": "bash"}.get(lang, "cat")
164
+ # Workspace-relative path: the /files API roots at /workspace and exec's
165
+ # cwd is /workspace, so a relative path is visible to both.
166
+ name = f"_sf_{int(time.time()*1000)}.{ext}"
167
+ self.files.write(name, code)
168
+ return self.run(f"{runner} {name}")
169
+
170
+ # ── files (directory-aware; paths are relative to the /workspace root) ────
171
+ def upload(self, local: str, remote: str) -> None:
172
+ """Upload a file or an entire directory. Paths are relative to the
173
+ sandbox workspace (/workspace) — e.g. upload('./app', 'app')."""
174
+ p = Path(local)
175
+ remote = remote.lstrip("/")
176
+ if p.is_dir():
177
+ self._upload_tar(p, remote)
178
+ else:
179
+ self.files.write(remote, p.read_bytes())
180
+
181
+ def _upload_tar(self, local_dir: Path, remote: str) -> None:
182
+ buf = io.BytesIO()
183
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
184
+ tar.add(str(local_dir), arcname=".")
185
+ buf.seek(0)
186
+ # Upload the tar into the workspace, then untar it there via exec (cwd is
187
+ # /workspace, so the relative tar path and target dir both resolve).
188
+ tmp = f"_sf_up_{int(time.time()*1000)}.tgz"
189
+ self.files.write(tmp, buf.read())
190
+ self.run(f"mkdir -p {remote} && tar -xzf {tmp} -C {remote} && rm -f {tmp}").check()
191
+
192
+ def download(self, remote: str, local: str) -> None:
193
+ """Download a file (to a local path) or a directory (unpacked into local)."""
194
+ remote = remote.lstrip("/")
195
+ if self.run(f"test -d {remote} && echo DIR || echo FILE").stdout.startswith("DIR"):
196
+ tmp = f"_sf_dl_{int(time.time()*1000)}.tgz"
197
+ self.run(f"tar -czf {tmp} -C {remote} .").check()
198
+ r = self._req("GET", f"/v1/sandboxes/{self.id}/files?path={tmp}")
199
+ self.run(f"rm -f {tmp}")
200
+ with tarfile.open(fileobj=io.BytesIO(r.content), mode="r:gz") as t:
201
+ members = [m for m in t.getmembers()
202
+ if not (m.name.startswith("..") or os.path.isabs(m.name))]
203
+ Path(local).mkdir(parents=True, exist_ok=True)
204
+ t.extractall(local, members=members)
205
+ else:
206
+ Path(local).write_text(self.files.read(remote))
207
+
208
+ # ── lifecycle ────────────────────────────────────────────────────────────
209
+ def pause(self) -> None:
210
+ self._req("POST", f"/v1/sandboxes/{self.id}/pause")
211
+
212
+ def resume(self) -> None:
213
+ self._req("POST", f"/v1/sandboxes/{self.id}/resume")
214
+
215
+ def set_timeout(self, seconds: int) -> None:
216
+ self._req("POST", f"/v1/sandboxes/{self.id}/timeout", json={"timeout": seconds})
217
+
218
+ def logs(self) -> list:
219
+ return self._req("GET", f"/v1/sandboxes/{self.id}/logs").json()
220
+
221
+ def metrics(self) -> dict:
222
+ return self._req("GET", f"/v1/sandboxes/{self.id}/metrics").json()
223
+
224
+ def kill(self) -> None:
225
+ try:
226
+ self._s.delete(f"{self._base}/v1/sandboxes/{self.id}", timeout=30)
227
+ except requests.RequestException:
228
+ pass
229
+
230
+ # ── plumbing ─────────────────────────────────────────────────────────────
231
+ def _req(self, method: str, path: str, timeout: int = 30, **kw) -> requests.Response:
232
+ r = self._s.request(method, f"{self._base}{path}", timeout=timeout, **kw)
233
+ if r.status_code == 404:
234
+ raise RuntimeError(f"not found: {path}")
235
+ if not r.ok:
236
+ raise RuntimeError(f"{method} {path} → {r.status_code}: {r.text[:200]}")
237
+ return r
238
+
239
+ def __enter__(self) -> "Sandbox":
240
+ return self
241
+
242
+ def __exit__(self, *_):
243
+ self.kill()
244
+
245
+ def __repr__(self) -> str:
246
+ return f"Sandbox(id={self.id!r}, template={self.template!r})"
247
+
248
+
249
+ def build_image(context_dir: str, tag: str, *,
250
+ api_url: Optional[str] = None, api_key: Optional[str] = None) -> str:
251
+ """Build a custom image from a local context (Dockerfile at its root) on the
252
+ fleet and return the launchable tag:
253
+
254
+ img = build_image("./myapp", "myapp")
255
+ with Sandbox.create(image=img) as sb:
256
+ ...
257
+ """
258
+ cfg = config()
259
+ base = (api_url or cfg["api_url"]).rstrip("/")
260
+ key = api_key or cfg["key"]
261
+ if not base:
262
+ raise RuntimeError("no API URL — set SANDFORGE_API_URL or run `sandforge login`")
263
+ buf = io.BytesIO()
264
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
265
+ tar.add(context_dir, arcname=".")
266
+ buf.seek(0)
267
+ s = requests.Session()
268
+ if key:
269
+ s.headers["Authorization"] = f"Bearer {key}"
270
+ r = s.post(f"{base}/v1/images/build", params={"name": tag}, data=buf.read(),
271
+ headers={"Content-Type": "application/octet-stream"}, timeout=1500)
272
+ if not r.ok:
273
+ raise RuntimeError(f"image build request failed ({r.status_code}): {r.text[:300]}")
274
+ d = r.json()
275
+ if not d.get("ok"):
276
+ raise RuntimeError("image build failed:\n" + (d.get("log") or "")[-2000:])
277
+ return d["tag"]
278
+
279
+
280
+ class Templates:
281
+ """List available templates."""
282
+
283
+ def __init__(self, api_url: Optional[str] = None, api_key: Optional[str] = None):
284
+ cfg = config()
285
+ self._base = (api_url or cfg["api_url"]).rstrip("/")
286
+ self._s = requests.Session()
287
+ key = api_key or cfg["key"]
288
+ if key:
289
+ self._s.headers["Authorization"] = f"Bearer {key}"
290
+
291
+ def list(self) -> list:
292
+ return self._s.get(f"{self._base}/v1/templates", timeout=15).json()
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,21 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="pilldrops",
5
+ version="1.0.0",
6
+ description="Python SDK for PillDrops — AI-native sandboxes (secure code execution + app hosting)",
7
+ long_description="Create sandboxes, run commands and code, upload directories, "
8
+ "launch app servers with preview URLs, and manage lifecycle — from Python. "
9
+ "`import pilldrops` is canonical; `import sandforge` ships as a compatibility alias.",
10
+ long_description_content_type="text/markdown",
11
+ url="https://pilldrops.com",
12
+ packages=find_packages(include=["pilldrops", "pilldrops.*", "sandforge", "sandforge.*",
13
+ "yoursandbox", "yoursandbox.*"]),
14
+ python_requires=">=3.8",
15
+ install_requires=["requests>=2.28"],
16
+ classifiers=[
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ ],
21
+ )
@@ -0,0 +1,94 @@
1
+ """Live integration test: boots bin/sandboxd and drives the SDK against it.
2
+
3
+ Skips automatically if the binary or python `requests` is unavailable.
4
+ """
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ import tempfile
11
+ import shutil
12
+ import socket
13
+
14
+ import pytest
15
+
16
+ requests = pytest.importorskip("requests")
17
+ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
18
+ BIN = os.path.join(ROOT, "bin", "sandboxd")
19
+ sys.path.insert(0, os.path.join(ROOT, "sdk", "python"))
20
+
21
+
22
+ def _free_port():
23
+ s = socket.socket()
24
+ s.bind(("127.0.0.1", 0))
25
+ p = s.getsockname()[1]
26
+ s.close()
27
+ return p
28
+
29
+
30
+ @pytest.fixture(scope="module")
31
+ def server():
32
+ if not os.path.exists(BIN):
33
+ pytest.skip("bin/sandboxd not built; run: go build -o bin/sandboxd ./cmd/sandboxd/")
34
+ port = _free_port()
35
+ data = tempfile.mkdtemp(prefix="sbd-it-")
36
+ proc = subprocess.Popen(
37
+ [BIN],
38
+ env={**os.environ, "PORT": str(port), "SANDBOXD_DATA": data},
39
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
40
+ )
41
+ key = None
42
+ deadline = time.time() + 15
43
+ while time.time() < deadline:
44
+ line = proc.stdout.readline()
45
+ if not line:
46
+ continue
47
+ m = re.search(r"(sf_live_[a-f0-9]+)", line)
48
+ if m:
49
+ key = m.group(1)
50
+ break
51
+ assert key, "did not capture first-run API key from server output"
52
+ base = f"http://127.0.0.1:{port}"
53
+ for _ in range(50):
54
+ try:
55
+ if requests.get(base + "/health", timeout=1).ok:
56
+ break
57
+ except requests.RequestException:
58
+ time.sleep(0.1)
59
+ yield base, key
60
+ proc.terminate()
61
+ try:
62
+ proc.wait(timeout=5)
63
+ except subprocess.TimeoutExpired:
64
+ proc.kill()
65
+ shutil.rmtree(data, ignore_errors=True)
66
+
67
+
68
+ def test_sdk_end_to_end(server):
69
+ base, key = server
70
+ os.environ["E2B_API_URL"] = base
71
+ os.environ["E2B_API_KEY"] = key
72
+ import importlib
73
+ import yoursandbox.sandbox as sbmod
74
+ importlib.reload(sbmod)
75
+ Sandbox = sbmod.Sandbox
76
+
77
+ sb = Sandbox.create(template="python-3.11")
78
+ try:
79
+ assert sb.run("echo sdk-ok").stdout.strip() == "sdk-ok"
80
+
81
+ sb.filesystem.write("a.txt", "hello")
82
+ assert sb.filesystem.read("a.txt") == "hello"
83
+
84
+ names = [e["name"] for e in sb.filesystem.list(".")]
85
+ assert "a.txt" in names
86
+
87
+ sb.filesystem.move("a.txt", "b.txt")
88
+ assert sb.filesystem.stat("b.txt")["name"] == "b.txt"
89
+
90
+ sb.filesystem.remove("b.txt")
91
+
92
+ assert "vcpu_seconds" in sb.metrics()
93
+ finally:
94
+ sb.kill()
@@ -0,0 +1,92 @@
1
+ """Unit tests for the sandforge SDK against a tiny in-process mock API."""
2
+ import json
3
+ import threading
4
+ from http.server import BaseHTTPRequestHandler, HTTPServer
5
+
6
+ import pytest
7
+
8
+ import sys
9
+ from pathlib import Path
10
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
11
+
12
+ from sandforge import Sandbox # noqa: E402
13
+
14
+
15
+ class _Handler(BaseHTTPRequestHandler):
16
+ created_body = None
17
+ exec_bodies = []
18
+
19
+ def log_message(self, *_):
20
+ pass
21
+
22
+ def _json(self, code, obj):
23
+ self.send_response(code)
24
+ self.send_header("Content-Type", "application/json")
25
+ self.end_headers()
26
+ self.wfile.write(json.dumps(obj).encode())
27
+
28
+ def do_POST(self):
29
+ n = int(self.headers.get("Content-Length", 0))
30
+ raw = self.rfile.read(n)
31
+ if self.path == "/v1/sandboxes":
32
+ _Handler.created_body = json.loads(raw)
33
+ self._json(201, {"id": "sb_test", "template": "node-20",
34
+ "status": "running", "url": "https://x/preview/sb_test/3000/"})
35
+ elif self.path.endswith("/exec"):
36
+ _Handler.exec_bodies.append(json.loads(raw))
37
+ self._json(200, {"stdout": "hello\n", "stderr": "", "exit_code": 0})
38
+ elif self.path.endswith("/files"):
39
+ self._json(200, {})
40
+ else:
41
+ self._json(200, {"ok": True})
42
+
43
+ def do_DELETE(self):
44
+ self._json(200, {"status": "destroyed"})
45
+
46
+
47
+ @pytest.fixture
48
+ def server(monkeypatch):
49
+ _Handler.created_body = None
50
+ _Handler.exec_bodies = []
51
+ httpd = HTTPServer(("127.0.0.1", 0), _Handler)
52
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
53
+ base = f"http://127.0.0.1:{httpd.server_address[1]}"
54
+ monkeypatch.setenv("SANDFORGE_API_URL", base)
55
+ monkeypatch.setenv("SANDFORGE_API_KEY", "sfc_test")
56
+ yield base
57
+ httpd.shutdown()
58
+
59
+
60
+ def test_create_forwards_all_flags(server):
61
+ with Sandbox.create(template="node-20", image="ghcr.io/me/app:1",
62
+ start="npm run dev", ports=[3000, 8080],
63
+ env={"NODE_ENV": "production"}, vcpu=2, mem_mb=1024,
64
+ storage_mb=4096, timeout=600) as sb:
65
+ b = _Handler.created_body
66
+ assert b["image"] == "ghcr.io/me/app:1"
67
+ assert b["start"] == "npm run dev"
68
+ assert b["ports"] == [3000, 8080]
69
+ assert b["env"] == ["NODE_ENV=production"]
70
+ assert b["vcpu"] == 2 and b["memory_mb"] == 1024 and b["storage_mb"] == 4096
71
+ assert b["timeout"] == 600
72
+ # Preview URL comes straight from the create response.
73
+ assert sb.url == "https://x/preview/sb_test/3000/"
74
+
75
+
76
+ def test_run_and_run_code(server):
77
+ with Sandbox.create() as sb:
78
+ r = sb.run("echo hello")
79
+ assert r.ok and r.stdout == "hello\n"
80
+ sb.run_code("print('hi')", lang="python")
81
+ # run_code writes a workspace file then execs `python3 <file>`.
82
+ assert any("python3 _sf_" in e["cmd"] for e in _Handler.exec_bodies)
83
+
84
+
85
+ def test_upload_directory_tars(server, tmp_path):
86
+ (tmp_path / "a.txt").write_text("A")
87
+ (tmp_path / "sub").mkdir()
88
+ (tmp_path / "sub" / "b.txt").write_text("B")
89
+ with Sandbox.create() as sb:
90
+ sb.upload(str(tmp_path), "app")
91
+ # A directory upload untars server-side into the workspace.
92
+ assert any("tar -xzf" in e["cmd"] and "app" in e["cmd"] for e in _Handler.exec_bodies)
@@ -0,0 +1,4 @@
1
+ from .sandbox import Sandbox, ExecResult, Templates, APIKeys
2
+
3
+ __all__ = ["Sandbox", "ExecResult", "Templates", "APIKeys"]
4
+ __version__ = "0.3.0"
@@ -0,0 +1,277 @@
1
+ """
2
+ E2B Sandbox SDK — Full Python client for the Sandbox Platform.
3
+
4
+ Usage:
5
+ from yoursandbox import Sandbox
6
+
7
+ with Sandbox.create() as sb:
8
+ sb.run("pip install requests -q")
9
+ result = sb.run("python -c 'import requests; print(requests.__version__)'")
10
+ print(result.stdout)
11
+
12
+ sb.filesystem.write("hello.txt", "Hello World!")
13
+ files = sb.filesystem.list("/")
14
+ print(files)
15
+ """
16
+ import logging
17
+ import os
18
+ import requests
19
+ from dataclasses import dataclass
20
+ from typing import Optional, List, Dict
21
+
22
+ logger = logging.getLogger("yoursandbox")
23
+
24
+ BASE = os.environ.get("E2B_API_URL", os.environ.get("SANDBOX_API_URL", "http://localhost:8080"))
25
+
26
+
27
+ @dataclass
28
+ class ExecResult:
29
+ stdout: str
30
+ stderr: str
31
+ exit_code: int
32
+ truncated: bool = False
33
+
34
+ @property
35
+ def success(self) -> bool:
36
+ return self.exit_code == 0
37
+
38
+ def raise_on_error(self) -> "ExecResult":
39
+ if not self.success:
40
+ raise RuntimeError(f"Command failed (exit {self.exit_code}):\n{self.stderr}")
41
+ return self
42
+
43
+
44
+ class _Filesystem:
45
+ """Filesystem operations for a sandbox."""
46
+
47
+ def __init__(self, sandbox_id: str, session: requests.Session, base: str):
48
+ self._id = sandbox_id
49
+ self._s = session
50
+ self._base = base
51
+
52
+ def write(self, path: str, content: "str | bytes") -> None:
53
+ if isinstance(content, str): content = content.encode()
54
+ self._s.post(f"{self._base}/v1/sandboxes/{self._id}/files",
55
+ params={"path": path}, data=content,
56
+ headers={"Content-Type": "application/octet-stream"}, timeout=30).raise_for_status()
57
+
58
+ def read(self, path: str) -> str:
59
+ r = self._s.get(f"{self._base}/v1/sandboxes/{self._id}/files",
60
+ params={"path": path}, timeout=30)
61
+ if r.status_code == 404: raise FileNotFoundError(f"No such file: {path}")
62
+ r.raise_for_status()
63
+ return r.text
64
+
65
+ def list(self, path: str = "/") -> List[Dict]:
66
+ r = self._s.get(f"{self._base}/v1/sandboxes/{self._id}/filesystem/list",
67
+ params={"path": path}, timeout=10)
68
+ r.raise_for_status()
69
+ body = r.json()
70
+ # Server wraps entries as {"entries":[…]}; return the bare list so callers
71
+ # can iterate DirEntry dicts as the type hint promises.
72
+ return body.get("entries", body) if isinstance(body, dict) else body
73
+
74
+ def mkdir(self, path: str) -> None:
75
+ self._s.post(f"{self._base}/v1/sandboxes/{self._id}/filesystem/mkdir",
76
+ json={"path": path}, timeout=10).raise_for_status()
77
+
78
+ def move(self, source: str, destination: str) -> None:
79
+ self._s.post(f"{self._base}/v1/sandboxes/{self._id}/filesystem/move",
80
+ json={"src": source, "dst": destination}, timeout=10).raise_for_status()
81
+
82
+ def remove(self, path: str) -> None:
83
+ self._s.post(f"{self._base}/v1/sandboxes/{self._id}/filesystem/remove",
84
+ json={"path": path}, timeout=10)
85
+
86
+ def stat(self, path: str) -> Dict:
87
+ r = self._s.get(f"{self._base}/v1/sandboxes/{self._id}/filesystem/stat",
88
+ params={"path": path}, timeout=10)
89
+ if r.status_code == 404: raise FileNotFoundError(f"No such file: {path}")
90
+ r.raise_for_status()
91
+ return r.json()
92
+
93
+
94
+ class _Process:
95
+ """Process management for a sandbox."""
96
+
97
+ def __init__(self, sandbox_id: str, session: requests.Session, base: str):
98
+ self._id = sandbox_id
99
+ self._s = session
100
+ self._base = base
101
+
102
+ def list(self) -> List[Dict]:
103
+ r = self._s.get(f"{self._base}/v1/sandboxes/{self._id}/process/list", timeout=10)
104
+ r.raise_for_status()
105
+ return r.json()
106
+
107
+ def signal(self, pid: int, signal: str = "SIGTERM") -> None:
108
+ self._s.post(f"{self._base}/v1/sandboxes/{self._id}/process/signal",
109
+ json={"pid": pid, "signal": signal}, timeout=10).raise_for_status()
110
+
111
+ def kill(self, pid: int) -> None:
112
+ self.signal(pid, "SIGKILL")
113
+
114
+
115
+ class Sandbox:
116
+ """Secure isolated sandbox for code execution."""
117
+
118
+ _BASE = BASE
119
+
120
+ def __init__(self, sandbox_id: str, session: requests.Session, info: dict = None):
121
+ self.id = sandbox_id
122
+ self._session = session
123
+ self._info = info or {}
124
+ self.filesystem = _Filesystem(sandbox_id, session, self._BASE)
125
+ self.process = _Process(sandbox_id, session, self._BASE)
126
+
127
+ @property
128
+ def url(self) -> str:
129
+ return self._info.get("url", f"{self._BASE}/sandbox/{self.id}/")
130
+
131
+ @property
132
+ def template(self) -> str:
133
+ return self._info.get("template", "")
134
+
135
+ @property
136
+ def boot_ms(self) -> int:
137
+ return self._info.get("boot_ms", 0)
138
+
139
+ @property
140
+ def app_url(self) -> Optional[str]:
141
+ return self._info.get("app_url")
142
+
143
+ @classmethod
144
+ def create(cls, template: str = "python-3.11", timeout: int = 300,
145
+ api_key: Optional[str] = None,
146
+ files: Optional[List[Dict]] = None,
147
+ setup: Optional[List[str]] = None,
148
+ start: Optional[str] = None,
149
+ port: Optional[int] = None) -> "Sandbox":
150
+ key = api_key or os.environ.get("E2B_API_KEY") or os.environ.get("SANDBOX_API_KEY")
151
+ session = requests.Session()
152
+ if key: session.headers["Authorization"] = f"Bearer {key}"
153
+
154
+ body = {"template": template, "timeout": timeout}
155
+ if files: body["files"] = files
156
+ if setup: body["setup"] = setup
157
+ if start: body["start"] = start
158
+ if port: body["port"] = port
159
+
160
+ resp = session.post(f"{cls._BASE}/v1/sandboxes", json=body, timeout=120)
161
+ if resp.status_code == 401: raise PermissionError("Invalid API key")
162
+ if resp.status_code == 429: raise RuntimeError("Server busy")
163
+ if not resp.ok: raise RuntimeError(f"Create failed ({resp.status_code}): {resp.text}")
164
+
165
+ data = resp.json()
166
+ sb = cls(sandbox_id=data["id"], session=session, info=data)
167
+
168
+ try:
169
+ info_resp = session.get(f"{cls._BASE}/v1/sandboxes/{data['id']}", timeout=10)
170
+ if info_resp.ok: sb._info.update(info_resp.json())
171
+ except requests.RequestException as e:
172
+ logger.debug("Failed to fetch sandbox info after create: %s", e)
173
+
174
+ return sb
175
+
176
+ def run(self, cmd: str, cwd: str = "", timeout: int = 30) -> ExecResult:
177
+ resp = self._session.post(f"{self._BASE}/v1/sandboxes/{self.id}/exec",
178
+ json={"cmd": cmd, "cwd": cwd, "timeout": timeout}, timeout=timeout + 15)
179
+ if resp.status_code == 404: raise RuntimeError(f"Sandbox {self.id} not found")
180
+ if resp.status_code == 403: raise PermissionError(f"Forbidden: sandbox {self.id}")
181
+ if not resp.ok: raise RuntimeError(f"Exec failed: {resp.text}")
182
+ d = resp.json()
183
+ return ExecResult(stdout=d["stdout"], stderr=d["stderr"],
184
+ exit_code=d["exit_code"], truncated=d.get("truncated", False))
185
+
186
+ def info(self) -> dict:
187
+ resp = self._session.get(f"{self._BASE}/v1/sandboxes/{self.id}", timeout=10)
188
+ resp.raise_for_status()
189
+ self._info = resp.json()
190
+ return self._info
191
+
192
+ def pause(self) -> dict:
193
+ r = self._session.post(f"{self._BASE}/v1/sandboxes/{self.id}/pause", timeout=10)
194
+ r.raise_for_status(); return r.json()
195
+
196
+ def resume(self) -> dict:
197
+ r = self._session.post(f"{self._BASE}/v1/sandboxes/{self.id}/resume", timeout=10)
198
+ r.raise_for_status(); return r.json()
199
+
200
+ def set_timeout(self, seconds: int) -> dict:
201
+ r = self._session.post(f"{self._BASE}/v1/sandboxes/{self.id}/timeout",
202
+ json={"timeout": seconds}, timeout=10)
203
+ r.raise_for_status(); return r.json()
204
+
205
+ def refresh(self) -> dict:
206
+ r = self._session.post(f"{self._BASE}/v1/sandboxes/{self.id}/refresh", timeout=10)
207
+ r.raise_for_status(); return r.json()
208
+
209
+ def logs(self) -> list:
210
+ r = self._session.get(f"{self._BASE}/v1/sandboxes/{self.id}/logs", timeout=10)
211
+ r.raise_for_status(); return r.json()
212
+
213
+ def metrics(self) -> dict:
214
+ r = self._session.get(f"{self._BASE}/v1/sandboxes/{self.id}/metrics", timeout=10)
215
+ r.raise_for_status(); return r.json()
216
+
217
+ def history(self) -> list:
218
+ r = self._session.get(f"{self._BASE}/v1/sandboxes/{self.id}/history", timeout=10)
219
+ r.raise_for_status(); return r.json()
220
+
221
+ def kill(self) -> None:
222
+ try:
223
+ self._session.delete(f"{self._BASE}/v1/sandboxes/{self.id}", timeout=10)
224
+ except requests.RequestException as e:
225
+ logger.debug("Failed to kill sandbox %s: %s", self.id, e)
226
+
227
+ def __enter__(self): return self
228
+ def __exit__(self, *_): self.kill()
229
+ def __repr__(self): return f"Sandbox(id={self.id!r}, template={self.template!r})"
230
+
231
+
232
+ class Templates:
233
+ """List sandbox templates. Template CRUD is not yet exposed by the server."""
234
+
235
+ def __init__(self, base_url: str = None, api_key: str = None):
236
+ self._base = base_url or BASE
237
+ self._s = requests.Session()
238
+ key = api_key or os.environ.get("E2B_API_KEY", "")
239
+ if key: self._s.headers["Authorization"] = f"Bearer {key}"
240
+
241
+ def list(self) -> list:
242
+ return self._s.get(f"{self._base}/v1/templates", timeout=10).json()
243
+
244
+ def get(self, template_id: str) -> dict:
245
+ raise NotImplementedError("server exposes only GET /v1/templates (list)")
246
+
247
+ def create(self, *_, **__) -> dict:
248
+ raise NotImplementedError("template creation is not supported by sandboxd yet")
249
+
250
+ def update(self, *_, **__) -> dict:
251
+ raise NotImplementedError("template update is not supported by sandboxd yet")
252
+
253
+ def build(self, *_, **__) -> dict:
254
+ raise NotImplementedError("template build is not supported by sandboxd yet")
255
+
256
+ def delete(self, *_, **__) -> None:
257
+ raise NotImplementedError("template delete is not supported by sandboxd yet")
258
+
259
+
260
+ class APIKeys:
261
+ """Manage API keys."""
262
+
263
+ def __init__(self, base_url: str = None, api_key: str = None):
264
+ self._base = base_url or BASE
265
+ self._s = requests.Session()
266
+ key = api_key or os.environ.get("E2B_API_KEY", "")
267
+ if key: self._s.headers["Authorization"] = f"Bearer {key}"
268
+
269
+ def list(self) -> list:
270
+ return self._s.get(f"{self._base}/v1/keys", timeout=10).json()
271
+
272
+ def create(self, name: str = "default") -> dict:
273
+ r = self._s.post(f"{self._base}/v1/keys", json={"name": name}, timeout=10)
274
+ r.raise_for_status(); return r.json()
275
+
276
+ def revoke(self, key_id: str) -> None:
277
+ self._s.delete(f"{self._base}/v1/keys/{key_id}", timeout=10)