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.
- pilldrops-1.0.0/PKG-INFO +20 -0
- pilldrops-1.0.0/pilldrops/__init__.py +24 -0
- pilldrops-1.0.0/pilldrops.egg-info/PKG-INFO +20 -0
- pilldrops-1.0.0/pilldrops.egg-info/SOURCES.txt +13 -0
- pilldrops-1.0.0/pilldrops.egg-info/dependency_links.txt +1 -0
- pilldrops-1.0.0/pilldrops.egg-info/requires.txt +1 -0
- pilldrops-1.0.0/pilldrops.egg-info/top_level.txt +3 -0
- pilldrops-1.0.0/sandforge/__init__.py +21 -0
- pilldrops-1.0.0/sandforge/sandbox.py +292 -0
- pilldrops-1.0.0/setup.cfg +4 -0
- pilldrops-1.0.0/setup.py +21 -0
- pilldrops-1.0.0/tests/test_integration.py +94 -0
- pilldrops-1.0.0/tests/test_sandbox.py +92 -0
- pilldrops-1.0.0/yoursandbox/__init__.py +4 -0
- pilldrops-1.0.0/yoursandbox/sandbox.py +277 -0
pilldrops-1.0.0/PKG-INFO
ADDED
|
@@ -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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.28
|
|
@@ -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()
|
pilldrops-1.0.0/setup.py
ADDED
|
@@ -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,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)
|