## What's New?

- Bugfixes
- Updated test suites

Reviewed-on: wrenn/python-sdk#15
Co-authored-by: pptx704 <rafeed@omukk.dev>
Co-committed-by: pptx704 <rafeed@omukk.dev>
This commit is contained in:
2026-06-25 22:11:25 +00:00
committed by Rafeed M. Bhuiyan
parent 4924237a23
commit 45f7f467c5
17 changed files with 2368 additions and 3392 deletions

View File

@ -13,7 +13,7 @@ repos:
- pydantic>=2.12.5
- httpx>=0.28.1
- httpx-ws>=0.9.0
- email-validator>=2.3.0
- types-PyYAML
- repo: local
hooks:

View File

@ -1,5 +1,5 @@
# Makefile
.PHONY: generate lint test check test-integration test-code-runner
.PHONY: generate gen-docs lint test check test-integration test-code-runner prune-spec
# Variables
SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml"
@ -12,6 +12,8 @@ generate:
curl -fsSL $(SPEC_URL) -o $(SPEC_PATH)
$(MAKE) prune-spec
uv run datamodel-codegen \
--input $(SPEC_PATH) \
--output src/wrenn/models/_generated.py \
@ -40,6 +42,10 @@ test-code-runner:
check: lint test
prune-spec:
@echo "Pruning spec down to the SDK's API-key surface..."
uv run python scripts/prune_openapi.py $(SPEC_PATH)
gen-docs:
mkdir -p docs
uv run pydoc-markdown > docs/reference.md

File diff suppressed because it is too large Load Diff

View File

@ -166,6 +166,131 @@ Reset the inactivity timer for a capsule.
- `WrennNotFoundError` - If no capsule with the given ID exists.
<a id="wrenn.client.CapsulesResource.stats"></a>
#### stats
```python
def stats(range: str | None = None) -> CapsuleStats
```
Get aggregate capsule usage stats for the authenticated team.
**Arguments**:
- `range` _str | None_ - Time window. One of ``5m``, ``1h``, ``6h``,
``24h``, ``30d``. Defaults to ``1h`` server-side.
**Returns**:
- `CapsuleStats` - Current running counts plus 30-day peaks and a
chart-ready time series.
Example::
stats = wrenn.capsules.stats(range="24h")
print(stats.current.running_count, stats.peaks.vcpus)
<a id="wrenn.client.CapsulesResource.usage"></a>
#### usage
```python
def usage(from_: str | date | None = None,
to: str | date | None = None) -> UsageResponse
```
Get per-day CPU and RAM usage for the team.
**Arguments**:
- `from_` _str | date | None_ - Start date (``YYYY-MM-DD`` string or
``date``). Defaults to 30 days ago server-side.
- `to` _str | date | None_ - End date. Defaults to today.
**Returns**:
- `UsageResponse` - Daily ``cpu_minutes`` / ``ram_mb_minutes`` points.
Example::
from datetime import date, timedelta
today = date.today()
usage = wrenn.capsules.usage(from_=today - timedelta(days=7), to=today)
for point in usage.points:
print(point.date, point.cpu_minutes, point.ram_mb_minutes)
<a id="wrenn.client.CapsulesResource.metrics"></a>
#### metrics
```python
def metrics(id: str, range: str | None = None) -> CapsuleMetrics
```
Get time-series CPU, memory, and disk metrics for a capsule.
**Arguments**:
- `id` _str_ - Capsule ID.
- `range` _str | None_ - One of ``10m`` (500ms samples),
``2h`` (30s averages), ``24h`` (5-minute averages). Defaults
to ``10m`` server-side.
**Returns**:
- `CapsuleMetrics` - Sampled :class:`MetricPoint` series.
**Raises**:
- `WrennNotFoundError` - If the capsule does not exist or has been
destroyed.
Example::
m = wrenn.capsules.metrics("sb-abc123", range="2h")
for point in m.points:
print(point.timestamp_unix, point.cpu_pct, point.mem_bytes)
<a id="wrenn.client.EventsResource"></a>
## EventsResource Objects
```python
class EventsResource()
```
Sync server-sent event stream of capsule/template/host lifecycle events.
<a id="wrenn.client.EventsResource.stream"></a>
#### stream
```python
def stream() -> Iterator[SSEEvent]
```
Stream lifecycle events for the team in real time.
The connection is held open by the server; iterate the result to
receive :class:`SSEEvent` payloads as they arrive. Close the
iterator (or break out of the loop) to disconnect.
**Yields**:
- `SSEEvent` - One event per server frame.
Example::
with WrennClient() as wrenn:
for ev in wrenn.events.stream():
print(ev.event, ev.resource)
<a id="wrenn.client.AsyncCapsulesResource"></a>
## AsyncCapsulesResource Objects
@ -326,6 +451,129 @@ Reset the inactivity timer for a capsule.
- `WrennNotFoundError` - If no capsule with the given ID exists.
<a id="wrenn.client.AsyncCapsulesResource.stats"></a>
#### stats
```python
async def stats(range: str | None = None) -> CapsuleStats
```
Get aggregate capsule usage stats for the authenticated team.
**Arguments**:
- `range` _str | None_ - Time window. One of ``5m``, ``1h``, ``6h``,
``24h``, ``30d``. Defaults to ``1h`` server-side.
**Returns**:
- `CapsuleStats` - Current running counts plus 30-day peaks and a
chart-ready time series.
Example::
stats = await wrenn.capsules.stats(range="24h")
print(stats.current.running_count, stats.peaks.vcpus)
<a id="wrenn.client.AsyncCapsulesResource.usage"></a>
#### usage
```python
async def usage(from_: str | date | None = None,
to: str | date | None = None) -> UsageResponse
```
Get per-day CPU and RAM usage for the team.
**Arguments**:
- `from_` _str | date | None_ - Start date (``YYYY-MM-DD`` string or
``date``). Defaults to 30 days ago server-side.
- `to` _str | date | None_ - End date. Defaults to today.
**Returns**:
- `UsageResponse` - Daily ``cpu_minutes`` / ``ram_mb_minutes`` points.
Example::
from datetime import date, timedelta
today = date.today()
usage = await wrenn.capsules.usage(
from_=today - timedelta(days=7), to=today
)
for point in usage.points:
print(point.date, point.cpu_minutes, point.ram_mb_minutes)
<a id="wrenn.client.AsyncCapsulesResource.metrics"></a>
#### metrics
```python
async def metrics(id: str, range: str | None = None) -> CapsuleMetrics
```
Get time-series CPU, memory, and disk metrics for a capsule.
**Arguments**:
- `id` _str_ - Capsule ID.
- `range` _str | None_ - One of ``10m`` (500ms samples),
``2h`` (30s averages), ``24h`` (5-minute averages). Defaults
to ``10m`` server-side.
**Returns**:
- `CapsuleMetrics` - Sampled :class:`MetricPoint` series.
**Raises**:
- `WrennNotFoundError` - If the capsule does not exist or has been
destroyed.
Example::
m = await wrenn.capsules.metrics("sb-abc123", range="2h")
for point in m.points:
print(point.timestamp_unix, point.cpu_pct, point.mem_bytes)
<a id="wrenn.client.AsyncEventsResource"></a>
## AsyncEventsResource Objects
```python
class AsyncEventsResource()
```
Async server-sent event stream of capsule/template/host lifecycle events.
<a id="wrenn.client.AsyncEventsResource.stream"></a>
#### stream
```python
async def stream() -> AsyncIterator[SSEEvent]
```
Stream lifecycle events for the team in real time.
**Yields**:
- `SSEEvent` - One event per server frame.
Example::
async with AsyncWrennClient() as wrenn:
async for ev in wrenn.events.stream():
print(ev.event, ev.resource)
<a id="wrenn.client.SnapshotsResource"></a>
## SnapshotsResource Objects
@ -484,7 +732,11 @@ class WrennClient()
Synchronous client for the Wrenn API.
Authenticates with an API key.
Authenticates with an API key. Exposes three resources:
- :attr:`capsules` — capsule lifecycle, stats, usage, metrics
- :attr:`snapshots` — template snapshot management
- :attr:`events` — server-sent lifecycle event stream
**Arguments**:
@ -497,6 +749,15 @@ Authenticates with an API key.
- `timeout` - HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
or ``None`` for the default (30s read/write/pool, 10s connect).
Example::
from wrenn import WrennClient
with WrennClient() as wrenn: # reads WRENN_API_KEY
capsule = wrenn.capsules.create(template="minimal-ubuntu")
print(capsule.id, capsule.status)
wrenn.capsules.destroy(capsule.id)
<a id="wrenn.client.WrennClient.http"></a>
#### http
@ -528,7 +789,8 @@ class AsyncWrennClient()
Asynchronous client for the Wrenn API.
Authenticates with an API key.
Authenticates with an API key. Mirrors :class:`WrennClient` with
``await``-able methods on every resource.
**Arguments**:
@ -541,6 +803,16 @@ Authenticates with an API key.
- `timeout` - HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
or ``None`` for the default (30s read/write/pool, 10s connect).
Example::
from wrenn import AsyncWrennClient
async with AsyncWrennClient() as wrenn:
capsule = await wrenn.capsules.create(template="minimal-ubuntu")
async for event in wrenn.events.stream():
if event.resource and event.resource.id == capsule.id:
break
<a id="wrenn.client.AsyncWrennClient.http"></a>
#### http
@ -1936,18 +2208,6 @@ Send SIGKILL to the PTY process.
# wrenn.models.\_generated
<a id="wrenn.models._generated.SessionResponse"></a>
## SessionResponse Objects
```python
class SessionResponse(BaseModel)
```
Returned by login, activate, and switch-team. The actual auth credential
is the wrenn_sid cookie set on the response. The body carries identity
data the SPA needs to bootstrap.
<a id="wrenn.models._generated.Peaks"></a>
## Peaks Objects
@ -1978,16 +2238,6 @@ class Encoding(StrEnum)
Output encoding. "base64" when stdout/stderr contain binary data.
<a id="wrenn.models._generated.Type2"></a>
## Type2 Objects
```python
class Type2(StrEnum)
```
Host type. Regular hosts are shared; BYOC hosts belong to a team.
<a id="wrenn.models._generated.Outcome"></a>
## Outcome Objects

