v0.3.0 #15
@ -401,7 +401,9 @@ class EventsResource:
|
|||||||
print(ev.event, ev.resource)
|
print(ev.event, ev.resource)
|
||||||
"""
|
"""
|
||||||
with self._http.stream("GET", "/v1/events/stream", timeout=None) as resp:
|
with self._http.stream("GET", "/v1/events/stream", timeout=None) as resp:
|
||||||
_raise_for_status(resp)
|
if resp.status_code >= 400:
|
||||||
|
resp.read()
|
||||||
|
_raise_for_status(resp)
|
||||||
yield from _iter_sse_events(resp.iter_lines())
|
yield from _iter_sse_events(resp.iter_lines())
|
||||||
|
|
||||||
|
|
||||||
@ -611,7 +613,9 @@ class AsyncEventsResource:
|
|||||||
print(ev.event, ev.resource)
|
print(ev.event, ev.resource)
|
||||||
"""
|
"""
|
||||||
async with self._http.stream("GET", "/v1/events/stream", timeout=None) as resp:
|
async with self._http.stream("GET", "/v1/events/stream", timeout=None) as resp:
|
||||||
_raise_for_status(resp)
|
if resp.status_code >= 400:
|
||||||
|
await resp.aread()
|
||||||
|
_raise_for_status(resp)
|
||||||
data_lines: list[str] = []
|
data_lines: list[str] = []
|
||||||
async for raw in resp.aiter_lines():
|
async for raw in resp.aiter_lines():
|
||||||
if raw == "":
|
if raw == "":
|
||||||
|
|||||||
@ -13,9 +13,16 @@ from wrenn.exceptions import (
|
|||||||
WrennValidationError,
|
WrennValidationError,
|
||||||
)
|
)
|
||||||
from wrenn.models import (
|
from wrenn.models import (
|
||||||
|
Actor,
|
||||||
Capsule,
|
Capsule,
|
||||||
|
CapsuleMetrics,
|
||||||
|
CapsuleStats,
|
||||||
|
MetricPoint,
|
||||||
|
Resource,
|
||||||
|
SSEEvent,
|
||||||
Status,
|
Status,
|
||||||
Template,
|
Template,
|
||||||
|
UsageResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
BASE = "https://app.wrenn.dev/api"
|
BASE = "https://app.wrenn.dev/api"
|
||||||
@ -144,6 +151,69 @@ class TestCapsules:
|
|||||||
m = client.capsules.metrics("sb-1")
|
m = client.capsules.metrics("sb-1")
|
||||||
assert m.sandbox_id == "sb-1"
|
assert m.sandbox_id == "sb-1"
|
||||||
assert m.points and m.points[0].cpu_pct == 12.5
|
assert m.points and m.points[0].cpu_pct == 12.5
|
||||||
|
assert isinstance(m, CapsuleMetrics)
|
||||||
|
assert isinstance(m.points[0], MetricPoint)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_stats_default_omits_range(self, client):
|
||||||
|
route = respx.get(f"{BASE}/v1/capsules/stats").respond(
|
||||||
|
200,
|
||||||
|
json={"range": "1h", "current": {}, "peaks": {}, "series": {}},
|
||||||
|
)
|
||||||
|
stats = client.capsules.stats()
|
||||||
|
assert "range=" not in str(route.calls[0].request.url)
|
||||||
|
assert isinstance(stats, CapsuleStats)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_usage_default_omits_params(self, client):
|
||||||
|
route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []})
|
||||||
|
usage = client.capsules.usage()
|
||||||
|
url = str(route.calls[0].request.url)
|
||||||
|
assert "from=" not in url
|
||||||
|
assert "to=" not in url
|
||||||
|
assert isinstance(usage, UsageResponse)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_metrics_default_omits_range(self, client):
|
||||||
|
route = respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond(
|
||||||
|
200, json={"sandbox_id": "sb-1", "points": []}
|
||||||
|
)
|
||||||
|
client.capsules.metrics("sb-1")
|
||||||
|
assert "range=" not in str(route.calls[0].request.url)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_metrics_not_found(self, client):
|
||||||
|
respx.get(f"{BASE}/v1/capsules/nope/metrics").respond(
|
||||||
|
404,
|
||||||
|
json={"error": {"code": "not_found", "message": "capsule not found"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennNotFoundError):
|
||||||
|
client.capsules.metrics("nope")
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_stats_auth_error(self, client):
|
||||||
|
respx.get(f"{BASE}/v1/capsules/stats").respond(
|
||||||
|
401,
|
||||||
|
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennAuthenticationError):
|
||||||
|
client.capsules.stats()
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_usage_string_dates(self, client):
|
||||||
|
route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []})
|
||||||
|
client.capsules.usage(from_="2026-01-01", to="2026-01-31")
|
||||||
|
url = str(route.calls[0].request.url)
|
||||||
|
assert "from=2026-01-01" in url
|
||||||
|
assert "to=2026-01-31" in url
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_usage_partial_dates(self, client):
|
||||||
|
route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []})
|
||||||
|
client.capsules.usage(from_="2026-01-01")
|
||||||
|
url = str(route.calls[0].request.url)
|
||||||
|
assert "from=2026-01-01" in url
|
||||||
|
assert "to=" not in url
|
||||||
|
|
||||||
|
|
||||||
class TestEvents:
|
class TestEvents:
|
||||||
@ -170,6 +240,82 @@ class TestEvents:
|
|||||||
]
|
]
|
||||||
assert events[0].resource and events[0].resource.id == "sb-1"
|
assert events[0].resource and events[0].resource.id == "sb-1"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_stream_multiline_data(self, client):
|
||||||
|
body = (
|
||||||
|
"event: capsule.create\n"
|
||||||
|
'data: {"event":"capsule.create",\n'
|
||||||
|
'data: "resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "text/event-stream"},
|
||||||
|
content=body,
|
||||||
|
)
|
||||||
|
events = list(client.events.stream())
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].resource.id == "sb-1"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_stream_skips_keepalive_only(self, client):
|
||||||
|
body = ": keepalive\n\n: keepalive\n\n"
|
||||||
|
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "text/event-stream"},
|
||||||
|
content=body,
|
||||||
|
)
|
||||||
|
assert list(client.events.stream()) == []
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_stream_ignores_incomplete_trailing_frame(self, client):
|
||||||
|
body = (
|
||||||
|
'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||||
|
"\n"
|
||||||
|
'data: {"event":"capsule.destroy"' # no closing brace, no blank line
|
||||||
|
)
|
||||||
|
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "text/event-stream"},
|
||||||
|
content=body,
|
||||||
|
)
|
||||||
|
events = list(client.events.stream())
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].event.value == "capsule.create"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_stream_full_payload_round_trip(self, client):
|
||||||
|
body = (
|
||||||
|
'data: {"event":"capsule.create",'
|
||||||
|
'"outcome":"success",'
|
||||||
|
'"resource":{"id":"sb-1","type":"capsule"},'
|
||||||
|
'"actor":{"type":"api_key","id":"key-1","name":"ci"},'
|
||||||
|
'"metadata":{"reason":"manual"}}\n'
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "text/event-stream"},
|
||||||
|
content=body,
|
||||||
|
)
|
||||||
|
events = list(client.events.stream())
|
||||||
|
assert len(events) == 1
|
||||||
|
ev = events[0]
|
||||||
|
assert isinstance(ev, SSEEvent)
|
||||||
|
assert isinstance(ev.resource, Resource)
|
||||||
|
assert isinstance(ev.actor, Actor)
|
||||||
|
assert ev.actor.name == "ci"
|
||||||
|
assert ev.metadata == {"reason": "manual"}
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_stream_raises_on_4xx(self, client):
|
||||||
|
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||||
|
401,
|
||||||
|
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennAuthenticationError):
|
||||||
|
list(client.events.stream())
|
||||||
|
|
||||||
|
|
||||||
class TestSnapshots:
|
class TestSnapshots:
|
||||||
@respx.mock
|
@respx.mock
|
||||||
@ -329,6 +475,156 @@ class TestAsyncClient:
|
|||||||
with pytest.raises(WrennNotFoundError):
|
with pytest.raises(WrennNotFoundError):
|
||||||
await async_client.capsules.get("nope")
|
await async_client.capsules.get("nope")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_stats(self, async_client):
|
||||||
|
async with async_client:
|
||||||
|
route = respx.get(f"{BASE}/v1/capsules/stats").respond(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"range": "24h",
|
||||||
|
"current": {"running_count": 1},
|
||||||
|
"peaks": {"running_count": 5},
|
||||||
|
"series": {"running": [1]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
stats = await async_client.capsules.stats(range="24h")
|
||||||
|
assert "range=24h" in str(route.calls[0].request.url)
|
||||||
|
assert isinstance(stats, CapsuleStats)
|
||||||
|
assert stats.current.running_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_usage(self, async_client):
|
||||||
|
import datetime as _dt
|
||||||
|
|
||||||
|
async with async_client:
|
||||||
|
route = respx.get(f"{BASE}/v1/capsules/usage").respond(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"from": "2026-06-01",
|
||||||
|
"to": "2026-06-22",
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"date": "2026-06-01",
|
||||||
|
"cpu_minutes": 1.5,
|
||||||
|
"ram_mb_minutes": 200.0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
usage = await async_client.capsules.usage(
|
||||||
|
from_=_dt.date(2026, 6, 1), to="2026-06-22"
|
||||||
|
)
|
||||||
|
url = str(route.calls[0].request.url)
|
||||||
|
assert "from=2026-06-01" in url
|
||||||
|
assert "to=2026-06-22" in url
|
||||||
|
assert isinstance(usage, UsageResponse)
|
||||||
|
assert usage.points and usage.points[0].cpu_minutes == 1.5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_metrics(self, async_client):
|
||||||
|
async with async_client:
|
||||||
|
respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"sandbox_id": "sb-1",
|
||||||
|
"range": "2h",
|
||||||
|
"points": [
|
||||||
|
{"timestamp_unix": 1, "cpu_pct": 33.0, "mem_bytes": 1024}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
m = await async_client.capsules.metrics("sb-1", range="2h")
|
||||||
|
assert isinstance(m, CapsuleMetrics)
|
||||||
|
assert m.points[0].cpu_pct == 33.0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_metrics_not_found(self, async_client):
|
||||||
|
async with async_client:
|
||||||
|
respx.get(f"{BASE}/v1/capsules/nope/metrics").respond(
|
||||||
|
404,
|
||||||
|
json={"error": {"code": "not_found", "message": "not found"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennNotFoundError):
|
||||||
|
await async_client.capsules.metrics("nope")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_events_stream(self, async_client):
|
||||||
|
async with async_client:
|
||||||
|
body = (
|
||||||
|
": keepalive\n"
|
||||||
|
'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||||
|
"\n"
|
||||||
|
'data: {"event":"capsule.destroy","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "text/event-stream"},
|
||||||
|
content=body,
|
||||||
|
)
|
||||||
|
events = [ev async for ev in async_client.events.stream()]
|
||||||
|
assert [e.event.value for e in events] == [
|
||||||
|
"capsule.create",
|
||||||
|
"capsule.destroy",
|
||||||
|
]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_events_raises_on_4xx(self, async_client):
|
||||||
|
async with async_client:
|
||||||
|
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||||
|
401,
|
||||||
|
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennAuthenticationError):
|
||||||
|
async for _ in async_client.events.stream():
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_events_multiline_data(self, async_client):
|
||||||
|
async with async_client:
|
||||||
|
body = (
|
||||||
|
'data: {"event":"capsule.create",\n'
|
||||||
|
'data: "resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "text/event-stream"},
|
||||||
|
content=body,
|
||||||
|
)
|
||||||
|
events = [ev async for ev in async_client.events.stream()]
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].resource.id == "sb-1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPackageSurface:
|
||||||
|
def test_version(self):
|
||||||
|
import wrenn
|
||||||
|
|
||||||
|
assert wrenn.__version__ == "0.3.0"
|
||||||
|
|
||||||
|
def test_new_models_exported(self):
|
||||||
|
import wrenn.models as m
|
||||||
|
|
||||||
|
for name in (
|
||||||
|
"Actor",
|
||||||
|
"CapsuleMetrics",
|
||||||
|
"CapsuleStats",
|
||||||
|
"MetricPoint",
|
||||||
|
"Resource",
|
||||||
|
"SSEEvent",
|
||||||
|
"UsageResponse",
|
||||||
|
):
|
||||||
|
assert hasattr(m, name), name
|
||||||
|
assert name in m.__all__
|
||||||
|
|
||||||
|
|
||||||
class TestClientResolution:
|
class TestClientResolution:
|
||||||
def test_default_base_url_strips_app_subdomain(self):
|
def test_default_base_url_strips_app_subdomain(self):
|
||||||
|
|||||||
@ -428,13 +428,14 @@ class TestCodeRunnerMimeTypes:
|
|||||||
assert "array([0, 1, 2, 3, 4])" in ex.text
|
assert "array([0, 1, 2, 3, 4])" in ex.text
|
||||||
|
|
||||||
def test_requests_status_code(self):
|
def test_requests_status_code(self):
|
||||||
ex = self._run(
|
ex = self.capsule.run_code(
|
||||||
"import requests\n"
|
"import requests\n"
|
||||||
"r = requests.get('https://httpbin.org/status/204', timeout=10)\n"
|
"r = requests.get('https://httpbin.org/status/204', timeout=10)\n"
|
||||||
"r.status_code\n"
|
"r.status_code\n",
|
||||||
|
timeout=60,
|
||||||
)
|
)
|
||||||
if ex.error is not None:
|
if ex.error is not None:
|
||||||
pytest.skip(f"network unavailable: {ex.error.name}")
|
pytest.skip(f"network unavailable: {ex.error.name}: {ex.error.value}")
|
||||||
assert ex.text == "204"
|
assert ex.text == "204"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -388,6 +388,214 @@ class TestJupyterRequest:
|
|||||||
assert a["header"]["msg_id"] != b["header"]["msg_id"]
|
assert a["header"]["msg_id"] != b["header"]["msg_id"]
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── _protocol direct helpers ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestPickKernelId:
|
||||||
|
def test_returns_first_matching_kernel(self):
|
||||||
|
from wrenn.code_runner._protocol import pick_kernel_id
|
||||||
|
|
||||||
|
kernels = [
|
||||||
|
{"id": "k-1", "name": "python3"},
|
||||||
|
{"id": "k-2", "name": "wrenn"},
|
||||||
|
{"id": "k-3", "name": "wrenn"},
|
||||||
|
]
|
||||||
|
assert pick_kernel_id(kernels, "wrenn") == "k-2"
|
||||||
|
|
||||||
|
def test_returns_none_when_no_match(self):
|
||||||
|
from wrenn.code_runner._protocol import pick_kernel_id
|
||||||
|
|
||||||
|
kernels = [{"id": "k-1", "name": "python3"}]
|
||||||
|
assert pick_kernel_id(kernels, "wrenn") is None
|
||||||
|
|
||||||
|
def test_returns_none_on_empty_list(self):
|
||||||
|
from wrenn.code_runner._protocol import pick_kernel_id
|
||||||
|
|
||||||
|
assert pick_kernel_id([], "wrenn") is None
|
||||||
|
|
||||||
|
def test_ignores_entries_missing_name(self):
|
||||||
|
from wrenn.code_runner._protocol import pick_kernel_id
|
||||||
|
|
||||||
|
kernels = [{"id": "k-1"}, {"id": "k-2", "name": "wrenn"}]
|
||||||
|
assert pick_kernel_id(kernels, "wrenn") == "k-2"
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateLanguage:
|
||||||
|
def test_python_accepted(self):
|
||||||
|
from wrenn.code_runner._protocol import validate_language
|
||||||
|
|
||||||
|
validate_language("python") # no raise
|
||||||
|
|
||||||
|
def test_other_language_raises(self):
|
||||||
|
from wrenn.code_runner._protocol import validate_language
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="not supported"):
|
||||||
|
validate_language("r")
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildWsUrl:
|
||||||
|
def test_uses_proxy_domain_when_given(self):
|
||||||
|
from wrenn.code_runner._protocol import build_ws_url
|
||||||
|
|
||||||
|
url = build_ws_url(
|
||||||
|
base_url="https://app.wrenn.dev/api",
|
||||||
|
capsule_id="sb-1",
|
||||||
|
kernel_id="k-1",
|
||||||
|
proxy_domain="wrenn.dev",
|
||||||
|
)
|
||||||
|
assert url.startswith("wss://")
|
||||||
|
assert "8888-sb-1.wrenn.dev" in url
|
||||||
|
assert url.endswith("/api/kernels/k-1/channels")
|
||||||
|
|
||||||
|
def test_falls_back_to_base_host(self):
|
||||||
|
from wrenn.code_runner._protocol import build_ws_url
|
||||||
|
|
||||||
|
url = build_ws_url(
|
||||||
|
base_url="http://localhost:8080/api",
|
||||||
|
capsule_id="sb-1",
|
||||||
|
kernel_id="k-1",
|
||||||
|
)
|
||||||
|
# localhost stays http→ws (not wss) only if helper preserves scheme
|
||||||
|
assert "8888-sb-1.localhost:8080" in url
|
||||||
|
assert url.endswith("/api/kernels/k-1/channels")
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyKernelMessage:
|
||||||
|
def _make_execution(self):
|
||||||
|
from wrenn.code_runner.models import Execution, Logs
|
||||||
|
|
||||||
|
return Execution(results=[], logs=Logs(stdout=[], stderr=[]), error=None)
|
||||||
|
|
||||||
|
def test_ignores_other_parent(self):
|
||||||
|
from wrenn.code_runner._protocol import apply_kernel_message
|
||||||
|
|
||||||
|
execution = self._make_execution()
|
||||||
|
msg = {
|
||||||
|
"msg_type": "stream",
|
||||||
|
"header": {"msg_type": "stream"},
|
||||||
|
"parent_header": {"msg_id": "other"},
|
||||||
|
"content": {"name": "stdout", "text": "hi"},
|
||||||
|
}
|
||||||
|
emitted: list = []
|
||||||
|
done = apply_kernel_message(
|
||||||
|
msg, "mine", execution, emitted.append, None, None, None
|
||||||
|
)
|
||||||
|
assert done is False
|
||||||
|
assert execution.logs.stdout == []
|
||||||
|
|
||||||
|
def test_stream_stdout_routed(self):
|
||||||
|
from wrenn.code_runner._protocol import apply_kernel_message
|
||||||
|
|
||||||
|
execution = self._make_execution()
|
||||||
|
msg = {
|
||||||
|
"msg_type": "stream",
|
||||||
|
"parent_header": {"msg_id": "mine"},
|
||||||
|
"content": {"name": "stdout", "text": "hi"},
|
||||||
|
}
|
||||||
|
outs: list[str] = []
|
||||||
|
apply_kernel_message(
|
||||||
|
msg, "mine", execution, lambda e: None, None, outs.append, None
|
||||||
|
)
|
||||||
|
assert execution.logs.stdout == ["hi"]
|
||||||
|
assert outs == ["hi"]
|
||||||
|
|
||||||
|
def test_stream_stderr_routed(self):
|
||||||
|
from wrenn.code_runner._protocol import apply_kernel_message
|
||||||
|
|
||||||
|
execution = self._make_execution()
|
||||||
|
msg = {
|
||||||
|
"msg_type": "stream",
|
||||||
|
"parent_header": {"msg_id": "mine"},
|
||||||
|
"content": {"name": "stderr", "text": "boom"},
|
||||||
|
}
|
||||||
|
errs: list[str] = []
|
||||||
|
apply_kernel_message(
|
||||||
|
msg, "mine", execution, lambda e: None, None, None, errs.append
|
||||||
|
)
|
||||||
|
assert execution.logs.stderr == ["boom"]
|
||||||
|
assert errs == ["boom"]
|
||||||
|
|
||||||
|
def test_execute_result_marks_main(self):
|
||||||
|
from wrenn.code_runner._protocol import apply_kernel_message
|
||||||
|
|
||||||
|
execution = self._make_execution()
|
||||||
|
msg = {
|
||||||
|
"msg_type": "execute_result",
|
||||||
|
"parent_header": {"msg_id": "mine"},
|
||||||
|
"content": {
|
||||||
|
"data": {"text/plain": "42"},
|
||||||
|
"execution_count": 7,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
results: list = []
|
||||||
|
apply_kernel_message(
|
||||||
|
msg, "mine", execution, lambda e: None, results.append, None, None
|
||||||
|
)
|
||||||
|
assert execution.execution_count == 7
|
||||||
|
assert execution.results[0].is_main_result is True
|
||||||
|
assert results and results[0].is_main_result is True
|
||||||
|
|
||||||
|
def test_display_data_is_not_main(self):
|
||||||
|
from wrenn.code_runner._protocol import apply_kernel_message
|
||||||
|
|
||||||
|
execution = self._make_execution()
|
||||||
|
msg = {
|
||||||
|
"msg_type": "display_data",
|
||||||
|
"parent_header": {"msg_id": "mine"},
|
||||||
|
"content": {"data": {"text/plain": "x"}},
|
||||||
|
}
|
||||||
|
apply_kernel_message(msg, "mine", execution, lambda e: None, None, None, None)
|
||||||
|
assert execution.results[0].is_main_result is False
|
||||||
|
assert execution.execution_count is None
|
||||||
|
|
||||||
|
def test_error_message_emits_error(self):
|
||||||
|
from wrenn.code_runner._protocol import apply_kernel_message
|
||||||
|
|
||||||
|
execution = self._make_execution()
|
||||||
|
msg = {
|
||||||
|
"msg_type": "error",
|
||||||
|
"parent_header": {"msg_id": "mine"},
|
||||||
|
"content": {
|
||||||
|
"ename": "ValueError",
|
||||||
|
"evalue": "bad",
|
||||||
|
"traceback": ["line1", "line2"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
emitted: list = []
|
||||||
|
apply_kernel_message(msg, "mine", execution, emitted.append, None, None, None)
|
||||||
|
assert len(emitted) == 1
|
||||||
|
assert emitted[0].name == "ValueError"
|
||||||
|
assert emitted[0].traceback == "line1\nline2"
|
||||||
|
|
||||||
|
def test_idle_status_returns_true(self):
|
||||||
|
from wrenn.code_runner._protocol import apply_kernel_message
|
||||||
|
|
||||||
|
execution = self._make_execution()
|
||||||
|
msg = {
|
||||||
|
"msg_type": "status",
|
||||||
|
"parent_header": {"msg_id": "mine"},
|
||||||
|
"content": {"execution_state": "idle"},
|
||||||
|
}
|
||||||
|
done = apply_kernel_message(
|
||||||
|
msg, "mine", execution, lambda e: None, None, None, None
|
||||||
|
)
|
||||||
|
assert done is True
|
||||||
|
|
||||||
|
def test_busy_status_returns_false(self):
|
||||||
|
from wrenn.code_runner._protocol import apply_kernel_message
|
||||||
|
|
||||||
|
execution = self._make_execution()
|
||||||
|
msg = {
|
||||||
|
"msg_type": "status",
|
||||||
|
"parent_header": {"msg_id": "mine"},
|
||||||
|
"content": {"execution_state": "busy"},
|
||||||
|
}
|
||||||
|
done = apply_kernel_message(
|
||||||
|
msg, "mine", execution, lambda e: None, None, None, None
|
||||||
|
)
|
||||||
|
assert done is False
|
||||||
|
|
||||||
|
|
||||||
# ───────────────────────── run_code (WS-mocked) ─────────────────────────
|
# ───────────────────────── run_code (WS-mocked) ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,8 +7,18 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from wrenn import Capsule, CommandResult
|
from wrenn import Capsule, CommandResult
|
||||||
|
from wrenn.client import WrennClient
|
||||||
from wrenn.commands import CommandHandle, ProcessInfo
|
from wrenn.commands import CommandHandle, ProcessInfo
|
||||||
from wrenn.models import Capsule as CapsuleModel, FileEntry, Status
|
from wrenn.exceptions import WrennNotFoundError
|
||||||
|
from wrenn.models import (
|
||||||
|
Capsule as CapsuleModel,
|
||||||
|
CapsuleMetrics,
|
||||||
|
CapsuleStats,
|
||||||
|
FileEntry,
|
||||||
|
SSEEvent,
|
||||||
|
Status,
|
||||||
|
UsageResponse,
|
||||||
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
@ -406,3 +416,124 @@ class TestGit:
|
|||||||
def test_get_config_missing_returns_none(self):
|
def test_get_config_missing_returns_none(self):
|
||||||
value = self.capsule.git.get_config("nonexistent.key")
|
value = self.capsule.git.get_config("nonexistent.key")
|
||||||
assert value is None
|
assert value is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatsUsageMetrics:
|
||||||
|
"""Account-scoped + per-capsule observability endpoints."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
_ensure_env()
|
||||||
|
|
||||||
|
def test_stats_default(self):
|
||||||
|
with WrennClient() as client:
|
||||||
|
stats = client.capsules.stats()
|
||||||
|
assert isinstance(stats, CapsuleStats)
|
||||||
|
# current.running_count is always present even when zero
|
||||||
|
assert stats.current is not None
|
||||||
|
assert stats.peaks is not None
|
||||||
|
|
||||||
|
def test_stats_range(self):
|
||||||
|
with WrennClient() as client:
|
||||||
|
stats = client.capsules.stats(range="24h")
|
||||||
|
assert isinstance(stats, CapsuleStats)
|
||||||
|
if stats.range is not None:
|
||||||
|
assert stats.range.value == "24h"
|
||||||
|
|
||||||
|
def test_usage_no_args(self):
|
||||||
|
with WrennClient() as client:
|
||||||
|
usage = client.capsules.usage()
|
||||||
|
assert isinstance(usage, UsageResponse)
|
||||||
|
assert usage.points is not None
|
||||||
|
|
||||||
|
def test_usage_date_range(self):
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
with WrennClient() as client:
|
||||||
|
usage = client.capsules.usage(from_=today - timedelta(days=7), to=today)
|
||||||
|
assert isinstance(usage, UsageResponse)
|
||||||
|
|
||||||
|
def test_metrics_running_capsule(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
with WrennClient() as client:
|
||||||
|
m = client.capsules.metrics(capsule.capsule_id)
|
||||||
|
assert isinstance(m, CapsuleMetrics)
|
||||||
|
assert m.sandbox_id == capsule.capsule_id
|
||||||
|
assert m.points is not None
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_metrics_range_2h(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
with WrennClient() as client:
|
||||||
|
m = client.capsules.metrics(capsule.capsule_id, range="2h")
|
||||||
|
assert isinstance(m, CapsuleMetrics)
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_metrics_destroyed_capsule_raises(self):
|
||||||
|
# Create, destroy, then ask for metrics — guaranteed valid-shape ID
|
||||||
|
# that no longer exists. Server returns 404 for the missing capsule
|
||||||
|
# (avoids the 400 validation path for malformed IDs).
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
capsule_id = capsule.capsule_id
|
||||||
|
capsule.destroy(wait=True)
|
||||||
|
|
||||||
|
with WrennClient() as client:
|
||||||
|
with pytest.raises(WrennNotFoundError):
|
||||||
|
client.capsules.metrics(capsule_id)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventsStream:
|
||||||
|
"""SSE lifecycle stream — drives a capsule create and watches for it."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
_ensure_env()
|
||||||
|
|
||||||
|
def test_stream_receives_capsule_create_event(self):
|
||||||
|
import threading
|
||||||
|
|
||||||
|
seen: list[SSEEvent] = []
|
||||||
|
ready = threading.Event()
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
def _reader() -> None:
|
||||||
|
with WrennClient() as client:
|
||||||
|
stream = client.events.stream()
|
||||||
|
ready.set()
|
||||||
|
for ev in stream:
|
||||||
|
seen.append(ev)
|
||||||
|
if stop.is_set() or len(seen) > 20:
|
||||||
|
return
|
||||||
|
|
||||||
|
t = threading.Thread(target=_reader, daemon=True)
|
||||||
|
t.start()
|
||||||
|
assert ready.wait(timeout=5)
|
||||||
|
|
||||||
|
# Give the server a beat to fully open the SSE stream before
|
||||||
|
# the action that should appear on it.
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
capsule = Capsule()
|
||||||
|
capsule_id = capsule.capsule_id
|
||||||
|
try:
|
||||||
|
capsule.wait_ready(timeout=60)
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
# Wait for either our capsule's event or a timeout.
|
||||||
|
deadline = time.monotonic() + 15
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if any(
|
||||||
|
ev.resource is not None and ev.resource.id == capsule_id for ev in seen
|
||||||
|
):
|
||||||
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
stop.set()
|
||||||
|
assert any(isinstance(ev, SSEEvent) for ev in seen)
|
||||||
|
# Best-effort: assert we saw a capsule.* event for our capsule.
|
||||||
|
matching = [ev for ev in seen if ev.resource and ev.resource.id == capsule_id]
|
||||||
|
assert matching, f"no events for {capsule_id} in {len(seen)} received"
|
||||||
|
|||||||
Reference in New Issue
Block a user