- Rename `wrenn.code_interpreter` → `wrenn.code_runner` (canonical).
Keep old path as deprecation alias that emits a FutureWarning on
import, mirroring the existing `Sandbox` → `Capsule` pattern.
Submodule shims `code_interpreter/{capsule,async_capsule,models}.py`
keep direct-submodule imports working.
- Fix sync/async ctor-failure-safe `__del__`: initialise `_kernel_id`,
`_kernel_name`, `_proxy_client` before calling `super().__init__` so
a failed creation no longer crashes the destructor with
AttributeError.
- Send the kernel name to Jupyter. Previously `POST /api/kernels` had
no body, so the server picked an arbitrary default kernelspec. Now
sends `{"name": "wrenn"}` (override via `Capsule(kernel=...)`) and
reuses an existing kernel only when its `name` matches.
- Preserve Jupyter `text/plain` verbatim in `Result.from_bundle`.
The previous outer-quote strip was lossy (the string `'2'` became
indistinguishable from the int `2`, and strings containing escaped
quotes were mangled). `text` is now the `repr()` Jupyter sends.
Updated the stale `test_capsule_features` quote-strip test.
- Validate `run_code(language=...)`. Anything other than `"python"`
now raises `ValueError` instead of being silently ignored.
- Async `__del__` no longer touches the event loop; users must call
`await close()` or use `async with`.
- New unit suite `tests/test_code_runner_unit.py` (46 tests): MIME
unpacking, deprecation alias + warning, default template + kernel,
custom kernel override, ctor-failure-safe __del__, kernel
create/reuse/cache, retry on 5xx, 4xx propagation, request shape,
run_code stream/result/error/foreign-parent/idle/unsupported-language,
async variants.
- New e2e suite `tests/test_code_runner_e2e.py` (44 tests, integration
marker): template == `code-runner-beta`, kernel == `wrenn`, stdout
/stderr capture, state/import/function/class persistence, exceptions
(Value/Name/Syntax), callbacks, multi-line, `text` repr preservation,
filesystem round-trip, isolation between capsules, deprecated import
path. MIME-type class covers html, markdown, json, latex, svg,
javascript, png (matplotlib + seaborn), jpeg, multi-format bundles,
and text-round-trip via numpy + requests.
- `make test-code-runner` runs unit + e2e together. `make test`
extended to include the unit file.
- README: "Code Interpreter" section renamed to "Code Runner", all
imports updated, `kernel=` documented, removed the incorrect
"quotes stripped automatically" claim, replaced with the actual
`text/plain` semantics.
- CLAUDE.md: appended a "Code Runner Module" section covering module
path, defaults, kernel-reuse semantics, lifecycle invariant, and
the new test files + make target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
7.2 KiB
Python
214 lines
7.2 KiB
Python
from __future__ import annotations
|
|
|
|
import httpx
|
|
import respx
|
|
|
|
from wrenn.capsule import Capsule, _build_proxy_url
|
|
from wrenn.code_runner.models import Execution, ExecutionError, Logs, Result
|
|
|
|
BASE = "https://app.wrenn.dev/api"
|
|
|
|
|
|
class TestBuildProxyUrl:
|
|
def test_https_production(self):
|
|
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)
|
|
assert url == "ws://3000-cl-abc123.localhost:8080"
|
|
|
|
def test_https_custom_port(self):
|
|
url = _build_proxy_url("https://api.example.com:9443", "sb-1", 8080)
|
|
assert url == "wss://8080-sb-1.api.example.com:9443"
|
|
|
|
def test_http_no_port(self):
|
|
url = _build_proxy_url("http://192.168.1.1", "sb-2", 5000)
|
|
assert url == "ws://5000-sb-2.192.168.1.1"
|
|
|
|
|
|
class TestCapsuleCreate:
|
|
@respx.mock
|
|
def test_capsule_constructor_creates(self):
|
|
respx.post(f"{BASE}/v1/capsules").respond(
|
|
202, json={"id": "cl-1", "status": "starting", "template": "minimal"}
|
|
)
|
|
cap = Capsule(
|
|
template="minimal",
|
|
api_key="wrn_test1234567890abcdef12345678",
|
|
base_url=BASE,
|
|
)
|
|
assert cap.capsule_id == "cl-1"
|
|
assert hasattr(cap, "commands")
|
|
assert hasattr(cap, "files")
|
|
|
|
@respx.mock
|
|
def test_capsule_create_classmethod(self):
|
|
respx.post(f"{BASE}/v1/capsules").respond(
|
|
202, json={"id": "cl-2", "status": "starting"}
|
|
)
|
|
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
|
assert cap.capsule_id == "cl-2"
|
|
|
|
@respx.mock
|
|
def test_capsule_context_manager_kills(self):
|
|
respx.post(f"{BASE}/v1/capsules").respond(
|
|
202, json={"id": "cl-1", "status": "starting"}
|
|
)
|
|
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
|
|
with Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) 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(
|
|
202, json={"id": "cl-3", "status": "starting"}
|
|
)
|
|
cap = Capsule(base_url=BASE)
|
|
assert cap.capsule_id == "cl-3"
|
|
|
|
|
|
class TestCapsuleStaticMethods:
|
|
@respx.mock
|
|
def test_static_destroy(self):
|
|
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
|
|
Capsule._static_destroy(
|
|
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
|
)
|
|
assert route.called
|
|
|
|
@respx.mock
|
|
def test_static_pause(self):
|
|
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
|
|
202, json={"id": "cl-1", "status": "pausing"}
|
|
)
|
|
info = Capsule._static_pause(
|
|
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
|
)
|
|
assert info.status.value == "pausing"
|
|
|
|
@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", base_url=BASE)
|
|
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", base_url=BASE
|
|
)
|
|
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", base_url=BASE
|
|
)
|
|
assert cap.capsule_id == "cl-1"
|
|
|
|
@respx.mock
|
|
def test_connect_paused_resumes(self):
|
|
get_route = respx.get(f"{BASE}/v1/capsules/cl-1")
|
|
get_route.side_effect = [
|
|
httpx.Response(200, json={"id": "cl-1", "status": "paused"}),
|
|
httpx.Response(200, json={"id": "cl-1", "status": "running"}),
|
|
]
|
|
respx.post(f"{BASE}/v1/capsules/cl-1/resume").respond(
|
|
202, json={"id": "cl-1", "status": "resuming"}
|
|
)
|
|
cap = Capsule.connect(
|
|
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
|
)
|
|
assert cap.capsule_id == "cl-1"
|
|
|
|
|
|
class TestExecutionModels:
|
|
def test_execution_defaults(self):
|
|
e = Execution()
|
|
assert e.results == []
|
|
assert e.logs.stdout == []
|
|
assert e.logs.stderr == []
|
|
assert e.error is None
|
|
assert e.text is None
|
|
|
|
def test_result_from_bundle(self):
|
|
bundle = {"text/plain": "84", "image/png": "base64data"}
|
|
r = Result.from_bundle(bundle, is_main_result=True)
|
|
assert r.text == "84"
|
|
assert r.png == "base64data"
|
|
assert r.is_main_result is True
|
|
|
|
def test_result_from_bundle_preserves_text_plain(self):
|
|
# ``text/plain`` is the Jupyter repr — preserved verbatim now.
|
|
bundle = {"text/plain": "'hello'"}
|
|
r = Result.from_bundle(bundle)
|
|
assert r.text == "'hello'"
|
|
|
|
def test_result_from_bundle_extra_mimes(self):
|
|
bundle = {"text/plain": "x", "application/vnd.custom": "data"}
|
|
r = Result.from_bundle(bundle)
|
|
assert r.extra == {"application/vnd.custom": "data"}
|
|
|
|
def test_result_formats(self):
|
|
r = Result(text="hi", png="data")
|
|
assert "text" in r.formats()
|
|
assert "png" in r.formats()
|
|
assert "html" not in r.formats()
|
|
|
|
def test_execution_text_property(self):
|
|
e = Execution(
|
|
results=[
|
|
Result(text="chart", is_main_result=False),
|
|
Result(text="42", is_main_result=True),
|
|
]
|
|
)
|
|
assert e.text == "42"
|
|
|
|
def test_execution_error(self):
|
|
err = ExecutionError(
|
|
name="ZeroDivisionError",
|
|
value="division by zero",
|
|
traceback="Traceback ...\nZeroDivisionError: division by zero",
|
|
)
|
|
e = Execution(error=err)
|
|
assert e.error is not None
|
|
assert "ZeroDivisionError" in e.error.name
|
|
|
|
def test_logs(self):
|
|
logs = Logs(stdout=["hello\n", "world\n"], stderr=["warn\n"])
|
|
assert "".join(logs.stdout) == "hello\nworld\n"
|
|
assert "".join(logs.stderr) == "warn\n"
|
|
|
|
|
|
class TestDeprecationWarnings:
|
|
def test_import_sandbox_from_wrenn_warns(self):
|
|
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
|
|
fw = [x for x in w if issubclass(x.category, FutureWarning)]
|
|
assert len(fw) >= 1
|
|
assert "Sandbox" in str(fw[0].message)
|