View File

@ -1,6 +1,6 @@
[project]
name = "wrenn"
version = "0.2.0"
version = "0.3.0"
description = "Python SDK for Wrenn"
readme = "README.md"
license = "MIT"
@ -23,7 +23,6 @@ classifiers = [
]
dependencies = [
"certifi>=2026.2.25",
"email-validator>=2.3.0",
"httpx>=0.28.1",
"httpx-ws>=0.9.0",
"pydantic>=2.12.5",
@ -41,6 +40,7 @@ dev = [
"pydoc-markdown>=4.8.2",
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
"pyyaml>=6.0.3",
"respx>=0.23.1",
"ruff>=0.15.10",
]

179
scripts/prune_openapi.py Normal file
View File

@ -0,0 +1,179 @@
"""Prune the upstream Wrenn OpenAPI spec down to the SDK's surface.
The upstream spec at ``wrennhq/wrenn/internal/api/openapi.yaml`` covers
the whole control plane: account/team/host/channel/admin routes that
require an interactive ``wrenn_sid`` cookie. The Python SDK only ever
authenticates with an API key, so those routes are dead surface area
here. This script keeps only paths that list ``apiKeyAuth`` (and the
schemas they transitively reach) and rewrites the spec in place.
Run via ``make generate`` (curl ➜ prune ➜ datamodel-codegen) or
``uv run python scripts/prune_openapi.py [path]`` directly.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import yaml
KEEP_PATHS: set[str] = {
"/v1/capsules",
"/v1/capsules/stats",
"/v1/capsules/usage",
"/v1/capsules/{id}",
"/v1/capsules/{id}/exec",
"/v1/capsules/{id}/exec/stream",
"/v1/capsules/{id}/processes",
"/v1/capsules/{id}/processes/{selector}",
"/v1/capsules/{id}/processes/{selector}/stream",
"/v1/capsules/{id}/ping",
"/v1/capsules/{id}/metrics",
"/v1/capsules/{id}/pause",
"/v1/capsules/{id}/resume",
"/v1/capsules/{id}/files/write",
"/v1/capsules/{id}/files/read",
"/v1/capsules/{id}/files/list",
"/v1/capsules/{id}/files/mkdir",
"/v1/capsules/{id}/files/remove",
"/v1/capsules/{id}/files/stream/write",
"/v1/capsules/{id}/files/stream/read",
"/v1/capsules/{id}/pty",
"/v1/snapshots",
"/v1/snapshots/{name}",
"/v1/events/stream",
}
KEEP_SECURITY_SCHEMES: set[str] = {"apiKeyAuth"}
REF_RE = re.compile(r"#/components/(schemas|responses)/([A-Za-z0-9_]+)")
def _walk_refs(node: object, schemas: dict, responses: dict, seen: set[str]) -> None:
"""Depth-first walk collecting ``schemas:Name`` / ``responses:Name`` refs."""
if isinstance(node, dict):
for k, v in node.items():
if k == "$ref" and isinstance(v, str):
m = REF_RE.search(v)
if m:
kind, name = m.group(1), m.group(2)
key = f"{kind}:{name}"
if key in seen:
continue
seen.add(key)
target = (schemas if kind == "schemas" else responses).get(name)
if target is not None:
_walk_refs(target, schemas, responses, seen)
else:
_walk_refs(v, schemas, responses, seen)
elif isinstance(node, list):
for item in node:
_walk_refs(item, schemas, responses, seen)
def _filter_security(operation: dict) -> None:
sec = operation.get("security")
if not isinstance(sec, list):
return
kept = [
entry
for entry in sec
if isinstance(entry, dict)
and any(name in KEEP_SECURITY_SCHEMES for name in entry)
]
if kept:
operation["security"] = kept
else:
operation.pop("security", None)
def prune(spec: dict) -> dict:
paths = spec.get("paths", {})
components = spec.get("components", {})
schemas = components.get("schemas", {}) or {}
responses = components.get("responses", {}) or {}
missing = KEEP_PATHS - set(paths)
if missing:
raise SystemExit(
"prune_openapi: upstream spec is missing expected paths: "
+ ", ".join(sorted(missing))
)
kept_paths: dict[str, dict] = {}
for path, item in paths.items():
if path not in KEEP_PATHS:
continue
if not isinstance(item, dict):
kept_paths[path] = item
continue
for method, op in list(item.items()):
if isinstance(op, dict):
_filter_security(op)
if "security" in op and not op["security"]:
del item[method]
kept_paths[path] = item
seen: set[str] = set()
_walk_refs(kept_paths, schemas, responses, seen)
# Walk again to close over inter-schema refs we just pulled in.
while True:
before = len(seen)
for key in list(seen):
kind, name = key.split(":", 1)
target = (schemas if kind == "schemas" else responses).get(name)
if target is not None:
_walk_refs(target, schemas, responses, seen)
if len(seen) == before:
break
kept_schemas = {n: schemas[n] for n in schemas if f"schemas:{n}" in seen}
kept_responses = {n: responses[n] for n in responses if f"responses:{n}" in seen}
sec_schemes = components.get("securitySchemes", {}) or {}
kept_sec_schemes = {
n: sec_schemes[n] for n in sec_schemes if n in KEEP_SECURITY_SCHEMES
}
new_components: dict = {}
if kept_responses:
new_components["responses"] = kept_responses
if kept_sec_schemes:
new_components["securitySchemes"] = kept_sec_schemes
if kept_schemas:
new_components["schemas"] = kept_schemas
spec["paths"] = kept_paths
spec["components"] = new_components
top_security = spec.get("security")
if isinstance(top_security, list):
spec["security"] = [
e
for e in top_security
if isinstance(e, dict) and any(name in KEEP_SECURITY_SCHEMES for name in e)
]
return spec
def main(argv: list[str]) -> int:
target = Path(argv[1]) if len(argv) > 1 else Path("api/openapi.yaml")
spec = yaml.safe_load(target.read_text())
pruned = prune(spec)
target.write_text(
yaml.safe_dump(pruned, sort_keys=False, width=120, allow_unicode=True)
)
print(
f"prune_openapi: kept {len(pruned['paths'])} paths, "
f"{len(pruned['components'].get('schemas', {}))} schemas in {target}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

View File

@ -37,7 +37,7 @@ from wrenn.exceptions import (
from wrenn.models import FileEntry
from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession
__version__ = "0.1.4"
__version__ = "0.3.0"
__all__ = [
"__version__",

View File

@ -3,6 +3,8 @@ from __future__ import annotations
import asyncio
import os
import time
from collections.abc import AsyncIterator, Iterator
from datetime import date
import httpx
@ -13,10 +15,14 @@ from wrenn._config import (
ENV_BASE_URL,
ENV_PROXY_DOMAIN,
)
from wrenn.exceptions import handle_response
from wrenn.exceptions import _raise_for_status, handle_response
from wrenn.models import (
CapsuleMetrics,
CapsuleStats,
SSEEvent,
Template,
UsageResponse,
)
from wrenn.models import (
Capsule as CapsuleModel,
@ -151,6 +157,46 @@ def _snapshot_list_params(type: str | None) -> dict:
return params
def _date_param(value: str | date | None) -> str | None:
if value is None:
return None
if isinstance(value, date):
return value.isoformat()
return value
def _usage_params(from_: str | date | None, to: str | date | None) -> dict:
params: dict = {}
if (v := _date_param(from_)) is not None:
params["from"] = v
if (v := _date_param(to)) is not None:
params["to"] = v
return params
def _range_params(range: str | None) -> dict:
return {"range": range} if range is not None else {}
def _iter_sse_events(lines: Iterator[str]) -> Iterator[SSEEvent]:
"""Parse SSE ``data:`` frames into :class:`SSEEvent` objects.
Ignores ``event:`` names and ``:keepalive`` comments — the payload's
own ``event`` field carries the kind.
"""
data_lines: list[str] = []
for raw in lines:
if raw == "":
if data_lines:
yield SSEEvent.model_validate_json("\n".join(data_lines))
data_lines = []
continue
if raw.startswith(":"):
continue
if raw.startswith("data:"):
data_lines.append(raw[5:].lstrip())
class CapsulesResource:
"""Sync capsule control-plane operations."""
@ -260,6 +306,106 @@ class CapsulesResource:
resp = self._http.post(f"/v1/capsules/{id}/ping")
handle_response(resp)
def stats(self, range: str | None = None) -> CapsuleStats:
"""Get aggregate capsule usage stats for the authenticated team.
Args:
range (str | None): Time window. One of ``5m``, ``1h``, ``6h``,
``24h``, ``30d``. Defaults to ``1h`` server-side.
Returns:
CapsuleStats: Current running counts plus 30-day peaks and a
chart-ready time series.
Example::
stats = wrenn.capsules.stats(range="24h")
print(stats.current.running_count, stats.peaks.vcpus)
"""
resp = self._http.get("/v1/capsules/stats", params=_range_params(range))
return CapsuleStats.model_validate(handle_response(resp))
def usage(
self,
from_: str | date | None = None,
to: str | date | None = None,
) -> UsageResponse:
"""Get per-day CPU and RAM usage for the team.
Args:
from_ (str | date | None): Start date (``YYYY-MM-DD`` string or
``date``). Defaults to 30 days ago server-side.
to (str | date | None): End date. Defaults to today.
Returns:
UsageResponse: Daily ``cpu_minutes`` / ``ram_mb_minutes`` points.
Example::
from datetime import date, timedelta
today = date.today()
usage = wrenn.capsules.usage(from_=today - timedelta(days=7), to=today)
for point in usage.points:
print(point.date, point.cpu_minutes, point.ram_mb_minutes)
"""
resp = self._http.get("/v1/capsules/usage", params=_usage_params(from_, to))
return UsageResponse.model_validate(handle_response(resp))
def metrics(self, id: str, range: str | None = None) -> CapsuleMetrics:
"""Get time-series CPU, memory, and disk metrics for a capsule.
Args:
id (str): Capsule ID.
range (str | None): One of ``10m`` (500ms samples),
``2h`` (30s averages), ``24h`` (5-minute averages). Defaults
to ``10m`` server-side.
Returns:
CapsuleMetrics: Sampled :class:`MetricPoint` series.
Raises:
WrennNotFoundError: If the capsule does not exist or has been
destroyed.
Example::
m = wrenn.capsules.metrics("sb-abc123", range="2h")
for point in m.points:
print(point.timestamp_unix, point.cpu_pct, point.mem_bytes)
"""
resp = self._http.get(f"/v1/capsules/{id}/metrics", params=_range_params(range))
return CapsuleMetrics.model_validate(handle_response(resp))
class EventsResource:
"""Sync server-sent event stream of capsule/template/host lifecycle events."""
def __init__(self, http: httpx.Client) -> None:
self._http = http
def stream(self) -> Iterator[SSEEvent]:
"""Stream lifecycle events for the team in real time.
The connection is held open by the server; iterate the result to
receive :class:`SSEEvent` payloads as they arrive. Close the
iterator (or break out of the loop) to disconnect.
Yields:
SSEEvent: One event per server frame.
Example::
with WrennClient() as wrenn:
for ev in wrenn.events.stream():
print(ev.event, ev.resource)
"""
with self._http.stream("GET", "/v1/events/stream", timeout=None) as resp:
if resp.status_code >= 400:
resp.read()
_raise_for_status(resp)
yield from _iter_sse_events(resp.iter_lines())
class AsyncCapsulesResource:
"""Async capsule control-plane operations."""
@ -370,6 +516,118 @@ class AsyncCapsulesResource:
resp = await self._http.post(f"/v1/capsules/{id}/ping")
handle_response(resp)
async def stats(self, range: str | None = None) -> CapsuleStats:
"""Get aggregate capsule usage stats for the authenticated team.
Args:
range (str | None): Time window. One of ``5m``, ``1h``, ``6h``,
``24h``, ``30d``. Defaults to ``1h`` server-side.
Returns:
CapsuleStats: Current running counts plus 30-day peaks and a
chart-ready time series.
Example::
stats = await wrenn.capsules.stats(range="24h")
print(stats.current.running_count, stats.peaks.vcpus)
"""
resp = await self._http.get("/v1/capsules/stats", params=_range_params(range))
return CapsuleStats.model_validate(handle_response(resp))
async def usage(
self,
from_: str | date | None = None,
to: str | date | None = None,
) -> UsageResponse:
"""Get per-day CPU and RAM usage for the team.
Args:
from_ (str | date | None): Start date (``YYYY-MM-DD`` string or
``date``). Defaults to 30 days ago server-side.
to (str | date | None): End date. Defaults to today.
Returns:
UsageResponse: Daily ``cpu_minutes`` / ``ram_mb_minutes`` points.
Example::
from datetime import date, timedelta
today = date.today()
usage = await wrenn.capsules.usage(
from_=today - timedelta(days=7), to=today
)
for point in usage.points:
print(point.date, point.cpu_minutes, point.ram_mb_minutes)
"""
resp = await self._http.get(
"/v1/capsules/usage", params=_usage_params(from_, to)
)
return UsageResponse.model_validate(handle_response(resp))
async def metrics(self, id: str, range: str | None = None) -> CapsuleMetrics:
"""Get time-series CPU, memory, and disk metrics for a capsule.
Args:
id (str): Capsule ID.
range (str | None): One of ``10m`` (500ms samples),
``2h`` (30s averages), ``24h`` (5-minute averages). Defaults
to ``10m`` server-side.
Returns:
CapsuleMetrics: Sampled :class:`MetricPoint` series.
Raises:
WrennNotFoundError: If the capsule does not exist or has been
destroyed.
Example::
m = await wrenn.capsules.metrics("sb-abc123", range="2h")
for point in m.points:
print(point.timestamp_unix, point.cpu_pct, point.mem_bytes)
"""
resp = await self._http.get(
f"/v1/capsules/{id}/metrics", params=_range_params(range)
)
return CapsuleMetrics.model_validate(handle_response(resp))
class AsyncEventsResource:
"""Async server-sent event stream of capsule/template/host lifecycle events."""
def __init__(self, http: httpx.AsyncClient) -> None:
self._http = http
async def stream(self) -> AsyncIterator[SSEEvent]:
"""Stream lifecycle events for the team in real time.
Yields:
SSEEvent: One event per server frame.
Example::
async with AsyncWrennClient() as wrenn:
async for ev in wrenn.events.stream():
print(ev.event, ev.resource)
"""
async with self._http.stream("GET", "/v1/events/stream", timeout=None) as resp:
if resp.status_code >= 400:
await resp.aread()
_raise_for_status(resp)
data_lines: list[str] = []
async for raw in resp.aiter_lines():
if raw == "":
if data_lines:
yield SSEEvent.model_validate_json("\n".join(data_lines))
data_lines = []
continue
if raw.startswith(":"):
continue
if raw.startswith("data:"):
data_lines.append(raw[5:].lstrip())
class SnapshotsResource:
"""Sync snapshot operations."""
@ -486,7 +744,11 @@ class AsyncSnapshotsResource:
class WrennClient:
"""Synchronous client for the Wrenn API.
Authenticates with an API key.
Authenticates with an API key. Exposes three resources:
- :attr:`capsules` — capsule lifecycle, stats, usage, metrics
- :attr:`snapshots` — template snapshot management
- :attr:`events` — server-sent lifecycle event stream
Args:
api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var.
@ -497,6 +759,15 @@ class WrennClient:
is the default ``app.wrenn.dev`` host, else the ``base_url`` host.
timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
or ``None`` for the default (30s read/write/pool, 10s connect).
Example::
from wrenn import WrennClient
with WrennClient() as wrenn: # reads WRENN_API_KEY
capsule = wrenn.capsules.create(template="minimal-ubuntu")
print(capsule.id, capsule.status)
wrenn.capsules.destroy(capsule.id)
"""
def __init__(
@ -517,6 +788,7 @@ class WrennClient:
self.capsules = CapsulesResource(self._http)
self.snapshots = SnapshotsResource(self._http)
self.events = EventsResource(self._http)
@property
def http(self) -> httpx.Client:
@ -542,7 +814,8 @@ class WrennClient:
class AsyncWrennClient:
"""Asynchronous client for the Wrenn API.
Authenticates with an API key.
Authenticates with an API key. Mirrors :class:`WrennClient` with
``await``-able methods on every resource.
Args:
api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var.
@ -553,6 +826,16 @@ class AsyncWrennClient:
is the default ``app.wrenn.dev`` host, else the ``base_url`` host.
timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
or ``None`` for the default (30s read/write/pool, 10s connect).
Example::
from wrenn import AsyncWrennClient
async with AsyncWrennClient() as wrenn:
capsule = await wrenn.capsules.create(template="minimal-ubuntu")
async for event in wrenn.events.stream():
if event.resource and event.resource.id == capsule.id:
break
"""
def __init__(
@ -573,6 +856,7 @@ class AsyncWrennClient:
self.capsules = AsyncCapsulesResource(self._http)
self.snapshots = AsyncSnapshotsResource(self._http)
self.events = AsyncEventsResource(self._http)
@property
def http(self) -> httpx.AsyncClient:

View File

@ -1,17 +1,31 @@
from wrenn.models._generated import (
Actor,
Capsule,
CapsuleMetrics,
CapsuleStats,
FileEntry,
ListDirResponse,
MakeDirResponse,
MetricPoint,
Resource,
SSEEvent,
Status,
Template,
UsageResponse,
)
__all__ = [
"Actor",
"Capsule",
"CapsuleMetrics",
"CapsuleStats",
"FileEntry",
"ListDirResponse",
"MakeDirResponse",
"MetricPoint",
"Resource",
"SSEEvent",
"Status",
"Template",
"UsageResponse",
]

View File

@ -1,11 +1,91 @@
# generated by datamodel-codegen:
# filename: openapi.yaml
# timestamp: 2026-05-23T11:20:02+00:00
# timestamp: 2026-06-22T22:24:45+00:00
from __future__ import annotations
from pydantic import AwareDatetime, BaseModel, Field
from typing import Annotated
from datetime import date as date_aliased
from enum import StrEnum
from typing import Annotated
from pydantic import AwareDatetime, BaseModel, Field
class CreateCapsuleRequest(BaseModel):
template: str | None = "minimal-ubuntu"
vcpus: int | None = 1
memory_mb: int | None = 512
timeout_sec: Annotated[
int | None,
Field(
description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause. Positive values below 60 are silently clamped to 60 (the agent's startup envelope).\n",
ge=0,
),
] = 0
metadata: Annotated[
dict[str, str] | None,
Field(
description="Optional user-supplied key/value labels attached to the capsule. Max 20 keys; keys match [a-zA-Z0-9][a-zA-Z0-9._-]{0,63}; values are at most 64 characters. Reserved system keys (kernel_version, vmm_version, agent_version, envd_version) are rejected. Metadata is set at create-time only.\n"
),
] = None
class Point(BaseModel):
date: date_aliased | None = None
cpu_minutes: float | None = None
ram_mb_minutes: float | None = None
class UsageResponse(BaseModel):
from_: Annotated[date_aliased | None, Field(alias="from")] = None
to: date_aliased | None = None
points: list[Point] | None = None
class Range(StrEnum):
field_5m = "5m"
field_1h = "1h"
field_6h = "6h"
field_24h = "24h"
field_30d = "30d"
class Current(BaseModel):
running_count: int | None = None
vcpus_reserved: int | None = None
memory_mb_reserved: int | None = None
class Peaks(BaseModel):
"""
Maximum values over the last 30 days.
"""
running_count: int | None = None
vcpus: int | None = None
memory_mb: int | None = None
class Series(BaseModel):
"""
Parallel arrays for chart rendering.
"""
labels: list[AwareDatetime] | None = None
running: list[int] | None = None
vcpus: list[int] | None = None
memory_mb: list[int] | None = None
class CapsuleStats(BaseModel):
range: Range | None = None
current: Current | None = None
peaks: Annotated[
Peaks | None, Field(description="Maximum values over the last 30 days.")
] = None
series: Annotated[
Series | None, Field(description="Parallel arrays for chart rendering.")
] = None
class Status(StrEnum):
@ -37,7 +117,7 @@ class Capsule(BaseModel):
metadata: Annotated[
dict[str, str] | None,
Field(
description="Free-form key/value labels attached at create-time. Also carries\nagent-side version info (kernel_version, vmm_version,\nagent_version, envd_version) when running.\n"
description="User-supplied key/value labels attached at create-time. Once the\ncapsule is running this also carries agent-side system version info\n(kernel_version, vmm_version, agent_version, envd_version); those\nreserved keys are owned by the system and cannot be set by users.\n"
),
] = None
disk_size_mb: Annotated[
@ -51,6 +131,16 @@ class Capsule(BaseModel):
] = None
class CreateSnapshotRequest(BaseModel):
sandbox_id: Annotated[
str, Field(description="ID of the running capsule to snapshot.")
]
name: Annotated[
str | None,
Field(description="Name for the snapshot template. Auto-generated if omitted."),
] = None
class Type(StrEnum):
base = "base"
snapshot = "snapshot"
@ -78,7 +168,96 @@ class Template(BaseModel):
metadata: dict[str, str] | None = None
class Type2(StrEnum):
class ExecRequest(BaseModel):
cmd: str
args: list[str] | None = None
timeout_sec: Annotated[
int | None,
Field(description="Timeout in seconds (foreground exec only, default 30)"),
] = 30
background: Annotated[
bool | None,
Field(
description="If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202)"
),
] = False
tag: Annotated[
str | None,
Field(
description="Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true."
),
] = None
envs: Annotated[
dict[str, str] | None,
Field(
description="Environment variables for the process (applies to both foreground and background exec)"
),
] = None
cwd: Annotated[
str | None,
Field(
description="Working directory for the process (applies to both foreground and background exec)"
),
] = None
class BackgroundExecResponse(BaseModel):
sandbox_id: str | None = None
cmd: str | None = None
pid: int | None = None
tag: str | None = None
class ProcessEntry(BaseModel):
pid: int | None = None
tag: str | None = None
cmd: str | None = None
args: list[str] | None = None
class ProcessListResponse(BaseModel):
processes: list[ProcessEntry] | None = None
class Encoding(StrEnum):
"""
Output encoding. "base64" when stdout/stderr contain binary data.
"""
utf_8 = "utf-8"
base64 = "base64"
class ExecResponse(BaseModel):
sandbox_id: str | None = None
cmd: str | None = None
stdout: str | None = None
stderr: str | None = None
exit_code: int | None = None
duration_ms: int | None = None
encoding: Annotated[
Encoding | None,
Field(
description='Output encoding. "base64" when stdout/stderr contain binary data.'
),
] = None
class ReadFileRequest(BaseModel):
path: Annotated[str, Field(description="Absolute file path inside the capsule")]
class ListDirRequest(BaseModel):
path: Annotated[str, Field(description="Directory path inside the capsule")]
depth: Annotated[
int | None,
Field(
description="Recursion depth (0 = non-recursive, 1 = immediate children)"
),
] = 1
class Type1(StrEnum):
file = "file"
directory = "directory"
symlink = "symlink"
@ -87,7 +266,7 @@ class Type2(StrEnum):
class FileEntry(BaseModel):
name: str | None = None
path: str | None = None
type: Type2 | None = None
type: Type1 | None = None
size: int | None = None
mode: int | None = None
permissions: Annotated[
@ -105,5 +284,127 @@ class MakeDirResponse(BaseModel):
entry: FileEntry | None = None
class RemoveRequest(BaseModel):
path: Annotated[str, Field(description="Path to remove inside the capsule")]
class Range1(StrEnum):
field_5m = "5m"
field_10m = "10m"
field_1h = "1h"
field_2h = "2h"
field_6h = "6h"
field_12h = "12h"
field_24h = "24h"
class MetricPoint(BaseModel):
timestamp_unix: int | None = None
cpu_pct: Annotated[
float | None,
Field(
description="CPU utilization percentage (0-100), normalized to vCPU count"
),
] = None
mem_bytes: Annotated[
int | None,
Field(
description="Resident memory in bytes (VmRSS of Cloud Hypervisor process)"
),
] = None
disk_bytes: Annotated[
int | None, Field(description="Allocated disk bytes for the CoW sparse file")
] = None
class Error1(BaseModel):
code: str | None = None
message: str | None = None
class Error(BaseModel):
error: Error1 | None = None
class Event(StrEnum):
connected = "connected"
capsule_create = "capsule.create"
capsule_pause = "capsule.pause"
capsule_resume = "capsule.resume"
capsule_destroy = "capsule.destroy"
capsule_state_changed = "capsule.state.changed"
template_snapshot_create = "template.snapshot.create"
template_snapshot_delete = "template.snapshot.delete"
host_up = "host.up"
host_down = "host.down"
class Outcome(StrEnum):
"""
Present for action events (capsule.* except state.changed,
template.snapshot.*). Absent for host.up/down, capsule.state.changed,
and the connected sentinel.
"""
success = "success"
error = "error"
class Resource(BaseModel):
id: str | None = None
type: str | None = None
class Type2(StrEnum):
user = "user"
api_key = "api_key"
system = "system"
class Actor(BaseModel):
type: Type2 | None = None
id: str | None = None
name: str | None = None
class SSEEvent(BaseModel):
"""
Wire format of one SSE message body. The event name (`event:` line) is
the `kind` and the JSON below is the `data:` line.
"""
event: Event | None = None
outcome: Annotated[
Outcome | None,
Field(
description="Present for action events (capsule.* except state.changed,\ntemplate.snapshot.*). Absent for host.up/down, capsule.state.changed,\nand the connected sentinel.\n"
),
] = None
resource: Resource | None = None
actor: Actor | None = None
metadata: Annotated[
dict[str, str] | None,
Field(
description="Event-specific context. Examples: `reason` (ttl_expired,\nhost_failure, cleanup_after_create_error, orphaned),\n`host_ip`, `from`/`to` (for capsule.state.changed).\n"
),
] = None
error: Annotated[
str | None, Field(description="Failure reason; only set when outcome=error.")
] = None
sandbox: Annotated[
Capsule | None,
Field(description="Populated for capsule.* events; null if DB lookup failed."),
] = None
timestamp: AwareDatetime | None = None
class ListDirResponse(BaseModel):
entries: list[FileEntry] | None = None
class CapsuleMetrics(BaseModel):
sandbox_id: str | None = None
range: Range1 | None = None
points: list[MetricPoint] | None = None

View File

@ -1,6 +1,5 @@
from __future__ import annotations
import base64
import json
from collections.abc import AsyncIterator, Iterator
from enum import StrEnum
@ -8,6 +7,7 @@ from typing import Any
import httpx_ws
from pydantic import BaseModel
from wsproto.events import BytesMessage, TextMessage
# A clean (``WebSocketDisconnect``) or abrupt (``WebSocketNetworkError``) close
# both mean the PTY stream has ended; iteration must stop on either.
@ -31,7 +31,8 @@ class PtyEvent(BaseModel):
fatal: bool | None = None
def _parse_pty_event(raw: dict[str, Any]) -> PtyEvent:
def _parse_control_event(raw: dict[str, Any]) -> PtyEvent:
"""Parse a JSON control frame from the server."""
msg_type = raw.get("type", "")
if msg_type == "started":
return PtyEvent(
@ -39,10 +40,6 @@ def _parse_pty_event(raw: dict[str, Any]) -> PtyEvent:
pid=raw.get("pid"),
tag=raw.get("tag"),
)
if msg_type == "output":
raw_data = raw.get("data", "")
decoded = base64.b64decode(raw_data) if raw_data else b""
return PtyEvent(type=PtyEventType.output, data=decoded)
if msg_type == "exit":
return PtyEvent(type=PtyEventType.exit, exit_code=raw.get("exit_code", -1))
if msg_type == "error":
@ -133,10 +130,10 @@ class PtySession:
"""Send raw bytes to the PTY stdin.
Args:
data: Raw bytes to send. Base64-encoded internally.
data: Raw bytes to send. Delivered as a binary WebSocket frame
verbatim — no JSON wrapping, no base64.
"""
encoded = base64.b64encode(data).decode("ascii")
self._ws.send_text(json.dumps({"type": "input", "data": encoded}))
self._ws.send_bytes(data)
def resize(self, cols: int, rows: int) -> None:
"""Resize the PTY terminal.
@ -160,27 +157,34 @@ class PtySession:
return self
def __next__(self) -> PtyEvent:
if self._done:
raise StopIteration
try:
raw = self._ws.receive_text()
except _WS_CLOSED:
raise StopIteration
event = _parse_pty_event(json.loads(raw))
if event.type == PtyEventType.started:
if event.tag is not None:
self._tag = event.tag
if event.pid is not None:
self._pid = event.pid
if event.type == PtyEventType.ping:
self._send_pong()
if event.type == PtyEventType.exit:
self._done = True
while True:
if self._done:
raise StopIteration
try:
msg = self._ws.receive()
except _WS_CLOSED:
raise StopIteration
if isinstance(msg, BytesMessage):
return PtyEvent(type=PtyEventType.output, data=msg.data)
if isinstance(msg, TextMessage):
event = _parse_control_event(json.loads(msg.data))
else:
# Ignore other wsproto events (Ping/Pong/etc handled by httpx_ws).
continue
if event.type == PtyEventType.started:
if event.tag is not None:
self._tag = event.tag
if event.pid is not None:
self._pid = event.pid
if event.type == PtyEventType.ping:
self._send_pong()
if event.type == PtyEventType.exit:
self._done = True
return event
if event.type == PtyEventType.error and event.fatal:
self._done = True
return event
return event
if event.type == PtyEventType.error and event.fatal:
self._done = True
return event
return event
def __enter__(self) -> PtySession:
return self
@ -269,10 +273,10 @@ class AsyncPtySession:
"""Send raw bytes to the PTY stdin.
Args:
data: Raw bytes to send. Base64-encoded internally.
data: Raw bytes to send. Delivered as a binary WebSocket frame
verbatim — no JSON wrapping, no base64.
"""
encoded = base64.b64encode(data).decode("ascii")
await self._ws.send_text(json.dumps({"type": "input", "data": encoded}))
await self._ws.send_bytes(data)
async def resize(self, cols: int, rows: int) -> None:
"""Resize the PTY terminal.
@ -298,27 +302,34 @@ class AsyncPtySession:
return self
async def __anext__(self) -> PtyEvent:
if self._done:
raise StopAsyncIteration
try:
raw = await self._ws.receive_text()
except _WS_CLOSED:
raise StopAsyncIteration
event = _parse_pty_event(json.loads(raw))
if event.type == PtyEventType.started:
if event.tag is not None:
self._tag = event.tag
if event.pid is not None:
self._pid = event.pid
if event.type == PtyEventType.ping:
await self._send_pong()
if event.type == PtyEventType.exit:
self._done = True
while True:
if self._done:
raise StopAsyncIteration
try:
msg = await self._ws.receive()
except _WS_CLOSED:
raise StopAsyncIteration
if isinstance(msg, BytesMessage):
return PtyEvent(type=PtyEventType.output, data=msg.data)
if isinstance(msg, TextMessage):
event = _parse_control_event(json.loads(msg.data))
else:
# Ignore other wsproto events (Ping/Pong/etc handled by httpx_ws).
continue
if event.type == PtyEventType.started:
if event.tag is not None:
self._tag = event.tag
if event.pid is not None:
self._pid = event.pid
if event.type == PtyEventType.ping:
await self._send_pong()
if event.type == PtyEventType.exit:
self._done = True
return event
if event.type == PtyEventType.error and event.fatal:
self._done = True
return event
return event
if event.type == PtyEventType.error and event.fatal:
self._done = True
return event
return event
async def __aenter__(self) -> AsyncPtySession:
return self

View File

@ -13,9 +13,16 @@ from wrenn.exceptions import (
WrennValidationError,
)
from wrenn.models import (
Actor,
Capsule,
CapsuleMetrics,
CapsuleStats,
MetricPoint,
Resource,
SSEEvent,
Status,
Template,
UsageResponse,
)
BASE = "https://app.wrenn.dev/api"
@ -103,6 +110,212 @@ class TestCapsules:
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
@ -262,6 +475,156 @@ class TestAsyncClient:
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):

View File

@ -430,12 +430,10 @@ class TestCodeRunnerMimeTypes:
def test_requests_status_code(self):
ex = self._run(
"import requests\n"
"r = requests.get('https://httpbin.org/status/204', timeout=10)\n"
"r = requests.get('http://httpbingo.org/status/418', timeout=10)\n"
"r.status_code\n"
)
if ex.error is not None:
pytest.skip(f"network unavailable: {ex.error.name}")
assert ex.text == "204"
assert ex.text == "418"
class TestCodeRunnerIsolation:

View File

@ -388,6 +388,214 @@ class TestJupyterRequest:
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) ─────────────────────────

View File

@ -1,11 +1,11 @@
from __future__ import annotations
import base64
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
import respx
from wsproto.events import BytesMessage, TextMessage
from wrenn.capsule import Capsule
from wrenn.models import FileEntry
@ -13,9 +13,18 @@ from wrenn.pty import (
AsyncPtySession,
PtyEventType,
PtySession,
_parse_pty_event,
_parse_control_event,
)
def _text(payload: dict) -> TextMessage:
return TextMessage(data=json.dumps(payload))
def _bytes(data: bytes) -> BytesMessage:
return BytesMessage(data=data)
BASE = "https://app.wrenn.dev/api"
@ -226,50 +235,37 @@ class TestFilesExists:
class TestPtyEventParsing:
def test_started_event(self):
raw = {"type": "started", "tag": "pty-a1b2c3d4", "pid": 42}
event = _parse_pty_event(raw)
event = _parse_control_event(raw)
assert event.type == PtyEventType.started
assert event.pid == 42
assert event.tag == "pty-a1b2c3d4"
def test_output_event_base64(self):
encoded = base64.b64encode(b"ls -la\n").decode()
raw = {"type": "output", "data": encoded}
event = _parse_pty_event(raw)
assert event.type == PtyEventType.output
assert event.data == b"ls -la\n"
def test_output_event_empty(self):
raw = {"type": "output", "data": ""}
event = _parse_pty_event(raw)
assert event.data == b""
def test_exit_event(self):
raw = {"type": "exit", "exit_code": 0}
event = _parse_pty_event(raw)
event = _parse_control_event(raw)
assert event.type == PtyEventType.exit
assert event.exit_code == 0
def test_error_event(self):
raw = {"type": "error", "data": "process not found", "fatal": True}
event = _parse_pty_event(raw)
event = _parse_control_event(raw)
assert event.type == PtyEventType.error
assert event.data == "process not found"
assert event.fatal is True
def test_ping_event(self):
raw = {"type": "ping"}
event = _parse_pty_event(raw)
event = _parse_control_event(raw)
assert event.type == PtyEventType.ping
class TestPtySessionWrite:
def test_write_sends_base64_input(self):
def test_write_sends_binary_frame(self):
ws = MagicMock()
session = PtySession(ws, "cl-abc")
session.write(b"ls -la\n")
sent = json.loads(ws.send_text.call_args[0][0])
assert sent["type"] == "input"
assert base64.b64decode(sent["data"]) == b"ls -la\n"
ws.send_bytes.assert_called_once_with(b"ls -la\n")
ws.send_text.assert_not_called()
class TestPtySessionResize:
@ -303,12 +299,11 @@ class TestPtySessionKill:
class TestPtySessionIteration:
def test_iter_yields_events_until_exit(self):
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": "exit", "exit_code": 0}),
ws.receive.side_effect = [
_text({"type": "started", "tag": "pty-abc12345", "pid": 1}),
_bytes(b"hello"),
_text({"type": "exit", "exit_code": 0}),
]
ws.receive_text.side_effect = messages
session = PtySession(ws, "cl-abc")
events = list(session)
assert len(events) == 3
@ -320,12 +315,22 @@ class TestPtySessionIteration:
assert events[2].type == PtyEventType.exit
assert events[2].exit_code == 0
def test_iter_yields_empty_binary_frame(self):
ws = MagicMock()
ws.receive.side_effect = [
_bytes(b""),
_text({"type": "exit", "exit_code": 0}),
]
session = PtySession(ws, "cl-abc")
events = list(session)
assert events[0].type == PtyEventType.output
assert events[0].data == b""
def test_iter_stops_on_fatal_error(self):
ws = MagicMock()
messages = [
json.dumps({"type": "error", "data": "fatal", "fatal": True}),
ws.receive.side_effect = [
_text({"type": "error", "data": "fatal", "fatal": True}),
]
ws.receive_text.side_effect = messages
session = PtySession(ws, "cl-abc")
events = list(session)
assert len(events) == 1
@ -335,7 +340,7 @@ class TestPtySessionIteration:
import httpx_ws
ws = MagicMock()
ws.receive_text.side_effect = httpx_ws.WebSocketDisconnect()
ws.receive.side_effect = httpx_ws.WebSocketDisconnect()
session = PtySession(ws, "cl-abc")
events = list(session)
assert events == []
@ -344,9 +349,9 @@ class TestPtySessionIteration:
class TestPtySessionPong:
def test_ping_triggers_pong(self):
ws = MagicMock()
ws.receive_text.side_effect = [
json.dumps({"type": "ping"}),
json.dumps({"type": "exit", "exit_code": 0}),
ws.receive.side_effect = [
_text({"type": "ping"}),
_text({"type": "exit", "exit_code": 0}),
]
session = PtySession(ws, "cl-abc")
events = list(session)
@ -356,9 +361,9 @@ class TestPtySessionPong:
def test_no_pong_without_ping(self):
ws = MagicMock()
ws.receive_text.side_effect = [
json.dumps({"type": "output", "data": ""}),
json.dumps({"type": "exit", "exit_code": 0}),
ws.receive.side_effect = [
_bytes(b""),
_text({"type": "exit", "exit_code": 0}),
]
session = PtySession(ws, "cl-abc")
list(session)
@ -431,13 +436,12 @@ class TestPtySessionSendConnect:
class TestAsyncPtySession:
@pytest.mark.asyncio
async def test_async_write_sends_base64(self):
async def test_async_write_sends_binary_frame(self):
ws = AsyncMock()
session = AsyncPtySession(ws, "cl-abc")
await session.write(b"hello")
sent = json.loads(ws.send_text.call_args[0][0])
assert sent["type"] == "input"
assert base64.b64decode(sent["data"]) == b"hello"
ws.send_bytes.assert_awaited_once_with(b"hello")
ws.send_text.assert_not_called()
@pytest.mark.asyncio
async def test_async_resize(self):
@ -486,9 +490,9 @@ class TestAsyncPtySession:
@pytest.mark.asyncio
async def test_async_ping_triggers_pong(self):
ws = AsyncMock()
ws.receive_text.side_effect = [
json.dumps({"type": "ping"}),
json.dumps({"type": "exit", "exit_code": 0}),
ws.receive.side_effect = [
_text({"type": "ping"}),
_text({"type": "exit", "exit_code": 0}),
]
session = AsyncPtySession(ws, "cl-abc")
events = [e async for e in session]
@ -508,12 +512,11 @@ class TestAsyncPtySession:
@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": "exit", "exit_code": 0}),
ws.receive.side_effect = [
_text({"type": "started", "tag": "pty-xyz", "pid": 5}),
_bytes(b"hi"),
_text({"type": "exit", "exit_code": 0}),
]
ws.receive_text.side_effect = messages
session = AsyncPtySession(ws, "cl-abc")
events = []
async for event in session:
@ -522,6 +525,8 @@ class TestAsyncPtySession:
assert events[0].type == PtyEventType.started
assert session.tag == "pty-xyz"
assert session.pid == 5
assert events[1].type == PtyEventType.output
assert events[1].data == b"hi"
assert events[2].type == PtyEventType.exit

View File

@ -7,8 +7,18 @@ from pathlib import Path
import pytest
from wrenn import Capsule, CommandResult
from wrenn.client import WrennClient
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
@ -406,3 +416,124 @@ class TestGit:
def test_get_config_missing_returns_none(self):
value = self.capsule.git.get_config("nonexistent.key")
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"

28
uv.lock generated
View File

@ -285,15 +285,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "docspec"
version = "2.2.1"
@ -328,19 +319,6 @@ version = "0.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/ce/5d6a3782b9f88097ce3e579265015db3372ae78d12f67629b863a9208c96/docstring_parser-0.11.tar.gz", hash = "sha256:93b3f8f481c7d24e37c5d9f30293c89e2933fa209421c8abd731dd3ef0715ecb", size = 22775, upload-time = "2021-09-30T07:44:10.288Z" }
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]]
name = "filelock"
version = "3.29.0"
@ -1166,11 +1144,10 @@ wheels = [
[[package]]
name = "wrenn"
version = "0.2.0"
version = "0.3.0"
source = { editable = "." }
dependencies = [
{ name = "certifi" },
{ name = "email-validator" },
{ name = "httpx" },
{ name = "httpx-ws" },
{ name = "pydantic" },
@ -1184,6 +1161,7 @@ dev = [
{ name = "pydoc-markdown" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pyyaml" },
{ name = "respx" },
{ name = "ruff" },
]
@ -1191,7 +1169,6 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "certifi", specifier = ">=2026.2.25" },
{ name = "email-validator", specifier = ">=2.3.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "httpx-ws", specifier = ">=0.9.0" },
{ name = "pydantic", specifier = ">=2.12.5" },
@ -1205,6 +1182,7 @@ dev = [
{ name = "pydoc-markdown", specifier = ">=4.8.2" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "respx", specifier = ">=0.23.1" },
{ name = "ruff", specifier = ">=0.15.10" },
]