All checks were successful
ci/woodpecker/push/unit Pipeline was successful
Extend test_client.py with async stats/usage/metrics + async events stream, SSE edge cases (multi-line data, keepalive-only, incomplete trailing frame, full payload round trip, 4xx → typed error), default arg paths that omit query params, string/partial date variants, and 404/401 error paths. Verify __version__ == "0.3.0" and that the new models (Actor, CapsuleMetrics, CapsuleStats, MetricPoint, Resource, SSEEvent, UsageResponse) are exported from wrenn.models. Add live integration tests in test_integration.py: TestStatsUsageMetrics (stats default/range, usage no-args/date-range, metrics on a running capsule, metrics range=2h, metrics on a destroyed capsule → 404) and TestEventsStream (spawn reader thread, create capsule, assert its lifecycle event arrives). Add direct unit tests in test_code_runner_unit.py for the bare _protocol helpers (pick_kernel_id, validate_language, build_ws_url, apply_kernel_message) covering parent filtering, stdout/stderr routing, execute_result vs display_data main flag, error emit, and idle/busy status return. Fix EventsResource.stream() and AsyncEventsResource.stream(): calling _raise_for_status on a streaming httpx.Response without first reading the body raised httpx.ResponseNotRead on any 4xx/5xx. Now read the body (resp.read() / await resp.aread()) before status parsing when status >= 400. Bypass TestCodeRunnerMimeTypes._run in test_requests_status_code so the in-capsule SSL/network failure skips the test instead of tripping the helper's "unexpected error" pre-assertion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
663 lines
22 KiB
Python
663 lines
22 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
import respx
|
|
|
|
from wrenn.client import AsyncWrennClient, WrennClient
|
|
from wrenn.exceptions import (
|
|
WrennAgentError,
|
|
WrennAuthenticationError,
|
|
WrennConflictError,
|
|
WrennInternalError,
|
|
WrennNotFoundError,
|
|
WrennValidationError,
|
|
)
|
|
from wrenn.models import (
|
|
Actor,
|
|
Capsule,
|
|
CapsuleMetrics,
|
|
CapsuleStats,
|
|
MetricPoint,
|
|
Resource,
|
|
SSEEvent,
|
|
Status,
|
|
Template,
|
|
UsageResponse,
|
|
)
|
|
|
|
BASE = "https://app.wrenn.dev/api"
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
with WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as c:
|
|
yield c
|
|
|
|
|
|
@pytest.fixture
|
|
def async_client():
|
|
return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
|
|
|
|
|
class TestCapsules:
|
|
@respx.mock
|
|
def test_create(self, client):
|
|
respx.post(f"{BASE}/v1/capsules").respond(
|
|
202,
|
|
json={
|
|
"id": "sb-1",
|
|
"status": "starting",
|
|
"template": "base-python",
|
|
"vcpus": 2,
|
|
"memory_mb": 1024,
|
|
},
|
|
)
|
|
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.starting
|
|
|
|
@respx.mock
|
|
def test_create_defaults(self, client):
|
|
respx.post(f"{BASE}/v1/capsules").respond(
|
|
202, json={"id": "sb-2", "status": "starting"}
|
|
)
|
|
resp = client.capsules.create()
|
|
assert resp.id == "sb-2"
|
|
|
|
@respx.mock
|
|
def test_list(self, client):
|
|
respx.get(f"{BASE}/v1/capsules").respond(
|
|
200, json=[{"id": "sb-1", "status": "running"}]
|
|
)
|
|
boxes = client.capsules.list()
|
|
assert len(boxes) == 1
|
|
assert boxes[0].status == Status.running
|
|
|
|
@respx.mock
|
|
def test_get(self, client):
|
|
respx.get(f"{BASE}/v1/capsules/sb-1").respond(
|
|
200, json={"id": "sb-1", "status": "running"}
|
|
)
|
|
resp = client.capsules.get("sb-1")
|
|
assert resp.id == "sb-1"
|
|
|
|
@respx.mock
|
|
def test_destroy(self, client):
|
|
route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(202)
|
|
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(
|
|
202, json={"id": "sb-1", "status": "pausing"}
|
|
)
|
|
resp = client.capsules.pause("sb-1")
|
|
assert resp.status == Status.pausing
|
|
|
|
@respx.mock
|
|
def test_resume(self, client):
|
|
respx.post(f"{BASE}/v1/capsules/sb-1/resume").respond(
|
|
202, json={"id": "sb-1", "status": "resuming"}
|
|
)
|
|
resp = client.capsules.resume("sb-1")
|
|
assert resp.status == Status.resuming
|
|
|
|
@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
|
|
|
|
@respx.mock
|
|
def test_stats(self, client):
|
|
route = respx.get(f"{BASE}/v1/capsules/stats").respond(
|
|
200,
|
|
json={
|
|
"range": "6h",
|
|
"current": {"running_count": 2, "vcpus_reserved": 4},
|
|
"peaks": {"running_count": 9},
|
|
"series": {"running": [1, 2, 2]},
|
|
},
|
|
)
|
|
stats = client.capsules.stats(range="6h")
|
|
assert "range=6h" in str(route.calls[0].request.url)
|
|
assert stats.current and stats.current.running_count == 2
|
|
assert stats.peaks and stats.peaks.running_count == 9
|
|
|
|
@respx.mock
|
|
def test_usage_passes_date_params(self, client):
|
|
import datetime as _dt
|
|
|
|
route = respx.get(f"{BASE}/v1/capsules/usage").respond(
|
|
200, json={"from": "2026-06-01", "to": "2026-06-22", "points": []}
|
|
)
|
|
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
|
|
|
|
@respx.mock
|
|
def test_metrics(self, client):
|
|
respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond(
|
|
200,
|
|
json={
|
|
"sandbox_id": "sb-1",
|
|
"range": "10m",
|
|
"points": [{"timestamp_unix": 1, "cpu_pct": 12.5, "mem_bytes": 4096}],
|
|
},
|
|
)
|
|
m = client.capsules.metrics("sb-1")
|
|
assert m.sandbox_id == "sb-1"
|
|
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:
|
|
@respx.mock
|
|
def test_stream_parses_sse_frames(self, client):
|
|
body = (
|
|
": keepalive\n"
|
|
"event: capsule.create\n"
|
|
'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n'
|
|
"\n"
|
|
"event: capsule.destroy\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 = list(client.events.stream())
|
|
assert [e.event.value for e in events] == [
|
|
"capsule.create",
|
|
"capsule.destroy",
|
|
]
|
|
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:
|
|
@respx.mock
|
|
def test_create(self, client):
|
|
respx.post(f"{BASE}/v1/snapshots").respond(
|
|
201,
|
|
json={"name": "snap-1", "type": "snapshot", "vcpus": 1},
|
|
)
|
|
resp = client.snapshots.create(capsule_id="sb-1", name="snap-1")
|
|
assert isinstance(resp, Template)
|
|
assert resp.name == "snap-1"
|
|
|
|
@respx.mock
|
|
def test_create_with_overwrite(self, client):
|
|
route = respx.post(f"{BASE}/v1/snapshots").respond(
|
|
201, json={"name": "snap-1", "type": "snapshot"}
|
|
)
|
|
client.snapshots.create(capsule_id="sb-1", overwrite=True)
|
|
req = route.calls[0].request
|
|
assert "overwrite=true" in str(req.url)
|
|
|
|
@respx.mock
|
|
def test_list(self, client):
|
|
respx.get(f"{BASE}/v1/snapshots").respond(
|
|
200, json=[{"name": "base-python", "type": "base"}]
|
|
)
|
|
snaps = client.snapshots.list()
|
|
assert len(snaps) == 1
|
|
|
|
@respx.mock
|
|
def test_list_with_filter(self, client):
|
|
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(f"{BASE}/v1/snapshots/snap-1").respond(204)
|
|
client.snapshots.delete("snap-1")
|
|
assert route.called
|
|
|
|
|
|
class TestErrorHandling:
|
|
@respx.mock
|
|
def test_validation_error(self, client):
|
|
respx.post(f"{BASE}/v1/capsules").respond(
|
|
400,
|
|
json={"error": {"code": "invalid_request", "message": "bad input"}},
|
|
)
|
|
with pytest.raises(WrennValidationError) as exc_info:
|
|
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(f"{BASE}/v1/capsules").respond(
|
|
401,
|
|
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
|
)
|
|
with pytest.raises(WrennAuthenticationError):
|
|
client.capsules.list()
|
|
|
|
@respx.mock
|
|
def test_not_found_error(self, client):
|
|
respx.get(f"{BASE}/v1/capsules/nope").respond(
|
|
404,
|
|
json={"error": {"code": "not_found", "message": "capsule not found"}},
|
|
)
|
|
with pytest.raises(WrennNotFoundError):
|
|
client.capsules.get("nope")
|
|
|
|
@respx.mock
|
|
def test_conflict_error(self, client):
|
|
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_agent_error(self, client):
|
|
respx.post(f"{BASE}/v1/capsules").respond(
|
|
502,
|
|
json={"error": {"code": "agent_error", "message": "host agent failed"}},
|
|
)
|
|
with pytest.raises(WrennAgentError):
|
|
client.capsules.create()
|
|
|
|
@respx.mock
|
|
def test_internal_error(self, client):
|
|
respx.get(f"{BASE}/v1/capsules/sb-1").respond(
|
|
500,
|
|
json={"error": {"code": "internal_error", "message": "oops"}},
|
|
)
|
|
with pytest.raises(WrennInternalError):
|
|
client.capsules.get("sb-1")
|
|
|
|
@respx.mock
|
|
def test_unknown_error_code_falls_back(self, client):
|
|
respx.get(f"{BASE}/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.capsules.get("sb-1")
|
|
assert exc_info.value.code == "teapot"
|
|
|
|
|
|
class TestAuthModes:
|
|
def test_api_key_header(self):
|
|
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
|
assert c._http.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
|
|
|
|
def test_no_auth_raises(self, monkeypatch):
|
|
monkeypatch.delenv("WRENN_API_KEY", raising=False)
|
|
with pytest.raises(ValueError, match="No API key"):
|
|
WrennClient()
|
|
|
|
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:
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_async_capsules_create(self, async_client):
|
|
async with async_client:
|
|
respx.post(f"{BASE}/v1/capsules").respond(
|
|
202, json={"id": "sb-1", "status": "starting"}
|
|
)
|
|
resp = await async_client.capsules.create(template="base-python")
|
|
assert resp.id == "sb-1"
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_async_capsules_list(self, async_client):
|
|
async with async_client:
|
|
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_error_handling(self, async_client):
|
|
async with async_client:
|
|
respx.get(f"{BASE}/v1/capsules/nope").respond(
|
|
404,
|
|
json={"error": {"code": "not_found", "message": "not found"}},
|
|
)
|
|
with pytest.raises(WrennNotFoundError):
|
|
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:
|
|
def test_default_base_url_strips_app_subdomain(self):
|
|
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
|
assert c._proxy_domain == "wrenn.dev"
|
|
|
|
def test_custom_base_url_preserves_host(self):
|
|
with WrennClient(
|
|
api_key="wrn_test1234567890abcdef12345678",
|
|
base_url="http://localhost:8080/api",
|
|
) as c:
|
|
assert c._proxy_domain == "localhost:8080"
|
|
|
|
def test_explicit_proxy_domain_wins(self):
|
|
with WrennClient(
|
|
api_key="wrn_test1234567890abcdef12345678",
|
|
base_url="https://app.wrenn.dev/api",
|
|
proxy_domain="custom.example.com",
|
|
) as c:
|
|
assert c._proxy_domain == "custom.example.com"
|
|
|
|
def test_env_proxy_domain(self, monkeypatch):
|
|
monkeypatch.setenv("WRENN_PROXY_DOMAIN", "env.example.com")
|
|
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
|
assert c._proxy_domain == "env.example.com"
|
|
|
|
def test_default_timeout(self):
|
|
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
|
t = c._http.timeout
|
|
assert t.connect == 10.0
|
|
assert t.read == 30.0
|
|
|
|
def test_timeout_float_override(self):
|
|
with WrennClient(api_key="wrn_test1234567890abcdef12345678", timeout=5.0) as c:
|
|
assert c._http.timeout.connect == 5.0
|