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:
2026-04-15 15:19:23 +06:00
parent 0ac9bf79ee
commit 3cced768a4
14 changed files with 1936 additions and 1827 deletions

View File

@ -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)