opencomputer-sdk 0.4.6__tar.gz → 0.4.7__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.
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/PKG-INFO +1 -1
- opencomputer_sdk-0.4.7/examples/test_declarative_images.py +337 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/opencomputer/__init__.py +4 -2
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/opencomputer/agent.py +6 -0
- opencomputer_sdk-0.4.7/opencomputer/image.py +168 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/opencomputer/pty.py +4 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/opencomputer/sandbox.py +51 -9
- opencomputer_sdk-0.4.7/opencomputer/snapshot.py +105 -0
- opencomputer_sdk-0.4.7/opencomputer/sse.py +73 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/pyproject.toml +1 -1
- opencomputer_sdk-0.4.6/opencomputer/template.py +0 -75
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/.gitignore +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/README.md +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/examples/run_all_tests.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/examples/test_commands.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/examples/test_concurrent.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/examples/test_default_template.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/examples/test_domain_tls.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/examples/test_environment.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/examples/test_exec.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/examples/test_file_ops.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/examples/test_multi_template.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/examples/test_python_sdk.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/examples/test_reconnect.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/examples/test_timeout.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/opencomputer/commands.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/opencomputer/exec.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.7}/opencomputer/filesystem.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencomputer-sdk
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.7
|
|
4
4
|
Summary: Python SDK for OpenComputer - cloud sandbox platform
|
|
5
5
|
Project-URL: Homepage, https://github.com/diggerhq/opensandbox
|
|
6
6
|
Project-URL: Repository, https://github.com/diggerhq/opensandbox
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Declarative Image Builder & Snapshots Test (Python SDK)
|
|
4
|
+
|
|
5
|
+
Tests both patterns for defining sandbox environments:
|
|
6
|
+
|
|
7
|
+
Pattern 1 — On-demand images (cached by content hash):
|
|
8
|
+
image = Image.base().apt_install(["curl"])
|
|
9
|
+
sandbox = await Sandbox.create(image=image)
|
|
10
|
+
|
|
11
|
+
Pattern 2 — Pre-built named snapshots:
|
|
12
|
+
await snapshots.create(name="my-env", image=image)
|
|
13
|
+
sandbox = await Sandbox.create(snapshot="my-env")
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
python examples/test_declarative_images.py
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from opencomputer import Sandbox, Image, Snapshots
|
|
24
|
+
|
|
25
|
+
passed = 0
|
|
26
|
+
failed = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def green(msg: str) -> None:
|
|
30
|
+
print(f"\033[32m\u2713 {msg}\033[0m")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def red(msg: str) -> None:
|
|
34
|
+
print(f"\033[31m\u2717 {msg}\033[0m")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def bold(msg: str) -> None:
|
|
38
|
+
print(f"\033[1m{msg}\033[0m")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def dim(msg: str) -> None:
|
|
42
|
+
print(f"\033[2m {msg}\033[0m")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check(desc: str, condition: bool, detail: str = "") -> None:
|
|
46
|
+
global passed, failed
|
|
47
|
+
if condition:
|
|
48
|
+
green(desc)
|
|
49
|
+
passed += 1
|
|
50
|
+
else:
|
|
51
|
+
red(f"{desc} ({detail})" if detail else desc)
|
|
52
|
+
failed += 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def main() -> None:
|
|
56
|
+
global passed, failed
|
|
57
|
+
|
|
58
|
+
bold("\n╔══════════════════════════════════════════════════════╗")
|
|
59
|
+
bold("║ Declarative Image Builder & Snapshots Test (Python) ║")
|
|
60
|
+
bold("╚══════════════════════════════════════════════════════════╝\n")
|
|
61
|
+
|
|
62
|
+
sandboxes: list[Sandbox] = []
|
|
63
|
+
snapshots = Snapshots()
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
# ── Test: Image builder basics ──────────────────────────────
|
|
67
|
+
bold("━━━ Test: Image builder basics ━━━\n")
|
|
68
|
+
|
|
69
|
+
base = Image.base()
|
|
70
|
+
with_curl = base.apt_install(["curl"])
|
|
71
|
+
with_python = with_curl.pip_install(["requests"])
|
|
72
|
+
|
|
73
|
+
check("Image.base() uses default base", base.to_dict()["base"] == "base")
|
|
74
|
+
check("Image is immutable — apt_install returns new instance", len(base.to_dict()["steps"]) == 0)
|
|
75
|
+
check("Chained image has 1 step", len(with_curl.to_dict()["steps"]) == 1)
|
|
76
|
+
check("Further chained image has 2 steps", len(with_python.to_dict()["steps"]) == 2)
|
|
77
|
+
|
|
78
|
+
check("Different images have different cache keys", base.cache_key() != with_curl.cache_key())
|
|
79
|
+
check("Same image produces same cache key", base.cache_key() == Image.base().cache_key())
|
|
80
|
+
|
|
81
|
+
# File operations
|
|
82
|
+
with_file = base.add_file("/workspace/config.json", '{"key": "value"}')
|
|
83
|
+
check("add_file creates a step", len(with_file.to_dict()["steps"]) == 1)
|
|
84
|
+
check("add_file step type is correct", with_file.to_dict()["steps"][0]["type"] == "add_file")
|
|
85
|
+
print()
|
|
86
|
+
|
|
87
|
+
# ── Pattern 1: On-demand image creation ────────────────────
|
|
88
|
+
bold("━━━ Pattern 1: On-demand image (first build — cold) ━━━\n")
|
|
89
|
+
|
|
90
|
+
image = (
|
|
91
|
+
Image.base()
|
|
92
|
+
.apt_install(["curl", "jq"])
|
|
93
|
+
.run_commands("mkdir -p /workspace/project")
|
|
94
|
+
.env({"MY_VAR": "hello-from-image", "PROJECT_ROOT": "/workspace/project"})
|
|
95
|
+
.workdir("/workspace/project")
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
dim(f"Cache key: {image.cache_key()}")
|
|
99
|
+
dim("Creating sandbox from image (this will build on first run)...")
|
|
100
|
+
|
|
101
|
+
t1_start = time.time()
|
|
102
|
+
sandbox1 = await Sandbox.create(image=image, timeout=300)
|
|
103
|
+
t1_elapsed = int((time.time() - t1_start) * 1000)
|
|
104
|
+
sandboxes.append(sandbox1)
|
|
105
|
+
|
|
106
|
+
green(f"Sandbox created: {sandbox1.sandbox_id} ({t1_elapsed}ms)")
|
|
107
|
+
check("Sandbox created successfully", sandbox1.status in ("running", "creating"))
|
|
108
|
+
print()
|
|
109
|
+
|
|
110
|
+
# ── Verify installed packages ──────────────────────────────
|
|
111
|
+
bold("━━━ Verify: Packages installed ━━━\n")
|
|
112
|
+
|
|
113
|
+
curl_check = await sandbox1.commands.run("which curl")
|
|
114
|
+
check("curl is installed", curl_check.exit_code == 0, f"exit={curl_check.exit_code}")
|
|
115
|
+
|
|
116
|
+
jq_check = await sandbox1.commands.run("which jq")
|
|
117
|
+
check("jq is installed", jq_check.exit_code == 0, f"exit={jq_check.exit_code}")
|
|
118
|
+
print()
|
|
119
|
+
|
|
120
|
+
# ── Verify env vars ────────────────────────────────────────
|
|
121
|
+
bold("━━━ Verify: Environment variables ━━━\n")
|
|
122
|
+
|
|
123
|
+
env_check = await sandbox1.commands.run("bash -lc 'echo $MY_VAR'")
|
|
124
|
+
check("MY_VAR is set", env_check.stdout.strip() == "hello-from-image", f'got: "{env_check.stdout.strip()}"')
|
|
125
|
+
|
|
126
|
+
root_check = await sandbox1.commands.run("bash -lc 'echo $PROJECT_ROOT'")
|
|
127
|
+
check("PROJECT_ROOT is set", root_check.stdout.strip() == "/workspace/project", f'got: "{root_check.stdout.strip()}"')
|
|
128
|
+
print()
|
|
129
|
+
|
|
130
|
+
# ── Cache hit: second sandbox from same image ──────────────
|
|
131
|
+
bold("━━━ Pattern 1: Cache hit (second sandbox, same image) ━━━\n")
|
|
132
|
+
|
|
133
|
+
dim("Creating second sandbox from same image (should be cached)...")
|
|
134
|
+
t2_start = time.time()
|
|
135
|
+
sandbox2 = await Sandbox.create(image=image, timeout=300)
|
|
136
|
+
t2_elapsed = int((time.time() - t2_start) * 1000)
|
|
137
|
+
sandboxes.append(sandbox2)
|
|
138
|
+
|
|
139
|
+
green(f"Second sandbox created: {sandbox2.sandbox_id} ({t2_elapsed}ms)")
|
|
140
|
+
|
|
141
|
+
if t1_elapsed > 5000:
|
|
142
|
+
check(
|
|
143
|
+
f"Cache hit is faster ({t2_elapsed}ms vs {t1_elapsed}ms cold build)",
|
|
144
|
+
t2_elapsed < t1_elapsed,
|
|
145
|
+
f"cold={t1_elapsed}ms, cached={t2_elapsed}ms",
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
dim(f"Cold build was fast ({t1_elapsed}ms) — skipping speedup check")
|
|
149
|
+
|
|
150
|
+
curl_check2 = await sandbox2.commands.run("which curl")
|
|
151
|
+
check("Cached sandbox also has curl", curl_check2.exit_code == 0)
|
|
152
|
+
print()
|
|
153
|
+
|
|
154
|
+
# Kill sandboxes before creating more
|
|
155
|
+
for sb in sandboxes:
|
|
156
|
+
try:
|
|
157
|
+
await sb.kill()
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
sandboxes.clear()
|
|
161
|
+
|
|
162
|
+
# ── Pattern 2: Pre-built named snapshot ────────────────────
|
|
163
|
+
bold("━━━ Pattern 2: Create named snapshot ━━━\n")
|
|
164
|
+
|
|
165
|
+
snapshot_image = (
|
|
166
|
+
Image.base()
|
|
167
|
+
.run_commands(
|
|
168
|
+
"echo 'snapshot-marker' > /workspace/snapshot-test.txt",
|
|
169
|
+
"mkdir -p /workspace/data",
|
|
170
|
+
)
|
|
171
|
+
.env({"SNAPSHOT_ENV": "from-snapshot"})
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
dim("Creating snapshot 'test-env-py'...")
|
|
175
|
+
snapshot_info = await snapshots.create(name="test-env-py", image=snapshot_image)
|
|
176
|
+
green(f"Snapshot created: {snapshot_info['name']} (status={snapshot_info['status']})")
|
|
177
|
+
check("Snapshot status is ready", snapshot_info["status"] == "ready")
|
|
178
|
+
print()
|
|
179
|
+
|
|
180
|
+
# ── List snapshots ─────────────────────────────────────────
|
|
181
|
+
bold("━━━ Verify: List snapshots ━━━\n")
|
|
182
|
+
|
|
183
|
+
snapshot_list = await snapshots.list()
|
|
184
|
+
found = next((s for s in snapshot_list if s["name"] == "test-env-py"), None)
|
|
185
|
+
check("Snapshot appears in list", found is not None)
|
|
186
|
+
print()
|
|
187
|
+
|
|
188
|
+
# ── Get snapshot by name ───────────────────────────────────
|
|
189
|
+
bold("━━━ Verify: Get snapshot by name ━━━\n")
|
|
190
|
+
|
|
191
|
+
fetched = await snapshots.get("test-env-py")
|
|
192
|
+
check("Fetched snapshot matches", fetched["name"] == "test-env-py")
|
|
193
|
+
check("Fetched snapshot is ready", fetched["status"] == "ready")
|
|
194
|
+
print()
|
|
195
|
+
|
|
196
|
+
# ── Create sandbox from named snapshot ─────────────────────
|
|
197
|
+
bold("━━━ Pattern 2: Create sandbox from named snapshot ━━━\n")
|
|
198
|
+
|
|
199
|
+
dim("Creating sandbox from snapshot 'test-env-py'...")
|
|
200
|
+
sandbox3 = await Sandbox.create(snapshot="test-env-py", timeout=300)
|
|
201
|
+
sandboxes.append(sandbox3)
|
|
202
|
+
|
|
203
|
+
check("Sandbox created successfully", sandbox3.status in ("running", "creating"))
|
|
204
|
+
|
|
205
|
+
marker_check = await sandbox3.commands.run("cat /workspace/snapshot-test.txt")
|
|
206
|
+
check("Snapshot marker file exists", marker_check.stdout.strip() == "snapshot-marker", f'got: "{marker_check.stdout.strip()}"')
|
|
207
|
+
|
|
208
|
+
snapshot_env_check = await sandbox3.commands.run("bash -lc 'echo $SNAPSHOT_ENV'")
|
|
209
|
+
check("Snapshot env var is set", snapshot_env_check.stdout.strip() == "from-snapshot", f'got: "{snapshot_env_check.stdout.strip()}"')
|
|
210
|
+
print()
|
|
211
|
+
|
|
212
|
+
# ── Delete snapshot ────────────────────────────────────────
|
|
213
|
+
bold("━━━ Cleanup: Delete snapshot ━━━\n")
|
|
214
|
+
|
|
215
|
+
await snapshots.delete("test-env-py")
|
|
216
|
+
green("Snapshot 'test-env-py' deleted")
|
|
217
|
+
|
|
218
|
+
list_after = await snapshots.list()
|
|
219
|
+
deleted_found = next((s for s in list_after if s["name"] == "test-env-py"), None)
|
|
220
|
+
check("Snapshot no longer in list", deleted_found is None)
|
|
221
|
+
print()
|
|
222
|
+
|
|
223
|
+
# Kill sandbox before next test
|
|
224
|
+
for sb in sandboxes:
|
|
225
|
+
try:
|
|
226
|
+
await sb.kill()
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
sandboxes.clear()
|
|
230
|
+
|
|
231
|
+
# ── Test: addFile step ─────────────────────────────────────
|
|
232
|
+
bold("━━━ Test: add_file — bake files into the image ━━━\n")
|
|
233
|
+
|
|
234
|
+
file_image = (
|
|
235
|
+
Image.base()
|
|
236
|
+
.add_file("/workspace/config.json", '{"env": "production", "debug": false}')
|
|
237
|
+
.add_file("/workspace/setup.sh", "#!/bin/bash\necho 'Hello from setup!'")
|
|
238
|
+
.run_commands("chmod +x /workspace/setup.sh")
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
dim("Creating sandbox with embedded files...")
|
|
242
|
+
sandbox4 = await Sandbox.create(image=file_image, timeout=300)
|
|
243
|
+
sandboxes.append(sandbox4)
|
|
244
|
+
|
|
245
|
+
config_check = await sandbox4.commands.run("cat /workspace/config.json")
|
|
246
|
+
check("add_file wrote config.json", '"env": "production"' in config_check.stdout, f'got: "{config_check.stdout.strip()}"')
|
|
247
|
+
|
|
248
|
+
setup_check = await sandbox4.commands.run("/workspace/setup.sh")
|
|
249
|
+
check("add_file wrote executable script", setup_check.stdout.strip() == "Hello from setup!", f'got: "{setup_check.stdout.strip()}"')
|
|
250
|
+
print()
|
|
251
|
+
|
|
252
|
+
# Kill sandbox before next test
|
|
253
|
+
for sb in sandboxes:
|
|
254
|
+
try:
|
|
255
|
+
await sb.kill()
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
sandboxes.clear()
|
|
259
|
+
|
|
260
|
+
# ── Test: Build log streaming ──────────────────────────────
|
|
261
|
+
bold("━━━ Test: Build log streaming (on_build_log callback) ━━━\n")
|
|
262
|
+
|
|
263
|
+
log_image = Image.base().run_commands("echo 'build step 1'", "echo 'build step 2'")
|
|
264
|
+
|
|
265
|
+
build_logs: list[str] = []
|
|
266
|
+
dim("Creating sandbox with build log streaming...")
|
|
267
|
+
sandbox5 = await Sandbox.create(
|
|
268
|
+
image=log_image,
|
|
269
|
+
timeout=300,
|
|
270
|
+
on_build_log=lambda log: (build_logs.append(log), dim(f" build: {log}")),
|
|
271
|
+
)
|
|
272
|
+
sandboxes.append(sandbox5)
|
|
273
|
+
|
|
274
|
+
check("Build log callback was called", len(build_logs) > 0, f"got {len(build_logs)} logs")
|
|
275
|
+
check("Sandbox created via SSE stream", sandbox5.status in ("running", "creating"))
|
|
276
|
+
print()
|
|
277
|
+
|
|
278
|
+
# Kill sandbox before next test
|
|
279
|
+
for sb in sandboxes:
|
|
280
|
+
try:
|
|
281
|
+
await sb.kill()
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
|
284
|
+
sandboxes.clear()
|
|
285
|
+
|
|
286
|
+
# ── Test: Snapshot build log streaming ──────────────────────
|
|
287
|
+
bold("━━━ Test: Snapshot build log streaming (on_build_logs) ━━━\n")
|
|
288
|
+
|
|
289
|
+
snapshot_log_image = Image.base().run_commands("echo 'snapshot build'")
|
|
290
|
+
|
|
291
|
+
snapshot_logs: list[str] = []
|
|
292
|
+
dim("Creating snapshot with build log streaming...")
|
|
293
|
+
streamed_snapshot = await snapshots.create(
|
|
294
|
+
name="test-streamed-py",
|
|
295
|
+
image=snapshot_log_image,
|
|
296
|
+
on_build_logs=lambda log: (snapshot_logs.append(log), dim(f" snapshot build: {log}")),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
check("Snapshot build log callback was called", len(snapshot_logs) > 0, f"got {len(snapshot_logs)} logs")
|
|
300
|
+
check("Streamed snapshot has name", streamed_snapshot["name"] == "test-streamed-py")
|
|
301
|
+
check("Streamed snapshot is ready", streamed_snapshot["status"] == "ready")
|
|
302
|
+
|
|
303
|
+
await snapshots.delete("test-streamed-py")
|
|
304
|
+
green("Cleaned up test-streamed-py snapshot")
|
|
305
|
+
print()
|
|
306
|
+
|
|
307
|
+
except Exception as err:
|
|
308
|
+
red(f"Fatal error: {err}")
|
|
309
|
+
import traceback
|
|
310
|
+
traceback.print_exc()
|
|
311
|
+
failed += 1
|
|
312
|
+
finally:
|
|
313
|
+
bold("━━━ Cleanup ━━━\n")
|
|
314
|
+
for sb in sandboxes:
|
|
315
|
+
try:
|
|
316
|
+
await sb.kill()
|
|
317
|
+
dim(f"Killed {sb.sandbox_id}")
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
320
|
+
try:
|
|
321
|
+
await snapshots.delete("test-env-py")
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
try:
|
|
325
|
+
await snapshots.delete("test-streamed-py")
|
|
326
|
+
except Exception:
|
|
327
|
+
pass
|
|
328
|
+
|
|
329
|
+
bold("========================================")
|
|
330
|
+
bold(f" Results: {passed} passed, {failed} failed")
|
|
331
|
+
bold("========================================\n")
|
|
332
|
+
if failed > 0:
|
|
333
|
+
sys.exit(1)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
if __name__ == "__main__":
|
|
337
|
+
asyncio.run(main())
|
|
@@ -4,8 +4,9 @@ from opencomputer.sandbox import Sandbox
|
|
|
4
4
|
from opencomputer.agent import Agent, AgentEvent, AgentSession, AgentSessionInfo
|
|
5
5
|
from opencomputer.filesystem import Filesystem
|
|
6
6
|
from opencomputer.exec import Exec, ProcessResult, ExecSessionInfo
|
|
7
|
+
from opencomputer.image import Image
|
|
7
8
|
from opencomputer.pty import Pty, PtySession
|
|
8
|
-
from opencomputer.
|
|
9
|
+
from opencomputer.snapshot import Snapshots
|
|
9
10
|
|
|
10
11
|
__all__ = [
|
|
11
12
|
"Sandbox",
|
|
@@ -17,9 +18,10 @@ __all__ = [
|
|
|
17
18
|
"Exec",
|
|
18
19
|
"ProcessResult",
|
|
19
20
|
"ExecSessionInfo",
|
|
21
|
+
"Image",
|
|
20
22
|
"Pty",
|
|
21
23
|
"PtySession",
|
|
22
|
-
"
|
|
24
|
+
"Snapshots",
|
|
23
25
|
]
|
|
24
26
|
|
|
25
27
|
__version__ = "0.5.0"
|
|
@@ -48,11 +48,13 @@ class Agent:
|
|
|
48
48
|
sandbox_id: str,
|
|
49
49
|
connect_url: str,
|
|
50
50
|
token: str,
|
|
51
|
+
api_key: str = "",
|
|
51
52
|
):
|
|
52
53
|
self._client = client
|
|
53
54
|
self._sandbox_id = sandbox_id
|
|
54
55
|
self._connect_url = connect_url
|
|
55
56
|
self._token = token
|
|
57
|
+
self._api_key = api_key
|
|
56
58
|
|
|
57
59
|
async def start(
|
|
58
60
|
self,
|
|
@@ -138,8 +140,12 @@ class Agent:
|
|
|
138
140
|
"https://", "wss://"
|
|
139
141
|
)
|
|
140
142
|
ws_endpoint = f"{ws_url}/sandboxes/{self._sandbox_id}/exec/{session_id}"
|
|
143
|
+
# WebSocket API cannot set custom headers, so pass credentials as query params.
|
|
144
|
+
# Prefer JWT token (direct worker access); fall back to API key (control plane).
|
|
141
145
|
if self._token:
|
|
142
146
|
ws_endpoint += f"?token={self._token}"
|
|
147
|
+
elif self._api_key:
|
|
148
|
+
ws_endpoint += f"?api_key={self._api_key}"
|
|
143
149
|
|
|
144
150
|
ws = await websockets.connect(ws_endpoint)
|
|
145
151
|
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Declarative image builder for OpenSandbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ImageStep:
|
|
15
|
+
type: str
|
|
16
|
+
args: dict[str, Any]
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict[str, Any]:
|
|
19
|
+
return {"type": self.type, "args": self.args}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Image:
|
|
24
|
+
"""Declarative image builder.
|
|
25
|
+
|
|
26
|
+
Defines a reproducible sandbox environment via a fluent API.
|
|
27
|
+
Under the hood, the manifest is sent to the server which boots a base sandbox,
|
|
28
|
+
executes each step, checkpoints the result, and caches it by content hash.
|
|
29
|
+
|
|
30
|
+
Example::
|
|
31
|
+
|
|
32
|
+
image = (
|
|
33
|
+
Image.base()
|
|
34
|
+
.apt_install(["curl", "git"])
|
|
35
|
+
.pip_install(["requests", "pandas"])
|
|
36
|
+
.add_file("/workspace/config.json", '{"key": "value"}')
|
|
37
|
+
.env({"PROJECT_ROOT": "/workspace"})
|
|
38
|
+
.workdir("/workspace")
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# On-demand: cached by content hash
|
|
42
|
+
sandbox = await Sandbox.create(image=image)
|
|
43
|
+
|
|
44
|
+
# Pre-built snapshot
|
|
45
|
+
await Snapshot.create(name="data-science", image=image)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
_base: str = "base"
|
|
49
|
+
_steps: tuple[ImageStep, ...] = field(default_factory=tuple)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def base(cls) -> Image:
|
|
53
|
+
"""Create a new image starting from the default OpenSandbox environment.
|
|
54
|
+
|
|
55
|
+
The base includes Ubuntu 22.04 with Python, Node.js, build tools, and
|
|
56
|
+
common utilities. Customize by chaining steps like .apt_install(),
|
|
57
|
+
.pip_install(), .run_commands(), etc.
|
|
58
|
+
"""
|
|
59
|
+
return cls()
|
|
60
|
+
|
|
61
|
+
def apt_install(self, packages: list[str]) -> Image:
|
|
62
|
+
"""Install system packages via apt-get."""
|
|
63
|
+
return Image(
|
|
64
|
+
_base=self._base,
|
|
65
|
+
_steps=(*self._steps, ImageStep("apt_install", {"packages": packages})),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def pip_install(self, packages: list[str]) -> Image:
|
|
69
|
+
"""Install Python packages via pip."""
|
|
70
|
+
return Image(
|
|
71
|
+
_base=self._base,
|
|
72
|
+
_steps=(*self._steps, ImageStep("pip_install", {"packages": packages})),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def run_commands(self, *commands: str) -> Image:
|
|
76
|
+
"""Run one or more shell commands."""
|
|
77
|
+
return Image(
|
|
78
|
+
_base=self._base,
|
|
79
|
+
_steps=(*self._steps, ImageStep("run", {"commands": list(commands)})),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def env(self, vars: dict[str, str]) -> Image:
|
|
83
|
+
"""Set environment variables (written to /etc/environment)."""
|
|
84
|
+
return Image(
|
|
85
|
+
_base=self._base,
|
|
86
|
+
_steps=(*self._steps, ImageStep("env", {"vars": vars})),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def workdir(self, path: str) -> Image:
|
|
90
|
+
"""Set the default working directory."""
|
|
91
|
+
return Image(
|
|
92
|
+
_base=self._base,
|
|
93
|
+
_steps=(*self._steps, ImageStep("workdir", {"path": path})),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def add_file(self, remote_path: str, content: str) -> Image:
|
|
97
|
+
"""Add a file with inline content to the image.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
remote_path: Absolute path inside the sandbox where the file will be written.
|
|
101
|
+
content: String content of the file.
|
|
102
|
+
"""
|
|
103
|
+
encoded = base64.b64encode(content.encode()).decode()
|
|
104
|
+
return Image(
|
|
105
|
+
_base=self._base,
|
|
106
|
+
_steps=(*self._steps, ImageStep("add_file", {
|
|
107
|
+
"path": remote_path,
|
|
108
|
+
"content": encoded,
|
|
109
|
+
"encoding": "base64",
|
|
110
|
+
})),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def add_local_file(self, local_path: str, remote_path: str) -> Image:
|
|
114
|
+
"""Add a local file into the image.
|
|
115
|
+
|
|
116
|
+
Reads the file from disk and embeds its content in the manifest.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
local_path: Path to the file on the local machine.
|
|
120
|
+
remote_path: Absolute path inside the sandbox where the file will be written.
|
|
121
|
+
"""
|
|
122
|
+
with open(local_path, "rb") as f:
|
|
123
|
+
encoded = base64.b64encode(f.read()).decode()
|
|
124
|
+
return Image(
|
|
125
|
+
_base=self._base,
|
|
126
|
+
_steps=(*self._steps, ImageStep("add_file", {
|
|
127
|
+
"path": remote_path,
|
|
128
|
+
"content": encoded,
|
|
129
|
+
"encoding": "base64",
|
|
130
|
+
})),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def add_local_dir(self, local_path: str, remote_path: str) -> Image:
|
|
134
|
+
"""Add a local directory into the image.
|
|
135
|
+
|
|
136
|
+
Recursively reads all files and embeds them in the manifest.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
local_path: Path to the directory on the local machine.
|
|
140
|
+
remote_path: Absolute path inside the sandbox where the directory will be created.
|
|
141
|
+
"""
|
|
142
|
+
files: list[dict[str, str]] = []
|
|
143
|
+
for root, _dirs, filenames in os.walk(local_path):
|
|
144
|
+
for fname in filenames:
|
|
145
|
+
full = os.path.join(root, fname)
|
|
146
|
+
rel = os.path.relpath(full, local_path)
|
|
147
|
+
with open(full, "rb") as f:
|
|
148
|
+
encoded = base64.b64encode(f.read()).decode()
|
|
149
|
+
files.append({"relativePath": rel, "content": encoded})
|
|
150
|
+
return Image(
|
|
151
|
+
_base=self._base,
|
|
152
|
+
_steps=(*self._steps, ImageStep("add_dir", {
|
|
153
|
+
"path": remote_path,
|
|
154
|
+
"files": files,
|
|
155
|
+
})),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def to_dict(self) -> dict[str, Any]:
|
|
159
|
+
"""Returns the manifest as a plain dict (for JSON serialization)."""
|
|
160
|
+
return {
|
|
161
|
+
"base": self._base,
|
|
162
|
+
"steps": [s.to_dict() for s in self._steps],
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
def cache_key(self) -> str:
|
|
166
|
+
"""Compute a deterministic content hash for caching."""
|
|
167
|
+
canonical = json.dumps(self.to_dict(), sort_keys=True, separators=(",", ":"))
|
|
168
|
+
return hashlib.sha256(canonical.encode()).hexdigest()
|
|
@@ -70,9 +70,13 @@ class Pty:
|
|
|
70
70
|
ws_url = self._api_url.replace("http://", "ws://").replace("https://", "wss://")
|
|
71
71
|
ws_url = f"{ws_url}/sandboxes/{self._sandbox_id}/pty/{session_id}"
|
|
72
72
|
|
|
73
|
+
# Pass credentials as both header and query param for compatibility.
|
|
74
|
+
# Python websockets supports custom headers, but query params work
|
|
75
|
+
# consistently across all WebSocket implementations.
|
|
73
76
|
headers = {}
|
|
74
77
|
if self._api_key:
|
|
75
78
|
headers["X-API-Key"] = self._api_key
|
|
79
|
+
ws_url += f"?api_key={self._api_key}"
|
|
76
80
|
|
|
77
81
|
ws = await websockets.connect(ws_url, additional_headers=headers)
|
|
78
82
|
|
|
@@ -4,14 +4,16 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
|
-
from typing import Any
|
|
7
|
+
from typing import Any, Callable
|
|
8
8
|
|
|
9
9
|
import httpx
|
|
10
10
|
|
|
11
11
|
from opencomputer.agent import Agent
|
|
12
12
|
from opencomputer.exec import Exec
|
|
13
13
|
from opencomputer.filesystem import Filesystem
|
|
14
|
+
from opencomputer.image import Image
|
|
14
15
|
from opencomputer.pty import Pty
|
|
16
|
+
from opencomputer.sse import parse_sse_stream
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
@dataclass
|
|
@@ -37,8 +39,23 @@ class Sandbox:
|
|
|
37
39
|
api_url: str | None = None,
|
|
38
40
|
envs: dict[str, str] | None = None,
|
|
39
41
|
metadata: dict[str, str] | None = None,
|
|
42
|
+
image: Image | None = None,
|
|
43
|
+
snapshot: str | None = None,
|
|
44
|
+
on_build_log: Callable[[str], None] | None = None,
|
|
40
45
|
) -> Sandbox:
|
|
41
|
-
"""Create a new sandbox instance.
|
|
46
|
+
"""Create a new sandbox instance.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
template: Base template name (default "base").
|
|
50
|
+
timeout: Sandbox timeout in seconds (default 300).
|
|
51
|
+
api_key: API key (or OPENCOMPUTER_API_KEY env var).
|
|
52
|
+
api_url: API URL (or OPENCOMPUTER_API_URL env var).
|
|
53
|
+
envs: Environment variables to inject.
|
|
54
|
+
metadata: Arbitrary metadata tags.
|
|
55
|
+
image: Declarative Image definition. The server builds and caches it as a checkpoint.
|
|
56
|
+
snapshot: Name of a pre-built snapshot to create the sandbox from.
|
|
57
|
+
on_build_log: Callback for build log streaming when using image/snapshot.
|
|
58
|
+
"""
|
|
42
59
|
url = api_url or os.environ.get("OPENCOMPUTER_API_URL", "https://app.opencomputer.dev")
|
|
43
60
|
url = url.rstrip("/")
|
|
44
61
|
key = api_key or os.environ.get("OPENCOMPUTER_API_KEY", "")
|
|
@@ -46,11 +63,17 @@ class Sandbox:
|
|
|
46
63
|
# Control plane client always uses /api prefix
|
|
47
64
|
api_base = url if url.endswith("/api") else f"{url}/api"
|
|
48
65
|
|
|
49
|
-
headers = {}
|
|
66
|
+
headers: dict[str, str] = {}
|
|
50
67
|
if key:
|
|
51
68
|
headers["X-API-Key"] = key
|
|
52
69
|
|
|
53
|
-
|
|
70
|
+
use_sse = on_build_log is not None and (image is not None or snapshot is not None)
|
|
71
|
+
if use_sse:
|
|
72
|
+
headers["Accept"] = "text/event-stream"
|
|
73
|
+
|
|
74
|
+
# Image builds may take longer
|
|
75
|
+
client_timeout = 300.0 if image else 30.0
|
|
76
|
+
client = httpx.AsyncClient(base_url=api_base, headers=headers, timeout=client_timeout)
|
|
54
77
|
|
|
55
78
|
body: dict[str, Any] = {
|
|
56
79
|
"templateID": template,
|
|
@@ -60,10 +83,17 @@ class Sandbox:
|
|
|
60
83
|
body["envs"] = envs
|
|
61
84
|
if metadata:
|
|
62
85
|
body["metadata"] = metadata
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
86
|
+
if image is not None:
|
|
87
|
+
body["image"] = image.to_dict()
|
|
88
|
+
if snapshot is not None:
|
|
89
|
+
body["snapshot"] = snapshot
|
|
90
|
+
|
|
91
|
+
if use_sse:
|
|
92
|
+
data = await cls._create_with_sse(client, body, on_build_log)
|
|
93
|
+
else:
|
|
94
|
+
resp = await client.post("/sandboxes", json=body)
|
|
95
|
+
resp.raise_for_status()
|
|
96
|
+
data = resp.json()
|
|
67
97
|
|
|
68
98
|
connect_url = data.get("connectURL", "")
|
|
69
99
|
token = data.get("token", "")
|
|
@@ -89,6 +119,18 @@ class Sandbox:
|
|
|
89
119
|
_data_client=data_client,
|
|
90
120
|
)
|
|
91
121
|
|
|
122
|
+
@classmethod
|
|
123
|
+
async def _create_with_sse(
|
|
124
|
+
cls,
|
|
125
|
+
client: httpx.AsyncClient,
|
|
126
|
+
body: dict[str, Any],
|
|
127
|
+
on_build_log: Callable[[str], None] | None,
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
"""Create sandbox with SSE build log streaming."""
|
|
130
|
+
async with client.stream("POST", "/sandboxes", json=body) as resp:
|
|
131
|
+
resp.raise_for_status()
|
|
132
|
+
return await parse_sse_stream(resp, on_build_log or (lambda _: None))
|
|
133
|
+
|
|
92
134
|
@classmethod
|
|
93
135
|
async def connect(
|
|
94
136
|
cls,
|
|
@@ -173,7 +215,7 @@ class Sandbox:
|
|
|
173
215
|
@property
|
|
174
216
|
def agent(self) -> Agent:
|
|
175
217
|
"""Access Claude Agent SDK sessions."""
|
|
176
|
-
return Agent(self._ops_client, self.sandbox_id, self._connect_url, self._token)
|
|
218
|
+
return Agent(self._ops_client, self.sandbox_id, self._connect_url, self._token, self._api_key)
|
|
177
219
|
|
|
178
220
|
@property
|
|
179
221
|
def files(self) -> Filesystem:
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Snapshot management for OpenSandbox - pre-built named image checkpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from opencomputer.image import Image
|
|
11
|
+
from opencomputer.sse import parse_sse_stream
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Snapshots:
|
|
15
|
+
"""Manage pre-built snapshots (named, persistent image checkpoints).
|
|
16
|
+
|
|
17
|
+
Example::
|
|
18
|
+
|
|
19
|
+
snapshots = Snapshots()
|
|
20
|
+
|
|
21
|
+
image = (
|
|
22
|
+
Image.base()
|
|
23
|
+
.apt_install(["python3", "python3-pip"])
|
|
24
|
+
.pip_install(["pandas", "numpy"])
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
await snapshots.create(name="data-science", image=image)
|
|
28
|
+
info = await snapshots.get("data-science")
|
|
29
|
+
all_snapshots = await snapshots.list()
|
|
30
|
+
await snapshots.delete("data-science")
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
api_key: str | None = None,
|
|
36
|
+
api_url: str | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
url = api_url or os.environ.get("OPENCOMPUTER_API_URL", "https://app.opencomputer.dev")
|
|
39
|
+
url = url.rstrip("/")
|
|
40
|
+
key = api_key or os.environ.get("OPENCOMPUTER_API_KEY", "")
|
|
41
|
+
|
|
42
|
+
api_base = url if url.endswith("/api") else f"{url}/api"
|
|
43
|
+
|
|
44
|
+
self._headers: dict[str, str] = {}
|
|
45
|
+
if key:
|
|
46
|
+
self._headers["X-API-Key"] = key
|
|
47
|
+
|
|
48
|
+
self._api_base = api_base
|
|
49
|
+
self._client = httpx.AsyncClient(base_url=api_base, headers=self._headers, timeout=300.0)
|
|
50
|
+
|
|
51
|
+
async def create(
|
|
52
|
+
self,
|
|
53
|
+
name: str,
|
|
54
|
+
image: Image,
|
|
55
|
+
on_build_logs: Callable[[str], None] | None = None,
|
|
56
|
+
) -> dict[str, Any]:
|
|
57
|
+
"""Create a pre-built snapshot from a declarative image.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
name: Unique name for this snapshot.
|
|
61
|
+
image: Declarative Image definition.
|
|
62
|
+
on_build_logs: Optional callback for build log streaming.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Snapshot info dict with id, name, status, etc.
|
|
66
|
+
"""
|
|
67
|
+
body = {"name": name, "image": image.to_dict()}
|
|
68
|
+
|
|
69
|
+
if on_build_logs is not None:
|
|
70
|
+
headers = {**self._headers, "Accept": "text/event-stream"}
|
|
71
|
+
client = httpx.AsyncClient(
|
|
72
|
+
base_url=self._api_base, headers=headers, timeout=300.0,
|
|
73
|
+
)
|
|
74
|
+
try:
|
|
75
|
+
async with client.stream("POST", "/snapshots", json=body) as resp:
|
|
76
|
+
resp.raise_for_status()
|
|
77
|
+
return await parse_sse_stream(resp, on_build_logs)
|
|
78
|
+
finally:
|
|
79
|
+
await client.aclose()
|
|
80
|
+
|
|
81
|
+
resp = await self._client.post("/snapshots", json=body)
|
|
82
|
+
resp.raise_for_status()
|
|
83
|
+
return resp.json()
|
|
84
|
+
|
|
85
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
86
|
+
"""List all named snapshots for the current org."""
|
|
87
|
+
resp = await self._client.get("/snapshots")
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
return resp.json()
|
|
90
|
+
|
|
91
|
+
async def get(self, name: str) -> dict[str, Any]:
|
|
92
|
+
"""Get a snapshot by name."""
|
|
93
|
+
resp = await self._client.get(f"/snapshots/{name}")
|
|
94
|
+
resp.raise_for_status()
|
|
95
|
+
return resp.json()
|
|
96
|
+
|
|
97
|
+
async def delete(self, name: str) -> None:
|
|
98
|
+
"""Delete a named snapshot."""
|
|
99
|
+
resp = await self._client.delete(f"/snapshots/{name}")
|
|
100
|
+
if resp.status_code != 404:
|
|
101
|
+
resp.raise_for_status()
|
|
102
|
+
|
|
103
|
+
async def close(self) -> None:
|
|
104
|
+
"""Close the HTTP client."""
|
|
105
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Shared SSE stream parser for build log streaming."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def parse_sse_stream(
|
|
12
|
+
resp: httpx.Response,
|
|
13
|
+
on_log: Callable[[str], None],
|
|
14
|
+
) -> dict[str, Any]:
|
|
15
|
+
"""Parse an SSE stream from the server.
|
|
16
|
+
|
|
17
|
+
The server emits three event types:
|
|
18
|
+
- build_log: streamed during image build, contains { step, type, message }
|
|
19
|
+
- error: build failed, contains { error: string }
|
|
20
|
+
- result: final result JSON
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
resp: httpx streaming response with content-type text/event-stream
|
|
24
|
+
on_log: Callback for build log messages
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The parsed result dict from the "result" event
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
RuntimeError: If the build fails or no result is received
|
|
31
|
+
"""
|
|
32
|
+
result: dict[str, Any] | None = None
|
|
33
|
+
buffer = ""
|
|
34
|
+
|
|
35
|
+
async for chunk in resp.aiter_text():
|
|
36
|
+
buffer += chunk
|
|
37
|
+
events = buffer.split("\n\n")
|
|
38
|
+
buffer = events[-1]
|
|
39
|
+
|
|
40
|
+
for event in events[:-1]:
|
|
41
|
+
if not event.strip():
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
event_type = ""
|
|
45
|
+
data = ""
|
|
46
|
+
for line in event.split("\n"):
|
|
47
|
+
if line.startswith("event: "):
|
|
48
|
+
event_type = line[7:]
|
|
49
|
+
elif line.startswith("data: "):
|
|
50
|
+
data = line[6:]
|
|
51
|
+
|
|
52
|
+
if not data:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
if event_type == "build_log":
|
|
56
|
+
try:
|
|
57
|
+
parsed = json.loads(data)
|
|
58
|
+
on_log(parsed.get("message", data))
|
|
59
|
+
except (json.JSONDecodeError, TypeError):
|
|
60
|
+
on_log(data)
|
|
61
|
+
elif event_type == "error":
|
|
62
|
+
msg = data
|
|
63
|
+
try:
|
|
64
|
+
msg = json.loads(data).get("error", data)
|
|
65
|
+
except (json.JSONDecodeError, TypeError):
|
|
66
|
+
pass
|
|
67
|
+
raise RuntimeError(f"Build failed: {msg}")
|
|
68
|
+
elif event_type == "result":
|
|
69
|
+
result = json.loads(data)
|
|
70
|
+
|
|
71
|
+
if not result:
|
|
72
|
+
raise RuntimeError("No result received from build stream")
|
|
73
|
+
return result
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
"""Template management for custom sandbox environments."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
|
|
7
|
-
import httpx
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@dataclass
|
|
11
|
-
class TemplateInfo:
|
|
12
|
-
"""Template metadata."""
|
|
13
|
-
|
|
14
|
-
template_id: str
|
|
15
|
-
name: str
|
|
16
|
-
tag: str
|
|
17
|
-
status: str
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
@dataclass
|
|
21
|
-
class Template:
|
|
22
|
-
"""Template management operations."""
|
|
23
|
-
|
|
24
|
-
_client: httpx.AsyncClient
|
|
25
|
-
|
|
26
|
-
@classmethod
|
|
27
|
-
def _from_client(cls, client: httpx.AsyncClient) -> Template:
|
|
28
|
-
return cls(_client=client)
|
|
29
|
-
|
|
30
|
-
async def build(self, name: str, dockerfile: str) -> TemplateInfo:
|
|
31
|
-
"""Build a new template from a Dockerfile."""
|
|
32
|
-
resp = await self._client.post(
|
|
33
|
-
"/templates",
|
|
34
|
-
json={"name": name, "dockerfile": dockerfile},
|
|
35
|
-
timeout=300.0,
|
|
36
|
-
)
|
|
37
|
-
resp.raise_for_status()
|
|
38
|
-
data = resp.json()
|
|
39
|
-
return TemplateInfo(
|
|
40
|
-
template_id=data["templateID"],
|
|
41
|
-
name=data["name"],
|
|
42
|
-
tag=data.get("tag", "latest"),
|
|
43
|
-
status=data.get("status", "ready"),
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
async def list(self) -> list[TemplateInfo]:
|
|
47
|
-
"""List all available templates."""
|
|
48
|
-
resp = await self._client.get("/templates")
|
|
49
|
-
resp.raise_for_status()
|
|
50
|
-
return [
|
|
51
|
-
TemplateInfo(
|
|
52
|
-
template_id=t["templateID"],
|
|
53
|
-
name=t["name"],
|
|
54
|
-
tag=t.get("tag", "latest"),
|
|
55
|
-
status=t.get("status", "ready"),
|
|
56
|
-
)
|
|
57
|
-
for t in resp.json()
|
|
58
|
-
]
|
|
59
|
-
|
|
60
|
-
async def get(self, name: str) -> TemplateInfo:
|
|
61
|
-
"""Get template details by name."""
|
|
62
|
-
resp = await self._client.get(f"/templates/{name}")
|
|
63
|
-
resp.raise_for_status()
|
|
64
|
-
data = resp.json()
|
|
65
|
-
return TemplateInfo(
|
|
66
|
-
template_id=data["templateID"],
|
|
67
|
-
name=data["name"],
|
|
68
|
-
tag=data.get("tag", "latest"),
|
|
69
|
-
status=data.get("status", "ready"),
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
async def delete(self, name: str) -> None:
|
|
73
|
-
"""Delete a template."""
|
|
74
|
-
resp = await self._client.delete(f"/templates/{name}")
|
|
75
|
-
resp.raise_for_status()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|