fix: renamed sandbox to capsule

This commit is contained in:
Tasnim Kabir Sadik
2026-04-13 03:16:27 +06:00
parent 976af9a209
commit bf5914c0a8
14 changed files with 1929 additions and 1805 deletions

View File

@ -1,11 +1,10 @@
from __future__ import annotations
import pytest
import respx
from wrenn.capsule import Capsule, CodeResult, _build_proxy_url
from wrenn.client import WrennClient
from wrenn.sandbox import CodeResult, Sandbox, _build_proxy_url
@pytest.fixture
@ -32,14 +31,14 @@ class TestBuildProxyUrl:
assert url == "ws://5000-sb-2.192.168.1.1"
class TestSandboxGetUrl:
class TestCapsuleGetUrl:
@respx.mock
def test_get_url_returns_proxy_url(self, client):
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, json={"id": "cl-abc", "status": "pending"}
)
sb = client.sandboxes.create(template="minimal")
url = sb.get_url(8888)
cap = client.capsules.create(template="minimal")
url = cap.get_url(8888)
assert url == "wss://8888-cl-abc.api.wrenn.dev"
@respx.mock
@ -48,22 +47,22 @@ class TestSandboxGetUrl:
api_key="wrn_test1234567890abcdef12345678",
base_url="http://localhost:8080",
) as c:
respx.post("http://localhost:8080/v1/sandboxes").respond(
respx.post("http://localhost:8080/v1/capsules").respond(
201, json={"id": "cl-xyz", "status": "pending"}
)
sb = c.sandboxes.create()
url = sb.get_url(3000)
cap = c.capsules.create()
url = cap.get_url(3000)
assert url == "ws://3000-cl-xyz.localhost:8080"
class TestSandboxHttpClient:
class TestCapsuleHttpClient:
@respx.mock
def test_http_client_has_api_key_header(self, client):
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, json={"id": "cl-abc", "status": "pending"}
)
sb = client.sandboxes.create()
hc = sb.http_client
cap = client.capsules.create()
hc = cap.http_client
assert hc.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
@respx.mock
@ -71,51 +70,51 @@ class TestSandboxHttpClient:
route = respx.get("https://8888-cl-abc.api.wrenn.dev/api/kernels").respond(
200, json=[]
)
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, json={"id": "cl-abc", "status": "pending"}
)
sb = client.sandboxes.create()
resp = sb.http_client.get("/api/kernels")
cap = client.capsules.create()
resp = cap.http_client.get("/api/kernels")
assert resp.status_code == 200
assert route.called
def test_jwt_only_get_url_works(self):
with WrennClient(token="jwt-abc") as c:
sb = Sandbox(id="cl-abc")
sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc")
url = sb.get_url(8888)
cap = Capsule(id="cl-abc")
cap._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc")
url = cap.get_url(8888)
assert "8888-cl-abc" in url
def test_jwt_only_http_client_has_bearer_header(self):
with WrennClient(token="jwt-abc") as c:
sb = Sandbox(id="cl-abc")
sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc")
hc = sb.http_client
cap = Capsule(id="cl-abc")
cap._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc")
hc = cap.http_client
assert hc.headers["Authorization"] == "Bearer jwt-abc"
class TestCreateReturnsBoundSandbox:
class TestCreateReturnsBoundCapsule:
@respx.mock
def test_create_returns_sandbox_subclass(self, client):
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
def test_create_returns_capsule_subclass(self, client):
respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, json={"id": "cl-1", "status": "pending", "template": "minimal"}
)
sb = client.sandboxes.create(template="minimal")
assert isinstance(sb, Sandbox)
assert sb.id == "cl-1"
assert hasattr(sb, "exec")
assert hasattr(sb, "run_code")
assert hasattr(sb, "get_url")
cap = client.capsules.create(template="minimal")
assert isinstance(cap, Capsule)
assert cap.id == "cl-1"
assert hasattr(cap, "exec")
assert hasattr(cap, "run_code")
assert hasattr(cap, "get_url")
@respx.mock
def test_create_context_manager(self, client):
route = respx.delete("https://api.wrenn.dev/v1/sandboxes/cl-1").respond(204)
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
route = respx.delete("https://api.wrenn.dev/v1/capsules/cl-1").respond(204)
respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, json={"id": "cl-1", "status": "pending"}
)
sb = client.sandboxes.create()
with sb:
assert sb.id == "cl-1"
cap = client.capsules.create()
with cap:
assert cap.id == "cl-1"
assert route.called
@ -147,8 +146,8 @@ class TestCodeResult:
class TestJupyterMessageFormat:
def test_execute_request_structure(self):
sb = Sandbox(id="test")
msg = sb._jupyter_execute_request("x = 42")
cap = Capsule(id="test")
msg = cap._jupyter_execute_request("x = 42")
assert msg["msg_type"] == "execute_request"
assert msg["content"]["code"] == "x = 42"
assert msg["content"]["silent"] is False
@ -157,7 +156,45 @@ class TestJupyterMessageFormat:
assert msg["header"]["msg_type"] == "execute_request"
def test_execute_request_unique_ids(self):
sb = Sandbox(id="test")
m1 = sb._jupyter_execute_request("a")
m2 = sb._jupyter_execute_request("b")
cap = Capsule(id="test")
m1 = cap._jupyter_execute_request("a")
m2 = cap._jupyter_execute_request("b")
assert m1["msg_id"] != m2["msg_id"]
class TestDeprecationWarnings:
def test_import_sandbox_from_capsule_warns(self):
import importlib
import warnings
import wrenn.capsule as capsule_mod
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
klass = capsule_mod.Sandbox
assert klass is Capsule
assert len(w) == 1
assert issubclass(w[0].category, DeprecationWarning)
assert "Sandbox" in str(w[0].message)
def test_import_sandbox_from_wrenn_warns(self):
import warnings
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
from wrenn import Sandbox
assert Sandbox is Capsule
assert any(issubclass(x.category, DeprecationWarning) for x in w)
def test_client_sandboxes_property_warns(self):
import warnings
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
resource = c.sandboxes
assert resource is c.capsules
assert len(w) == 1
assert issubclass(w[0].category, DeprecationWarning)
assert "sandboxes" in str(w[0].message)

