feat: redesign SDK with e2b-compatible interface
Replace the WrennClient-centric API with a top-level Capsule class that mirrors e2b's Sandbox interface, enabling drop-in migration. Key changes: - Capsule/AsyncCapsule with direct construction (reads WRENN_API_KEY and WRENN_BASE_URL env vars), namespaced sub-objects (capsule.commands, capsule.files), dual instance/static lifecycle methods via _DualMethod descriptor (capsule.kill() and Capsule.kill(id)) - WrennClient simplified to API-key-only endpoints (capsules, snapshots); JWT-based resources (auth, hosts, teams) removed - wrenn.code_interpreter submodule with Capsule subclass defaulting to code-runner-beta template and run_code() support - Sandbox alias emits FutureWarning instead of DeprecationWarning Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -3,20 +3,16 @@ from __future__ import annotations
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from wrenn.capsule import Capsule, CodeResult, _build_proxy_url
|
||||
from wrenn.client import WrennClient
|
||||
from wrenn.capsule import Capsule, _build_proxy_url
|
||||
from wrenn.code_interpreter.capsule import CodeResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
||||
yield c
|
||||
BASE = "https://app.wrenn.dev/api"
|
||||
|
||||
|
||||
class TestBuildProxyUrl:
|
||||
def test_https_production(self):
|
||||
url = _build_proxy_url("https://api.wrenn.dev", "cl-abc123", 8888)
|
||||
assert url == "wss://8888-cl-abc123.api.wrenn.dev"
|
||||
url = _build_proxy_url("https://app.wrenn.dev/api", "cl-abc123", 8888)
|
||||
assert url == "wss://8888-cl-abc123.app.wrenn.dev"
|
||||
|
||||
def test_http_localhost(self):
|
||||
url = _build_proxy_url("http://localhost:8080", "cl-abc123", 3000)
|
||||
@ -31,92 +27,98 @@ class TestBuildProxyUrl:
|
||||
assert url == "ws://5000-sb-2.192.168.1.1"
|
||||
|
||||
|
||||
class TestCapsuleGetUrl:
|
||||
class TestCapsuleCreate:
|
||||
@respx.mock
|
||||
def test_get_url_returns_proxy_url(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
201, json={"id": "cl-abc", "status": "pending"}
|
||||
)
|
||||
cap = client.capsules.create(template="minimal")
|
||||
url = cap.get_url(8888)
|
||||
assert url == "wss://8888-cl-abc.api.wrenn.dev"
|
||||
|
||||
@respx.mock
|
||||
def test_get_url_localhost(self):
|
||||
with WrennClient(
|
||||
api_key="wrn_test1234567890abcdef12345678",
|
||||
base_url="http://localhost:8080",
|
||||
) as c:
|
||||
respx.post("http://localhost:8080/v1/capsules").respond(
|
||||
201, json={"id": "cl-xyz", "status": "pending"}
|
||||
)
|
||||
cap = c.capsules.create()
|
||||
url = cap.get_url(3000)
|
||||
assert url == "ws://3000-cl-xyz.localhost:8080"
|
||||
|
||||
|
||||
class TestCapsuleHttpClient:
|
||||
@respx.mock
|
||||
def test_http_client_has_api_key_header(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
201, json={"id": "cl-abc", "status": "pending"}
|
||||
)
|
||||
cap = client.capsules.create()
|
||||
hc = cap.http_client
|
||||
assert hc.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
|
||||
|
||||
@respx.mock
|
||||
def test_http_client_sends_to_proxy(self, client):
|
||||
route = respx.get("https://8888-cl-abc.api.wrenn.dev/api/kernels").respond(
|
||||
200, json=[]
|
||||
)
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
201, json={"id": "cl-abc", "status": "pending"}
|
||||
)
|
||||
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:
|
||||
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:
|
||||
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 TestCreateReturnsBoundCapsule:
|
||||
@respx.mock
|
||||
def test_create_returns_capsule_subclass(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
def test_capsule_constructor_creates(self):
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201, json={"id": "cl-1", "status": "pending", "template": "minimal"}
|
||||
)
|
||||
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")
|
||||
cap = Capsule(template="minimal", api_key="wrn_test1234567890abcdef12345678")
|
||||
assert cap.capsule_id == "cl-1"
|
||||
assert hasattr(cap, "commands")
|
||||
assert hasattr(cap, "files")
|
||||
|
||||
@respx.mock
|
||||
def test_create_context_manager(self, client):
|
||||
route = respx.delete("https://api.wrenn.dev/v1/capsules/cl-1").respond(204)
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
def test_capsule_create_classmethod(self):
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201, json={"id": "cl-2", "status": "pending"}
|
||||
)
|
||||
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678")
|
||||
assert cap.capsule_id == "cl-2"
|
||||
|
||||
@respx.mock
|
||||
def test_capsule_context_manager_kills(self):
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201, json={"id": "cl-1", "status": "pending"}
|
||||
)
|
||||
cap = client.capsules.create()
|
||||
with cap:
|
||||
assert cap.id == "cl-1"
|
||||
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
|
||||
with Capsule(api_key="wrn_test1234567890abcdef12345678") as cap:
|
||||
assert cap.capsule_id == "cl-1"
|
||||
assert kill_route.called
|
||||
|
||||
@respx.mock
|
||||
def test_capsule_env_var(self, monkeypatch):
|
||||
monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env_key")
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201, json={"id": "cl-3", "status": "pending"}
|
||||
)
|
||||
cap = Capsule()
|
||||
assert cap.capsule_id == "cl-3"
|
||||
|
||||
|
||||
class TestCapsuleStaticMethods:
|
||||
@respx.mock
|
||||
def test_static_kill(self):
|
||||
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
|
||||
Capsule._static_kill("cl-1", api_key="wrn_test1234567890abcdef12345678")
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
def test_static_pause(self):
|
||||
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
|
||||
200, json={"id": "cl-1", "status": "paused"}
|
||||
)
|
||||
info = Capsule._static_pause("cl-1", api_key="wrn_test1234567890abcdef12345678")
|
||||
assert info.status.value == "paused"
|
||||
|
||||
@respx.mock
|
||||
def test_static_list(self):
|
||||
respx.get(f"{BASE}/v1/capsules").respond(
|
||||
200, json=[{"id": "cl-1", "status": "running"}]
|
||||
)
|
||||
items = Capsule.list(api_key="wrn_test1234567890abcdef12345678")
|
||||
assert len(items) == 1
|
||||
assert items[0].id == "cl-1"
|
||||
|
||||
@respx.mock
|
||||
def test_static_get_info(self):
|
||||
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
||||
200, json={"id": "cl-1", "status": "running"}
|
||||
)
|
||||
info = Capsule._static_get_info("cl-1", api_key="wrn_test1234567890abcdef12345678")
|
||||
assert info.id == "cl-1"
|
||||
|
||||
|
||||
class TestCapsuleConnect:
|
||||
@respx.mock
|
||||
def test_connect_running(self):
|
||||
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
||||
200, json={"id": "cl-1", "status": "running"}
|
||||
)
|
||||
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678")
|
||||
assert cap.capsule_id == "cl-1"
|
||||
|
||||
@respx.mock
|
||||
def test_connect_paused_resumes(self):
|
||||
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
||||
200, json={"id": "cl-1", "status": "paused"}
|
||||
)
|
||||
respx.post(f"{BASE}/v1/capsules/cl-1/resume").respond(
|
||||
200, json={"id": "cl-1", "status": "running"}
|
||||
)
|
||||
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678")
|
||||
assert cap.capsule_id == "cl-1"
|
||||
|
||||
|
||||
class TestCodeResult:
|
||||
def test_defaults(self):
|
||||
@ -144,57 +146,21 @@ class TestCodeResult:
|
||||
assert "ZeroDivisionError" in r.error
|
||||
|
||||
|
||||
class TestJupyterMessageFormat:
|
||||
def test_execute_request_structure(self):
|
||||
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
|
||||
assert "msg_id" in msg
|
||||
assert "header" in msg
|
||||
assert msg["header"]["msg_type"] == "execute_request"
|
||||
|
||||
def test_execute_request_unique_ids(self):
|
||||
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 importlib
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
# Clear cached attribute
|
||||
if "Sandbox" in dir(sys.modules.get("wrenn", object())):
|
||||
delattr(sys.modules["wrenn"], "Sandbox")
|
||||
|
||||
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)
|
||||
fw = [x for x in w if issubclass(x.category, FutureWarning)]
|
||||
assert len(fw) >= 1
|
||||
assert "Sandbox" in str(fw[0].message)
|
||||
|
||||
@ -8,22 +8,18 @@ from wrenn.exceptions import (
|
||||
WrennAgentError,
|
||||
WrennAuthenticationError,
|
||||
WrennConflictError,
|
||||
WrennForbiddenError,
|
||||
WrennHostHasCapsulesError,
|
||||
WrennInternalError,
|
||||
WrennNotFoundError,
|
||||
WrennValidationError,
|
||||
)
|
||||
from wrenn.models import (
|
||||
APIKeyResponse,
|
||||
AuthResponse,
|
||||
Capsule,
|
||||
CreateHostResponse,
|
||||
Host,
|
||||
Status,
|
||||
Template,
|
||||
)
|
||||
|
||||
BASE = "https://app.wrenn.dev/api"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
@ -36,71 +32,10 @@ def async_client():
|
||||
return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678")
|
||||
|
||||
|
||||
class TestAuth:
|
||||
@respx.mock
|
||||
def test_signup(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/auth/signup").respond(
|
||||
201,
|
||||
json={
|
||||
"token": "jwt-token",
|
||||
"user_id": "u-1",
|
||||
"team_id": "t-1",
|
||||
"email": "a@b.com",
|
||||
},
|
||||
)
|
||||
resp = client.auth.signup("a@b.com", "password123")
|
||||
assert isinstance(resp, AuthResponse)
|
||||
assert resp.token == "jwt-token"
|
||||
assert resp.user_id == "u-1"
|
||||
|
||||
@respx.mock
|
||||
def test_login(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/auth/login").respond(
|
||||
200,
|
||||
json={"token": "jwt-token", "email": "a@b.com"},
|
||||
)
|
||||
resp = client.auth.login("a@b.com", "password123")
|
||||
assert resp.token == "jwt-token"
|
||||
|
||||
|
||||
class TestAPIKeys:
|
||||
@respx.mock
|
||||
def test_create(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/api-keys").respond(
|
||||
201,
|
||||
json={
|
||||
"id": "key-1",
|
||||
"name": "my-key",
|
||||
"key_prefix": "wrn_ab12cd34",
|
||||
"key": "wrn_ab12cd34fullkey",
|
||||
},
|
||||
)
|
||||
resp = client.api_keys.create(name="my-key")
|
||||
assert isinstance(resp, APIKeyResponse)
|
||||
assert resp.name == "my-key"
|
||||
assert resp.key == "wrn_ab12cd34fullkey"
|
||||
|
||||
@respx.mock
|
||||
def test_list(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/api-keys").respond(
|
||||
200,
|
||||
json=[{"id": "key-1", "name": "k1"}, {"id": "key-2", "name": "k2"}],
|
||||
)
|
||||
keys = client.api_keys.list()
|
||||
assert len(keys) == 2
|
||||
assert keys[0].id == "key-1"
|
||||
|
||||
@respx.mock
|
||||
def test_delete(self, client):
|
||||
route = respx.delete("https://api.wrenn.dev/v1/api-keys/key-1").respond(204)
|
||||
client.api_keys.delete("key-1")
|
||||
assert route.called
|
||||
|
||||
|
||||
class TestCapsules:
|
||||
@respx.mock
|
||||
def test_create(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201,
|
||||
json={
|
||||
"id": "sb-1",
|
||||
@ -117,7 +52,7 @@ class TestCapsules:
|
||||
|
||||
@respx.mock
|
||||
def test_create_defaults(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201, json={"id": "sb-2", "status": "pending"}
|
||||
)
|
||||
resp = client.capsules.create()
|
||||
@ -125,7 +60,7 @@ class TestCapsules:
|
||||
|
||||
@respx.mock
|
||||
def test_list(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/capsules").respond(
|
||||
respx.get(f"{BASE}/v1/capsules").respond(
|
||||
200, json=[{"id": "sb-1", "status": "running"}]
|
||||
)
|
||||
boxes = client.capsules.list()
|
||||
@ -134,7 +69,7 @@ class TestCapsules:
|
||||
|
||||
@respx.mock
|
||||
def test_get(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond(
|
||||
respx.get(f"{BASE}/v1/capsules/sb-1").respond(
|
||||
200, json={"id": "sb-1", "status": "running"}
|
||||
)
|
||||
resp = client.capsules.get("sb-1")
|
||||
@ -142,15 +77,37 @@ class TestCapsules:
|
||||
|
||||
@respx.mock
|
||||
def test_destroy(self, client):
|
||||
route = respx.delete("https://api.wrenn.dev/v1/capsules/sb-1").respond(204)
|
||||
route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(204)
|
||||
client.capsules.destroy("sb-1")
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
def test_pause(self, client):
|
||||
respx.post(f"{BASE}/v1/capsules/sb-1/pause").respond(
|
||||
200, json={"id": "sb-1", "status": "paused"}
|
||||
)
|
||||
resp = client.capsules.pause("sb-1")
|
||||
assert resp.status == Status.paused
|
||||
|
||||
@respx.mock
|
||||
def test_resume(self, client):
|
||||
respx.post(f"{BASE}/v1/capsules/sb-1/resume").respond(
|
||||
200, json={"id": "sb-1", "status": "running"}
|
||||
)
|
||||
resp = client.capsules.resume("sb-1")
|
||||
assert resp.status == Status.running
|
||||
|
||||
@respx.mock
|
||||
def test_ping(self, client):
|
||||
route = respx.post(f"{BASE}/v1/capsules/sb-1/ping").respond(204)
|
||||
client.capsules.ping("sb-1")
|
||||
assert route.called
|
||||
|
||||
|
||||
class TestSnapshots:
|
||||
@respx.mock
|
||||
def test_create(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/snapshots").respond(
|
||||
respx.post(f"{BASE}/v1/snapshots").respond(
|
||||
201,
|
||||
json={"name": "snap-1", "type": "snapshot", "vcpus": 1},
|
||||
)
|
||||
@ -160,7 +117,7 @@ class TestSnapshots:
|
||||
|
||||
@respx.mock
|
||||
def test_create_with_overwrite(self, client):
|
||||
route = respx.post("https://api.wrenn.dev/v1/snapshots").respond(
|
||||
route = respx.post(f"{BASE}/v1/snapshots").respond(
|
||||
201, json={"name": "snap-1", "type": "snapshot"}
|
||||
)
|
||||
client.snapshots.create(capsule_id="sb-1", overwrite=True)
|
||||
@ -169,7 +126,7 @@ class TestSnapshots:
|
||||
|
||||
@respx.mock
|
||||
def test_list(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/snapshots").respond(
|
||||
respx.get(f"{BASE}/v1/snapshots").respond(
|
||||
200, json=[{"name": "base-python", "type": "base"}]
|
||||
)
|
||||
snaps = client.snapshots.list()
|
||||
@ -177,92 +134,22 @@ class TestSnapshots:
|
||||
|
||||
@respx.mock
|
||||
def test_list_with_filter(self, client):
|
||||
route = respx.get("https://api.wrenn.dev/v1/snapshots").respond(200, json=[])
|
||||
route = respx.get(f"{BASE}/v1/snapshots").respond(200, json=[])
|
||||
client.snapshots.list(type="snapshot")
|
||||
req = route.calls[0].request
|
||||
assert "type=snapshot" in str(req.url)
|
||||
|
||||
@respx.mock
|
||||
def test_delete(self, client):
|
||||
route = respx.delete("https://api.wrenn.dev/v1/snapshots/snap-1").respond(204)
|
||||
route = respx.delete(f"{BASE}/v1/snapshots/snap-1").respond(204)
|
||||
client.snapshots.delete("snap-1")
|
||||
assert route.called
|
||||
|
||||
|
||||
class TestHosts:
|
||||
@respx.mock
|
||||
def test_create(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/hosts").respond(
|
||||
201,
|
||||
json={
|
||||
"host": {"id": "h-1", "type": "regular", "status": "pending"},
|
||||
"registration_token": "reg-tok-123",
|
||||
},
|
||||
)
|
||||
resp = client.hosts.create(type="regular")
|
||||
assert isinstance(resp, CreateHostResponse)
|
||||
assert resp.registration_token == "reg-tok-123"
|
||||
|
||||
@respx.mock
|
||||
def test_list(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/hosts").respond(
|
||||
200, json=[{"id": "h-1", "status": "online"}]
|
||||
)
|
||||
hosts = client.hosts.list()
|
||||
assert len(hosts) == 1
|
||||
assert isinstance(hosts[0], Host)
|
||||
|
||||
@respx.mock
|
||||
def test_get(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/hosts/h-1").respond(
|
||||
200, json={"id": "h-1", "status": "online"}
|
||||
)
|
||||
resp = client.hosts.get("h-1")
|
||||
assert resp.id == "h-1"
|
||||
|
||||
@respx.mock
|
||||
def test_delete(self, client):
|
||||
route = respx.delete("https://api.wrenn.dev/v1/hosts/h-1").respond(204)
|
||||
client.hosts.delete("h-1")
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
def test_regenerate_token(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/hosts/h-1/token").respond(
|
||||
201,
|
||||
json={
|
||||
"host": {"id": "h-1"},
|
||||
"registration_token": "new-tok",
|
||||
},
|
||||
)
|
||||
resp = client.hosts.regenerate_token("h-1")
|
||||
assert resp.registration_token == "new-tok"
|
||||
|
||||
@respx.mock
|
||||
def test_list_tags(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/hosts/h-1/tags").respond(
|
||||
200, json=["gpu", "high-mem"]
|
||||
)
|
||||
tags = client.hosts.list_tags("h-1")
|
||||
assert tags == ["gpu", "high-mem"]
|
||||
|
||||
@respx.mock
|
||||
def test_add_tag(self, client):
|
||||
route = respx.post("https://api.wrenn.dev/v1/hosts/h-1/tags").respond(204)
|
||||
client.hosts.add_tag("h-1", "gpu")
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
def test_remove_tag(self, client):
|
||||
route = respx.delete("https://api.wrenn.dev/v1/hosts/h-1/tags/gpu").respond(204)
|
||||
client.hosts.remove_tag("h-1", "gpu")
|
||||
assert route.called
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
@respx.mock
|
||||
def test_validation_error(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
400,
|
||||
json={"error": {"code": "invalid_request", "message": "bad input"}},
|
||||
)
|
||||
@ -273,25 +160,16 @@ class TestErrorHandling:
|
||||
|
||||
@respx.mock
|
||||
def test_auth_error(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/capsules").respond(
|
||||
respx.get(f"{BASE}/v1/capsules").respond(
|
||||
401,
|
||||
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
||||
)
|
||||
with pytest.raises(WrennAuthenticationError):
|
||||
client.capsules.list()
|
||||
|
||||
@respx.mock
|
||||
def test_forbidden_error(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/hosts").respond(
|
||||
403,
|
||||
json={"error": {"code": "forbidden", "message": "nope"}},
|
||||
)
|
||||
with pytest.raises(WrennForbiddenError):
|
||||
client.hosts.create(type="regular")
|
||||
|
||||
@respx.mock
|
||||
def test_not_found_error(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/capsules/nope").respond(
|
||||
respx.get(f"{BASE}/v1/capsules/nope").respond(
|
||||
404,
|
||||
json={"error": {"code": "not_found", "message": "capsule not found"}},
|
||||
)
|
||||
@ -300,32 +178,16 @@ class TestErrorHandling:
|
||||
|
||||
@respx.mock
|
||||
def test_conflict_error(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond(
|
||||
respx.get(f"{BASE}/v1/capsules/sb-1").respond(
|
||||
409,
|
||||
json={"error": {"code": "invalid_state", "message": "not running"}},
|
||||
)
|
||||
with pytest.raises(WrennConflictError):
|
||||
client.capsules.get("sb-1")
|
||||
|
||||
@respx.mock
|
||||
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_capsules",
|
||||
"message": "host has running capsules",
|
||||
},
|
||||
"sandbox_ids": ["sb-1", "sb-2"],
|
||||
},
|
||||
)
|
||||
with pytest.raises(WrennHostHasCapsulesError) as exc_info:
|
||||
client.hosts.delete("h-1")
|
||||
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/capsules").respond(
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
502,
|
||||
json={"error": {"code": "agent_error", "message": "host agent failed"}},
|
||||
)
|
||||
@ -334,7 +196,7 @@ class TestErrorHandling:
|
||||
|
||||
@respx.mock
|
||||
def test_internal_error(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond(
|
||||
respx.get(f"{BASE}/v1/capsules/sb-1").respond(
|
||||
500,
|
||||
json={"error": {"code": "internal_error", "message": "oops"}},
|
||||
)
|
||||
@ -343,7 +205,7 @@ class TestErrorHandling:
|
||||
|
||||
@respx.mock
|
||||
def test_unknown_error_code_falls_back(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond(
|
||||
respx.get(f"{BASE}/v1/capsules/sb-1").respond(
|
||||
418,
|
||||
json={"error": {"code": "teapot", "message": "I'm a teapot"}},
|
||||
)
|
||||
@ -359,21 +221,14 @@ class TestAuthModes:
|
||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
||||
assert c._http.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
|
||||
|
||||
def test_token_header(self):
|
||||
with WrennClient(token="jwt-token-abc") as c:
|
||||
assert c._http.headers["Authorization"] == "Bearer jwt-token-abc"
|
||||
|
||||
def test_no_auth_raises(self):
|
||||
with pytest.raises(ValueError, match="Either api_key or token"):
|
||||
with pytest.raises(ValueError, match="No API key"):
|
||||
WrennClient()
|
||||
|
||||
@respx.mock
|
||||
def test_jwt_auth_on_api_keys(self):
|
||||
route = respx.get("https://api.wrenn.dev/v1/api-keys").respond(200, json=[])
|
||||
with WrennClient(token="jwt-abc") as c:
|
||||
c.api_keys.list()
|
||||
req = route.calls[0].request
|
||||
assert req.headers["Authorization"] == "Bearer jwt-abc"
|
||||
def test_env_var_fallback(self, monkeypatch):
|
||||
monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env")
|
||||
with WrennClient() as c:
|
||||
assert c._http.headers["X-API-Key"] == "wrn_from_env"
|
||||
|
||||
|
||||
class TestAsyncClient:
|
||||
@ -381,7 +236,7 @@ class TestAsyncClient:
|
||||
@respx.mock
|
||||
async def test_async_capsules_create(self, async_client):
|
||||
async with async_client:
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201, json={"id": "sb-1", "status": "pending"}
|
||||
)
|
||||
resp = await async_client.capsules.create(template="base-python")
|
||||
@ -391,25 +246,17 @@ class TestAsyncClient:
|
||||
@respx.mock
|
||||
async def test_async_capsules_list(self, async_client):
|
||||
async with async_client:
|
||||
respx.get("https://api.wrenn.dev/v1/capsules").respond(
|
||||
respx.get(f"{BASE}/v1/capsules").respond(
|
||||
200, json=[{"id": "sb-1"}]
|
||||
)
|
||||
boxes = await async_client.capsules.list()
|
||||
assert len(boxes) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_hosts_list(self, async_client):
|
||||
async with async_client:
|
||||
respx.get("https://api.wrenn.dev/v1/hosts").respond(200, json=[])
|
||||
hosts = await async_client.hosts.list()
|
||||
assert hosts == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_error_handling(self, async_client):
|
||||
async with async_client:
|
||||
respx.get("https://api.wrenn.dev/v1/capsules/nope").respond(
|
||||
respx.get(f"{BASE}/v1/capsules/nope").respond(
|
||||
404,
|
||||
json={"error": {"code": "not_found", "message": "not found"}},
|
||||
)
|
||||
|
||||
@ -8,7 +8,6 @@ import pytest
|
||||
import respx
|
||||
|
||||
from wrenn.capsule import Capsule
|
||||
from wrenn.client import WrennClient
|
||||
from wrenn.models import FileEntry
|
||||
from wrenn.pty import (
|
||||
AsyncPtySession,
|
||||
@ -17,25 +16,59 @@ from wrenn.pty import (
|
||||
_parse_pty_event,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
||||
yield c
|
||||
BASE = "https://app.wrenn.dev/api"
|
||||
|
||||
|
||||
def _make_capsule(client: WrennClient, cap_id: str = "cl-abc") -> Capsule:
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
def _make_capsule(cap_id: str = "cl-abc") -> Capsule:
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201, json={"id": cap_id, "status": "running"}
|
||||
)
|
||||
return client.capsules.create()
|
||||
return Capsule(api_key="wrn_test1234567890abcdef12345678")
|
||||
|
||||
|
||||
class TestListDir:
|
||||
class TestFilesRead:
|
||||
@respx.mock
|
||||
def test_list_dir_returns_entries(self, client):
|
||||
cap = _make_capsule(client)
|
||||
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond(
|
||||
def test_read_returns_string(self):
|
||||
cap = _make_capsule()
|
||||
content = b"file contents here"
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/read").respond(
|
||||
200, content=content
|
||||
)
|
||||
data = cap.files.read("/app/main.py")
|
||||
assert data == "file contents here"
|
||||
|
||||
@respx.mock
|
||||
def test_read_bytes(self):
|
||||
cap = _make_capsule()
|
||||
content = b"\x00\x01\x02"
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/read").respond(
|
||||
200, content=content
|
||||
)
|
||||
data = cap.files.read_bytes("/bin/binary")
|
||||
assert data == b"\x00\x01\x02"
|
||||
|
||||
|
||||
class TestFilesWrite:
|
||||
@respx.mock
|
||||
def test_write_string(self):
|
||||
cap = _make_capsule()
|
||||
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/write").respond(204)
|
||||
cap.files.write("/app/main.py", "print('hello')")
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
def test_write_bytes(self):
|
||||
cap = _make_capsule()
|
||||
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/write").respond(204)
|
||||
cap.files.write("/app/data.bin", b"\x00\x01\x02")
|
||||
assert route.called
|
||||
|
||||
|
||||
class TestFilesList:
|
||||
@respx.mock
|
||||
def test_list_returns_entries(self):
|
||||
cap = _make_capsule()
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||
200,
|
||||
json={
|
||||
"entries": [
|
||||
@ -66,7 +99,7 @@ class TestListDir:
|
||||
]
|
||||
},
|
||||
)
|
||||
entries = cap.list_dir("/home/user")
|
||||
entries = cap.files.list("/home/user")
|
||||
assert len(entries) == 2
|
||||
assert isinstance(entries[0], FileEntry)
|
||||
assert entries[0].name == "main.py"
|
||||
@ -75,57 +108,30 @@ class TestListDir:
|
||||
assert entries[1].type == "directory"
|
||||
|
||||
@respx.mock
|
||||
def test_list_dir_with_depth(self, client):
|
||||
cap = _make_capsule(client)
|
||||
route = respx.post(
|
||||
"https://api.wrenn.dev/v1/capsules/cl-abc/files/list"
|
||||
).respond(200, json={"entries": []})
|
||||
cap.list_dir("/home/user", depth=3)
|
||||
def test_list_with_depth(self):
|
||||
cap = _make_capsule()
|
||||
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||
200, json={"entries": []}
|
||||
)
|
||||
cap.files.list("/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):
|
||||
cap = _make_capsule(client)
|
||||
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond(
|
||||
def test_list_empty(self):
|
||||
cap = _make_capsule()
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||
200, json={"entries": []}
|
||||
)
|
||||
entries = cap.list_dir("/empty")
|
||||
entries = cap.files.list("/empty")
|
||||
assert entries == []
|
||||
|
||||
@respx.mock
|
||||
def test_list_dir_symlink(self, client):
|
||||
cap = _make_capsule(client)
|
||||
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond(
|
||||
200,
|
||||
json={
|
||||
"entries": [
|
||||
{
|
||||
"name": "link",
|
||||
"path": "/home/user/link",
|
||||
"type": "symlink",
|
||||
"size": 4,
|
||||
"mode": 41471,
|
||||
"permissions": "lrwxrwxrwx",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"modified_at": 1712899000,
|
||||
"symlink_target": "/bin",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
entries = cap.list_dir("/home/user")
|
||||
assert len(entries) == 1
|
||||
assert entries[0].type == "symlink"
|
||||
assert entries[0].symlink_target == "/bin"
|
||||
|
||||
|
||||
class TestMkdir:
|
||||
class TestFilesMakeDir:
|
||||
@respx.mock
|
||||
def test_mkdir_returns_entry(self, client):
|
||||
cap = _make_capsule(client)
|
||||
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/mkdir").respond(
|
||||
def test_make_dir_returns_entry(self):
|
||||
cap = _make_capsule()
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/mkdir").respond(
|
||||
200,
|
||||
json={
|
||||
"entry": {
|
||||
@ -142,19 +148,19 @@ class TestMkdir:
|
||||
}
|
||||
},
|
||||
)
|
||||
entry = cap.mkdir("/home/user/data")
|
||||
entry = cap.files.make_dir("/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):
|
||||
cap = _make_capsule(client)
|
||||
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/mkdir").respond(
|
||||
def test_make_dir_existing_returns_gracefully(self):
|
||||
cap = _make_capsule()
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/mkdir").respond(
|
||||
409,
|
||||
json={"error": {"code": "conflict", "message": "already exists"}},
|
||||
)
|
||||
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond(
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||
200,
|
||||
json={
|
||||
"entries": [
|
||||
@ -173,52 +179,48 @@ class TestMkdir:
|
||||
]
|
||||
},
|
||||
)
|
||||
entry = cap.mkdir("/home/user/data")
|
||||
entry = cap.files.make_dir("/home/user/data")
|
||||
assert entry.name == "data"
|
||||
|
||||
|
||||
class TestRemove:
|
||||
class TestFilesRemove:
|
||||
@respx.mock
|
||||
def test_remove_succeeds(self, client):
|
||||
cap = _make_capsule(client)
|
||||
route = respx.post(
|
||||
"https://api.wrenn.dev/v1/capsules/cl-abc/files/remove"
|
||||
).respond(204)
|
||||
cap.remove("/home/user/old_data")
|
||||
def test_remove_succeeds(self):
|
||||
cap = _make_capsule()
|
||||
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204)
|
||||
cap.files.remove("/home/user/old_data")
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
def test_remove_sends_path(self, client):
|
||||
cap = _make_capsule(client)
|
||||
route = respx.post(
|
||||
"https://api.wrenn.dev/v1/capsules/cl-abc/files/remove"
|
||||
).respond(204)
|
||||
cap.remove("/tmp/test.txt")
|
||||
def test_remove_sends_path(self):
|
||||
cap = _make_capsule()
|
||||
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204)
|
||||
cap.files.remove("/tmp/test.txt")
|
||||
body = json.loads(route.calls[0].request.content)
|
||||
assert body["path"] == "/tmp/test.txt"
|
||||
|
||||
|
||||
class TestUpload:
|
||||
class TestFilesExists:
|
||||
@respx.mock
|
||||
def test_upload_sends_multipart(self, client):
|
||||
cap = _make_capsule(client)
|
||||
route = respx.post(
|
||||
"https://api.wrenn.dev/v1/capsules/cl-abc/files/write"
|
||||
).respond(204)
|
||||
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()
|
||||
def test_exists_true(self):
|
||||
cap = _make_capsule()
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||
200,
|
||||
json={
|
||||
"entries": [
|
||||
{"name": "hello.txt", "path": "/tmp/hello.txt", "type": "file"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert cap.files.exists("/tmp/hello.txt") is True
|
||||
|
||||
@respx.mock
|
||||
def test_download_returns_bytes(self, client):
|
||||
cap = _make_capsule(client)
|
||||
content = b"file contents here"
|
||||
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/read").respond(
|
||||
200, content=content
|
||||
def test_exists_false(self):
|
||||
cap = _make_capsule()
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||
200, json={"entries": []}
|
||||
)
|
||||
data = cap.download("/app/main.py")
|
||||
assert data == content
|
||||
assert cap.files.exists("/tmp/nope.txt") is False
|
||||
|
||||
|
||||
class TestPtyEventParsing:
|
||||
@ -254,11 +256,6 @@ class TestPtyEventParsing:
|
||||
assert event.data == "process not found"
|
||||
assert event.fatal is True
|
||||
|
||||
def test_error_event_non_fatal(self):
|
||||
raw = {"type": "error", "data": "something", "fatal": False}
|
||||
event = _parse_pty_event(raw)
|
||||
assert event.fatal is False
|
||||
|
||||
def test_ping_event(self):
|
||||
raw = {"type": "ping"}
|
||||
event = _parse_pty_event(raw)
|
||||
@ -308,7 +305,9 @@ class TestPtySessionIteration:
|
||||
ws = MagicMock()
|
||||
messages = [
|
||||
json.dumps({"type": "started", "tag": "pty-abc12345", "pid": 1}),
|
||||
json.dumps({"type": "output", "data": base64.b64encode(b"hello").decode()}),
|
||||
json.dumps(
|
||||
{"type": "output", "data": base64.b64encode(b"hello").decode()}
|
||||
),
|
||||
json.dumps({"type": "exit", "exit_code": 0}),
|
||||
]
|
||||
ws.receive_text.side_effect = messages
|
||||
@ -385,9 +384,6 @@ class TestPtySessionSendStart:
|
||||
assert sent["cmd"] == "/bin/zsh"
|
||||
assert sent["args"] == ["-l"]
|
||||
assert sent["cols"] == 120
|
||||
assert sent["rows"] == 40
|
||||
assert sent["envs"] == {"TERM": "xterm-256color"}
|
||||
assert sent["cwd"] == "/home/user"
|
||||
|
||||
|
||||
class TestPtySessionSendConnect:
|
||||
@ -453,23 +449,15 @@ class TestAsyncPtySession:
|
||||
assert sent["type"] == "start"
|
||||
assert sent["cmd"] == "/bin/zsh"
|
||||
assert sent["cols"] == 100
|
||||
assert sent["rows"] == 30
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_send_connect(self):
|
||||
ws = AsyncMock()
|
||||
session = AsyncPtySession(ws, "cl-abc")
|
||||
await session._send_connect("pty-abc12345")
|
||||
sent = json.loads(ws.send_text.call_args[0][0])
|
||||
assert sent["type"] == "connect"
|
||||
assert sent["tag"] == "pty-abc12345"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_iteration(self):
|
||||
ws = AsyncMock()
|
||||
messages = [
|
||||
json.dumps({"type": "started", "tag": "pty-xyz", "pid": 5}),
|
||||
json.dumps({"type": "output", "data": base64.b64encode(b"hi").decode()}),
|
||||
json.dumps(
|
||||
{"type": "output", "data": base64.b64encode(b"hi").decode()}
|
||||
),
|
||||
json.dumps({"type": "exit", "exit_code": 0}),
|
||||
]
|
||||
ws.receive_text.side_effect = messages
|
||||
|
||||
Reference in New Issue
Block a user