View File

@ -9,7 +9,7 @@ from wrenn.exceptions import (
WrennAuthenticationError,
WrennConflictError,
WrennForbiddenError,
WrennHostHasSandboxesError,
WrennHostHasCapsulesError,
WrennInternalError,
WrennNotFoundError,
WrennValidationError,
@ -17,9 +17,9 @@ from wrenn.exceptions import (
from wrenn.models import (
APIKeyResponse,
AuthResponse,
Capsule,
CreateHostResponse,
Host,
Sandbox,
Status,
Template,
)
@ -97,10 +97,10 @@ class TestAPIKeys:
assert route.called
class TestSandboxes:
class TestCapsules:
@respx.mock
def test_create(self, client):
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
respx.post("https://api.wrenn.dev/v1/capsules").respond(
201,
json={
"id": "sb-1",
@ -110,40 +110,40 @@ class TestSandboxes:
"memory_mb": 1024,
},
)
resp = client.sandboxes.create(template="base-python", vcpus=2, memory_mb=1024)
assert isinstance(resp, Sandbox)
resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024)
assert isinstance(resp, Capsule)
assert resp.id == "sb-1"
assert resp.status == Status.pending
@respx.mock
def test_create_defaults(self, client):
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, json={"id": "sb-2", "status": "pending"}
)
resp = client.sandboxes.create()
resp = client.capsules.create()
assert resp.id == "sb-2"
@respx.mock
def test_list(self, client):
respx.get("https://api.wrenn.dev/v1/sandboxes").respond(
respx.get("https://api.wrenn.dev/v1/capsules").respond(
200, json=[{"id": "sb-1", "status": "running"}]
)
boxes = client.sandboxes.list()
boxes = client.capsules.list()
assert len(boxes) == 1
assert boxes[0].status == Status.running
@respx.mock
def test_get(self, client):
respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond(
respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond(
200, json={"id": "sb-1", "status": "running"}
)
resp = client.sandboxes.get("sb-1")
resp = client.capsules.get("sb-1")
assert resp.id == "sb-1"
@respx.mock
def test_destroy(self, client):
route = respx.delete("https://api.wrenn.dev/v1/sandboxes/sb-1").respond(204)
client.sandboxes.destroy("sb-1")
route = respx.delete("https://api.wrenn.dev/v1/capsules/sb-1").respond(204)
client.capsules.destroy("sb-1")
assert route.called
@ -154,7 +154,7 @@ class TestSnapshots:
201,
json={"name": "snap-1", "type": "snapshot", "vcpus": 1},
)
resp = client.snapshots.create(sandbox_id="sb-1", name="snap-1")
resp = client.snapshots.create(capsule_id="sb-1", name="snap-1")
assert isinstance(resp, Template)
assert resp.name == "snap-1"
@ -163,7 +163,7 @@ class TestSnapshots:
route = respx.post("https://api.wrenn.dev/v1/snapshots").respond(
201, json={"name": "snap-1", "type": "snapshot"}
)
client.snapshots.create(sandbox_id="sb-1", overwrite=True)
client.snapshots.create(capsule_id="sb-1", overwrite=True)
req = route.calls[0].request
assert "overwrite=true" in str(req.url)
@ -262,23 +262,23 @@ class TestHosts:
class TestErrorHandling:
@respx.mock
def test_validation_error(self, client):
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
respx.post("https://api.wrenn.dev/v1/capsules").respond(
400,
json={"error": {"code": "invalid_request", "message": "bad input"}},
)
with pytest.raises(WrennValidationError) as exc_info:
client.sandboxes.create()
client.capsules.create()
assert exc_info.value.code == "invalid_request"
assert exc_info.value.status_code == 400
@respx.mock
def test_auth_error(self, client):
respx.get("https://api.wrenn.dev/v1/sandboxes").respond(
respx.get("https://api.wrenn.dev/v1/capsules").respond(
401,
json={"error": {"code": "unauthorized", "message": "bad key"}},
)
with pytest.raises(WrennAuthenticationError):
client.sandboxes.list()
client.capsules.list()
@respx.mock
def test_forbidden_error(self, client):
@ -291,66 +291,66 @@ class TestErrorHandling:
@respx.mock
def test_not_found_error(self, client):
respx.get("https://api.wrenn.dev/v1/sandboxes/nope").respond(
respx.get("https://api.wrenn.dev/v1/capsules/nope").respond(
404,
json={"error": {"code": "not_found", "message": "sandbox not found"}},
json={"error": {"code": "not_found", "message": "capsule not found"}},
)
with pytest.raises(WrennNotFoundError):
client.sandboxes.get("nope")
client.capsules.get("nope")
@respx.mock
def test_conflict_error(self, client):
respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond(
respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond(
409,
json={"error": {"code": "invalid_state", "message": "not running"}},
)
with pytest.raises(WrennConflictError):
client.sandboxes.get("sb-1")
client.capsules.get("sb-1")
@respx.mock
def test_host_has_sandboxes_error(self, client):
def test_host_has_capsules_error(self, client):
respx.delete("https://api.wrenn.dev/v1/hosts/h-1").respond(
409,
json={
"error": {
"code": "host_has_sandboxes",
"message": "host has running sandboxes",
"code": "host_has_capsules",
"message": "host has running capsules",
},
"sandbox_ids": ["sb-1", "sb-2"],
},
)
with pytest.raises(WrennHostHasSandboxesError) as exc_info:
with pytest.raises(WrennHostHasCapsulesError) as exc_info:
client.hosts.delete("h-1")
assert exc_info.value.sandbox_ids == ["sb-1", "sb-2"]
assert exc_info.value.capsule_ids == ["sb-1", "sb-2"]
@respx.mock
def test_agent_error(self, client):
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
respx.post("https://api.wrenn.dev/v1/capsules").respond(
502,
json={"error": {"code": "agent_error", "message": "host agent failed"}},
)
with pytest.raises(WrennAgentError):
client.sandboxes.create()
client.capsules.create()
@respx.mock
def test_internal_error(self, client):
respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond(
respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond(
500,
json={"error": {"code": "internal_error", "message": "oops"}},
)
with pytest.raises(WrennInternalError):
client.sandboxes.get("sb-1")
client.capsules.get("sb-1")
@respx.mock
def test_unknown_error_code_falls_back(self, client):
respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond(
respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond(
418,
json={"error": {"code": "teapot", "message": "I'm a teapot"}},
)
from wrenn.exceptions import WrennError
with pytest.raises(WrennError) as exc_info:
client.sandboxes.get("sb-1")
client.capsules.get("sb-1")
assert exc_info.value.code == "teapot"
@ -379,22 +379,22 @@ class TestAuthModes:
class TestAsyncClient:
@pytest.mark.asyncio
@respx.mock
async def test_async_sandboxes_create(self, async_client):
async def test_async_capsules_create(self, async_client):
async with async_client:
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, json={"id": "sb-1", "status": "pending"}
)
resp = await async_client.sandboxes.create(template="base-python")
resp = await async_client.capsules.create(template="base-python")
assert resp.id == "sb-1"
@pytest.mark.asyncio
@respx.mock
async def test_async_sandboxes_list(self, async_client):
async def test_async_capsules_list(self, async_client):
async with async_client:
respx.get("https://api.wrenn.dev/v1/sandboxes").respond(
respx.get("https://api.wrenn.dev/v1/capsules").respond(
200, json=[{"id": "sb-1"}]
)
boxes = await async_client.sandboxes.list()
boxes = await async_client.capsules.list()
assert len(boxes) == 1
@pytest.mark.asyncio
@ -409,9 +409,9 @@ class TestAsyncClient:
@respx.mock
async def test_async_error_handling(self, async_client):
async with async_client:
respx.get("https://api.wrenn.dev/v1/sandboxes/nope").respond(
respx.get("https://api.wrenn.dev/v1/capsules/nope").respond(
404,
json={"error": {"code": "not_found", "message": "not found"}},
)
with pytest.raises(WrennNotFoundError):
await async_client.sandboxes.get("nope")
await async_client.capsules.get("nope")

View File

@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
import respx
from wrenn.capsule import Capsule
from wrenn.client import WrennClient
from wrenn.models import FileEntry
from wrenn.pty import (
@ -15,7 +16,6 @@ from wrenn.pty import (
PtySession,
_parse_pty_event,
)
from wrenn.sandbox import Sandbox
@pytest.fixture
@ -24,18 +24,18 @@ def client():
yield c
def _make_sandbox(client: WrennClient, sb_id: str = "cl-abc") -> Sandbox:
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
201, json={"id": sb_id, "status": "running"}
def _make_capsule(client: WrennClient, cap_id: str = "cl-abc") -> Capsule:
respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, json={"id": cap_id, "status": "running"}
)
return client.sandboxes.create()
return client.capsules.create()
class TestListDir:
@respx.mock
def test_list_dir_returns_entries(self, client):
sb = _make_sandbox(client)
respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list").respond(
cap = _make_capsule(client)
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond(
200,
json={
"entries": [
@ -66,7 +66,7 @@ class TestListDir:
]
},
)
entries = sb.list_dir("/home/user")
entries = cap.list_dir("/home/user")
assert len(entries) == 2
assert isinstance(entries[0], FileEntry)
assert entries[0].name == "main.py"
@ -76,27 +76,27 @@ class TestListDir:
@respx.mock
def test_list_dir_with_depth(self, client):
sb = _make_sandbox(client)
cap = _make_capsule(client)
route = respx.post(
"https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list"
"https://api.wrenn.dev/v1/capsules/cl-abc/files/list"
).respond(200, json={"entries": []})
sb.list_dir("/home/user", depth=3)
cap.list_dir("/home/user", depth=3)
body = json.loads(route.calls[0].request.content)
assert body["depth"] == 3
@respx.mock
def test_list_dir_empty(self, client):
sb = _make_sandbox(client)
respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list").respond(
cap = _make_capsule(client)
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond(
200, json={"entries": []}
)
entries = sb.list_dir("/empty")
entries = cap.list_dir("/empty")
assert entries == []
@respx.mock
def test_list_dir_symlink(self, client):
sb = _make_sandbox(client)
respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list").respond(
cap = _make_capsule(client)
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond(
200,
json={
"entries": [
@ -115,7 +115,7 @@ class TestListDir:
]
},
)
entries = sb.list_dir("/home/user")
entries = cap.list_dir("/home/user")
assert len(entries) == 1
assert entries[0].type == "symlink"
assert entries[0].symlink_target == "/bin"
@ -124,8 +124,8 @@ class TestListDir:
class TestMkdir:
@respx.mock
def test_mkdir_returns_entry(self, client):
sb = _make_sandbox(client)
respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/mkdir").respond(
cap = _make_capsule(client)
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/mkdir").respond(
200,
json={
"entry": {
@ -142,19 +142,19 @@ class TestMkdir:
}
},
)
entry = sb.mkdir("/home/user/data")
entry = cap.mkdir("/home/user/data")
assert isinstance(entry, FileEntry)
assert entry.name == "data"
assert entry.type == "directory"
@respx.mock
def test_mkdir_existing_returns_gracefully(self, client):
sb = _make_sandbox(client)
respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/mkdir").respond(
cap = _make_capsule(client)
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/mkdir").respond(
409,
json={"error": {"code": "conflict", "message": "already exists"}},
)
respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list").respond(
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond(
200,
json={
"entries": [
@ -173,27 +173,27 @@ class TestMkdir:
]
},
)
entry = sb.mkdir("/home/user/data")
entry = cap.mkdir("/home/user/data")
assert entry.name == "data"
class TestRemove:
@respx.mock
def test_remove_succeeds(self, client):
sb = _make_sandbox(client)
cap = _make_capsule(client)
route = respx.post(
"https://api.wrenn.dev/v1/sandboxes/cl-abc/files/remove"
"https://api.wrenn.dev/v1/capsules/cl-abc/files/remove"
).respond(204)
sb.remove("/home/user/old_data")
cap.remove("/home/user/old_data")
assert route.called
@respx.mock
def test_remove_sends_path(self, client):
sb = _make_sandbox(client)
cap = _make_capsule(client)
route = respx.post(
"https://api.wrenn.dev/v1/sandboxes/cl-abc/files/remove"
"https://api.wrenn.dev/v1/capsules/cl-abc/files/remove"
).respond(204)
sb.remove("/tmp/test.txt")
cap.remove("/tmp/test.txt")
body = json.loads(route.calls[0].request.content)
assert body["path"] == "/tmp/test.txt"
@ -201,23 +201,23 @@ class TestRemove:
class TestUpload:
@respx.mock
def test_upload_sends_multipart(self, client):
sb = _make_sandbox(client)
cap = _make_capsule(client)
route = respx.post(
"https://api.wrenn.dev/v1/sandboxes/cl-abc/files/write"
"https://api.wrenn.dev/v1/capsules/cl-abc/files/write"
).respond(204)
sb.upload("/app/main.py", b"print('hello')")
cap.upload("/app/main.py", b"print('hello')")
assert route.called
req = route.calls[0].request
assert b"multipart/form-data" in req.headers.get("content-type", "").encode()
@respx.mock
def test_download_returns_bytes(self, client):
sb = _make_sandbox(client)
cap = _make_capsule(client)
content = b"file contents here"
respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/read").respond(
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/read").respond(
200, content=content
)
data = sb.download("/app/main.py")
data = cap.download("/app/main.py")
assert data == content
@ -500,7 +500,8 @@ class TestExports:
assert APS is not None
def test_pty_event_importable(self):
from wrenn import PtyEvent as PE, PtyEventType as PET
from wrenn import PtyEvent as PE
from wrenn import PtyEventType as PET
assert PE is not None
assert PET is not None

View File

@ -64,74 +64,74 @@ def bearer_client() -> Generator[WrennClient, None, None]:
@requires_auth
class TestSandboxLifecycle:
class TestCapsuleLifecycle:
def test_create_exec_destroy(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
result = sb.exec("echo", args=["hello"])
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
result = cap.exec("echo", args=["hello"])
assert result.exit_code == 0
assert "hello" in result.stdout
def test_exec_with_args(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
result = sb.exec("echo", args=["hello", "world"])
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
result = cap.exec("echo", args=["hello", "world"])
assert result.exit_code == 0
assert "hello world" in result.stdout
def test_exec_nonzero_exit(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
result = sb.exec("sh", args=["-c", "exit 42"])
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
result = cap.exec("sh", args=["-c", "exit 42"])
assert result.exit_code == 42
def test_exec_stderr(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
result = sb.exec("sh", args=["-c", "echo err>&2"])
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
result = cap.exec("sh", args=["-c", "echo err>&2"])
assert result.exit_code == 0
assert "err" in result.stderr
def test_context_manager_cleanup(self, client):
sb = client.sandboxes.create(template="minimal", timeout_sec=120)
sb_id = sb.id
cap = client.capsules.create(template="minimal", timeout_sec=120)
cap_id = cap.id
with sb:
sb.wait_ready(timeout=60, interval=1)
with cap:
cap.wait_ready(timeout=60, interval=1)
fetched = client.sandboxes.get(sb_id)
fetched = client.capsules.get(cap_id)
assert fetched.status in ("stopped", "destroyed")
@requires_auth
class TestFileIO:
def test_upload_and_download(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
content = b"Hello from integration test!"
sb.upload("/tmp/test_file.txt", content)
downloaded = sb.download("/tmp/test_file.txt")
cap.upload("/tmp/test_file.txt", content)
downloaded = cap.download("/tmp/test_file.txt")
assert downloaded == content
def test_download_nonexistent_file(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
with pytest.raises(Exception):
sb.download("/tmp/no_such_file_12345")
cap.download("/tmp/no_such_file_12345")
@requires_auth
class TestPauseResume:
def test_pause_and_resume(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
sb.pause()
assert sb.status == "paused"
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
cap.pause()
assert cap.status == "paused"
sb.resume()
sb.wait_ready(timeout=60, interval=1)
cap.resume()
cap.wait_ready(timeout=60, interval=1)
result = sb.exec("echo", args=["resumed"])
result = cap.exec("echo", args=["resumed"])
assert result.exit_code == 0
assert "resumed" in result.stdout
@ -139,10 +139,10 @@ class TestPauseResume:
@requires_auth
class TestPing:
def test_ping_resets_timer(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
sb.ping()
result = sb.exec("echo", args=["still_alive"])
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
cap.ping()
result = cap.exec("echo", args=["still_alive"])
assert result.exit_code == 0
assert "still_alive" in result.stdout
@ -150,32 +150,32 @@ class TestPing:
@requires_auth
class TestProxy:
def test_get_url(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
url = sb.get_url(8888)
assert sb.id in url
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
url = cap.get_url(8888)
assert cap.id in url
assert "8888" in url
@requires_auth
class TestListAndGet:
def test_list_sandboxes(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
boxes = client.sandboxes.list()
def test_list_capsules(self, client):
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
boxes = client.capsules.list()
ids = [b.id for b in boxes]
assert sb.id in ids
assert cap.id in ids
def test_get_existing_sandbox(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
fetched = client.sandboxes.get(sb.id)
assert fetched.id == sb.id
def test_get_existing_capsule(self, client):
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
fetched = client.capsules.get(cap.id)
assert fetched.id == cap.id
assert fetched.status == "running"
def test_get_nonexistent_sandbox(self, client):
def test_get_nonexistent_capsule(self, client):
with pytest.raises((WrennNotFoundError, WrennValidationError)):
client.sandboxes.get("cl-nonexistent00000000000000000")
client.capsules.get("cl-nonexistent00000000000000000")
@requires_auth
@ -204,117 +204,117 @@ class TestAPIKeys:
@requires_auth
class TestRunCode:
def test_basic_execution(self, client):
with client.sandboxes.create(
with client.capsules.create(
template="python-interpreter-v0-beta", timeout_sec=120
) as sb:
sb.wait_ready(timeout=60, interval=1)
) as cap:
cap.wait_ready(timeout=60, interval=1)
r = sb.run_code("x = 42")
r = cap.run_code("x = 42")
assert r.error is None
r = sb.run_code("x * 2")
r = cap.run_code("x * 2")
assert r.text == "84"
def test_state_persists(self, client):
with client.sandboxes.create(
with client.capsules.create(
template="python-interpreter-v0-beta", timeout_sec=120
) as sb:
sb.wait_ready(timeout=60, interval=1)
) as cap:
cap.wait_ready(timeout=60, interval=1)
sb.run_code("def greet(name): return f'hello {name}'")
r = sb.run_code("greet('sandbox')")
assert "hello sandbox" in (r.text or "")
cap.run_code("def greet(name): return f'hello {name}'")
r = cap.run_code("greet('capsule')")
assert "hello capsule" in (r.text or "")
def test_error_traceback(self, client):
with client.sandboxes.create(
with client.capsules.create(
template="python-interpreter-v0-beta", timeout_sec=120
) as sb:
sb.wait_ready(timeout=60, interval=1)
) as cap:
cap.wait_ready(timeout=60, interval=1)
r = sb.run_code("1/0")
r = cap.run_code("1/0")
assert r.error is not None
assert "ZeroDivisionError" in r.error
def test_stdout_capture(self, client):
with client.sandboxes.create(
with client.capsules.create(
template="python-interpreter-v0-beta", timeout_sec=120
) as sb:
sb.wait_ready(timeout=60, interval=1)
) as cap:
cap.wait_ready(timeout=60, interval=1)
r = sb.run_code("print('hello from kernel')")
r = cap.run_code("print('hello from kernel')")
assert "hello from kernel" in r.stdout
@requires_auth
class TestAsyncSandboxLifecycle:
class TestAsyncCapsuleLifecycle:
@pytest.mark.asyncio
async def test_async_create_exec_destroy(self, async_client):
async with async_client:
sb = await async_client.sandboxes.create(
cap = await async_client.capsules.create(
template="minimal", timeout_sec=120
)
try:
await sb.async_wait_ready(timeout=60, interval=1)
result = await sb.async_exec("echo", args=["async_hello"])
await cap.async_wait_ready(timeout=60, interval=1)
result = await cap.async_exec("echo", args=["async_hello"])
assert result.exit_code == 0
assert "async_hello" in result.stdout
finally:
await sb.async_destroy()
await cap.async_destroy()
@pytest.mark.asyncio
async def test_async_upload_download(self, async_client):
async with async_client:
sb = await async_client.sandboxes.create(
cap = await async_client.capsules.create(
template="minimal", timeout_sec=120
)
try:
await sb.async_wait_ready(timeout=60, interval=1)
await cap.async_wait_ready(timeout=60, interval=1)
content = b"Async upload test"
await sb.async_upload("/tmp/async_test.txt", content)
downloaded = await sb.async_download("/tmp/async_test.txt")
await cap.async_upload("/tmp/async_test.txt", content)
downloaded = await cap.async_download("/tmp/async_test.txt")
assert downloaded == content
finally:
await sb.async_destroy()
await cap.async_destroy()
@pytest.mark.asyncio
async def test_async_run_code(self, async_client):
async with async_client:
sb = await async_client.sandboxes.create(
cap = await async_client.capsules.create(
template="python-interpreter-v0-beta", timeout_sec=120
)
try:
await sb.async_wait_ready(timeout=60, interval=1)
r = await sb.async_run_code("42 * 2")
await cap.async_wait_ready(timeout=60, interval=1)
r = await cap.async_run_code("42 * 2")
assert r.text == "84"
finally:
await sb.async_destroy()
await cap.async_destroy()
@requires_auth
class TestFilesystemListDir:
def test_list_dir_root(self, client: WrennClient):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
sb.mkdir("/tmp/ls_test_root")
sb.upload("/tmp/ls_test_root/hello.txt", b"hello")
entries = sb.list_dir("/tmp/ls_test_root")
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
cap.mkdir("/tmp/ls_test_root")
cap.upload("/tmp/ls_test_root/hello.txt", b"hello")
entries = cap.list_dir("/tmp/ls_test_root")
assert isinstance(entries, list)
names = [e.name for e in entries]
assert "hello.txt" in names
def test_list_dir_after_mkdir(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
sb.mkdir("/tmp/fs_test_dir")
entries = sb.list_dir("/tmp")
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
cap.mkdir("/tmp/fs_test_dir")
entries = cap.list_dir("/tmp")
names = [e.name for e in entries]
assert "fs_test_dir" in names
def test_list_dir_file_metadata(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
sb.upload("/tmp/meta_test.txt", b"hello world")
entries = sb.list_dir("/tmp")
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
cap.upload("/tmp/meta_test.txt", b"hello world")
entries = cap.list_dir("/tmp")
match = [e for e in entries if e.name == "meta_test.txt"]
assert len(match) == 1
f = match[0]
@ -326,100 +326,100 @@ class TestFilesystemListDir:
assert f.modified_at is not None
def test_list_dir_depth(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
sb.mkdir("/tmp/depth_a/depth_b")
sb.upload("/tmp/depth_a/depth_b/nested.txt", b"deep")
entries = sb.list_dir("/tmp/depth_a", depth=2)
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
cap.mkdir("/tmp/depth_a/depth_b")
cap.upload("/tmp/depth_a/depth_b/nested.txt", b"deep")
entries = cap.list_dir("/tmp/depth_a", depth=2)
paths = [e.path for e in entries]
assert any("nested.txt" in p for p in paths)
def test_list_dir_empty_directory(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
sb.mkdir("/tmp/empty_dir_test")
entries = sb.list_dir("/tmp/empty_dir_test")
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
cap.mkdir("/tmp/empty_dir_test")
entries = cap.list_dir("/tmp/empty_dir_test")
assert entries == []
@requires_auth
class TestFilesystemMkdir:
def test_mkdir_creates_directory(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
entry = sb.mkdir("/tmp/mkdir_test")
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
entry = cap.mkdir("/tmp/mkdir_test")
assert entry.name == "mkdir_test"
assert entry.type == "directory"
assert entry.path == "/tmp/mkdir_test"
def test_mkdir_creates_parents(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
entry = sb.mkdir("/tmp/a/b/c/d")
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
entry = cap.mkdir("/tmp/a/b/c/d")
assert entry.type == "directory"
def test_mkdir_already_exists(self, client: WrennClient):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
sb.mkdir("/tmp/exist_test")
entry = sb.mkdir("/tmp/exist_test")
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
cap.mkdir("/tmp/exist_test")
entry = cap.mkdir("/tmp/exist_test")
assert entry.type == "directory"
@requires_auth
class TestFilesystemRemove:
def test_remove_file(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
sb.upload("/tmp/rm_test.txt", b"delete me")
entries_before = sb.list_dir("/tmp")
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
cap.upload("/tmp/rm_test.txt", b"delete me")
entries_before = cap.list_dir("/tmp")
assert any(e.name == "rm_test.txt" for e in entries_before)
sb.remove("/tmp/rm_test.txt")
entries_after = sb.list_dir("/tmp")
cap.remove("/tmp/rm_test.txt")
entries_after = cap.list_dir("/tmp")
assert not any(e.name == "rm_test.txt" for e in entries_after)
def test_remove_directory(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
sb.mkdir("/tmp/rm_dir_test")
sb.upload("/tmp/rm_dir_test/file.txt", b"inside")
sb.remove("/tmp/rm_dir_test")
entries = sb.list_dir("/tmp")
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
cap.mkdir("/tmp/rm_dir_test")
cap.upload("/tmp/rm_dir_test/file.txt", b"inside")
cap.remove("/tmp/rm_dir_test")
entries = cap.list_dir("/tmp")
assert not any(e.name == "rm_dir_test" for e in entries)
def test_upload_download_remove_roundtrip(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
content = b"round trip test data " * 100
sb.upload("/tmp/rt.txt", content)
downloaded = sb.download("/tmp/rt.txt")
cap.upload("/tmp/rt.txt", content)
downloaded = cap.download("/tmp/rt.txt")
assert downloaded == content
sb.remove("/tmp/rt.txt")
cap.remove("/tmp/rt.txt")
with pytest.raises(Exception):
sb.download("/tmp/rt.txt")
cap.download("/tmp/rt.txt")
@requires_auth
class TestStreamUploadDownload:
def test_stream_upload_and_download(self, client: WrennClient):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
chunks = [b"chunk0_", b"chunk1_", b"chunk2"]
def data_gen():
yield from chunks
sb.stream_upload("/tmp/stream_test.bin", data_gen())
downloaded = sb.download("/tmp/stream_test.bin")
cap.stream_upload("/tmp/stream_test.bin", data_gen())
downloaded = cap.download("/tmp/stream_test.bin")
assert downloaded == b"chunk0_chunk1_chunk2"
def test_stream_download_large(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
content = b"x" * 65536 * 3
sb.upload("/tmp/large.bin", content)
cap.upload("/tmp/large.bin", content)
collected = b""
for chunk in sb.stream_download("/tmp/large.bin"):
for chunk in cap.stream_download("/tmp/large.bin"):
collected += chunk
assert collected == content
@ -427,9 +427,9 @@ class TestStreamUploadDownload:
@requires_auth
class TestPty:
def test_pty_basic_output(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
with sb.pty(cmd="/bin/sh", cwd="/tmp") as term:
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
with cap.pty(cmd="/bin/sh", cwd="/tmp") as term:
term.write(b"echo pty_hello\n")
output = b""
for event in term:
@ -442,9 +442,9 @@ class TestPty:
assert b"pty_hello" in output
def test_pty_tag_and_pid(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
with sb.pty(cmd="/bin/sh") as term:
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
with cap.pty(cmd="/bin/sh") as term:
started = False
for event in term:
if event.type == PtyEventType.started:
@ -459,18 +459,18 @@ class TestPty:
assert started
def test_pty_exit_on_command_exit(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
with sb.pty(cmd="/bin/echo", args=["immediate"]) as term:
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
with cap.pty(cmd="/bin/echo", args=["immediate"]) as term:
events = list(term)
types = [e.type for e in events]
assert PtyEventType.started in types
assert PtyEventType.output in types or PtyEventType.exit in types
def test_pty_resize(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
with sb.pty(cmd="/bin/sh", cols=80, rows=24) as term:
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
with cap.pty(cmd="/bin/sh", cols=80, rows=24) as term:
for event in term:
if event.type == PtyEventType.started:
term.resize(120, 40)
@ -479,9 +479,9 @@ class TestPty:
break
def test_pty_envs(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
sb.wait_ready(timeout=60, interval=1)
with sb.pty(cmd="/bin/sh", envs={"MY_VAR": "hello_env"}) as term:
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1)
with cap.pty(cmd="/bin/sh", envs={"MY_VAR": "hello_env"}) as term:
output = b""
for event in term:
if event.type == PtyEventType.started:
@ -500,69 +500,69 @@ class TestAsyncFilesystem:
@pytest.mark.asyncio
async def test_async_list_dir(self, async_client):
async with async_client:
sb = await async_client.sandboxes.create(
cap = await async_client.capsules.create(
template="minimal", timeout_sec=120
)
try:
await sb.async_wait_ready(timeout=60, interval=1)
await sb.async_mkdir("/tmp/async_ls_test")
await sb.async_upload("/tmp/async_ls_test/file.txt", b"data")
entries = await sb.async_list_dir("/tmp/async_ls_test")
await cap.async_wait_ready(timeout=60, interval=1)
await cap.async_mkdir("/tmp/async_ls_test")
await cap.async_upload("/tmp/async_ls_test/file.txt", b"data")
entries = await cap.async_list_dir("/tmp/async_ls_test")
assert isinstance(entries, list)
assert any(e.name == "file.txt" for e in entries)
finally:
await sb.async_destroy()
await cap.async_destroy()
@pytest.mark.asyncio
async def test_async_mkdir(self, async_client):
async with async_client:
sb = await async_client.sandboxes.create(
cap = await async_client.capsules.create(
template="minimal", timeout_sec=120
)
try:
await sb.async_wait_ready(timeout=60, interval=1)
entry = await sb.async_mkdir("/tmp/async_mkdir_test")
await cap.async_wait_ready(timeout=60, interval=1)
entry = await cap.async_mkdir("/tmp/async_mkdir_test")
assert entry.type == "directory"
assert entry.name == "async_mkdir_test"
finally:
await sb.async_destroy()
await cap.async_destroy()
@pytest.mark.asyncio
async def test_async_remove(self, async_client):
async with async_client:
sb = await async_client.sandboxes.create(
cap = await async_client.capsules.create(
template="minimal", timeout_sec=120
)
try:
await sb.async_wait_ready(timeout=60, interval=1)
await sb.async_upload("/tmp/async_rm.txt", b"bye")
entries = await sb.async_list_dir("/tmp")
await cap.async_wait_ready(timeout=60, interval=1)
await cap.async_upload("/tmp/async_rm.txt", b"bye")
entries = await cap.async_list_dir("/tmp")
assert any(e.name == "async_rm.txt" for e in entries)
await sb.async_remove("/tmp/async_rm.txt")
entries = await sb.async_list_dir("/tmp")
await cap.async_remove("/tmp/async_rm.txt")
entries = await cap.async_list_dir("/tmp")
assert not any(e.name == "async_rm.txt" for e in entries)
finally:
await sb.async_destroy()
await cap.async_destroy()
@pytest.mark.asyncio
async def test_async_full_filesystem_roundtrip(self, async_client):
async with async_client:
sb = await async_client.sandboxes.create(
cap = await async_client.capsules.create(
template="minimal", timeout_sec=120
)
try:
await sb.async_wait_ready(timeout=60, interval=1)
await cap.async_wait_ready(timeout=60, interval=1)
await sb.async_mkdir("/tmp/async_rt")
await sb.async_upload("/tmp/async_rt/file.txt", b"async content")
entries = await sb.async_list_dir("/tmp/async_rt")
await cap.async_mkdir("/tmp/async_rt")
await cap.async_upload("/tmp/async_rt/file.txt", b"async content")
entries = await cap.async_list_dir("/tmp/async_rt")
assert any(e.name == "file.txt" for e in entries)
data = await sb.async_download("/tmp/async_rt/file.txt")
data = await cap.async_download("/tmp/async_rt/file.txt")
assert data == b"async content"
await sb.async_remove("/tmp/async_rt/file.txt")
entries = await sb.async_list_dir("/tmp/async_rt")
await cap.async_remove("/tmp/async_rt/file.txt")
entries = await cap.async_list_dir("/tmp/async_rt")
assert not any(e.name == "file.txt" for e in entries)
finally:
await sb.async_destroy()
await cap.async_destroy()