3 Commits
v0.1.4 ... main

Author SHA1 Message Date
45f7f467c5 v0.3.0 (#15)
All checks were successful
ci/woodpecker/push/unit Pipeline was successful
## What's New?

- Bugfixes
- Updated test suites

Reviewed-on: #15
Co-authored-by: pptx704 <rafeed@omukk.dev>
Co-committed-by: pptx704 <rafeed@omukk.dev>
2026-06-25 22:11:25 +00:00
4924237a23 v0.2.0 (#14)
All checks were successful
ci/woodpecker/push/unit Pipeline was successful
Co-authored-by: Tasnim Kabir Sadik <tksadik92@gmail.com>
Reviewed-on: #14
Co-authored-by: pptx704 <rafeed@omukk.dev>
Co-committed-by: pptx704 <rafeed@omukk.dev>
2026-05-24 05:02:08 +00:00
de72dfe9c8 v0.1.5 (#13)
All checks were successful
ci/woodpecker/push/unit Pipeline was successful
Co-authored-by: Tasnim Kabir Sadik <tksadik92@gmail.com>
Reviewed-on: #13
2026-05-22 23:01:46 +00:00
20 changed files with 2267 additions and 3728 deletions

View File

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

View File

@ -1,5 +1,5 @@
# Makefile # 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 # Variables
SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml" 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) curl -fsSL $(SPEC_URL) -o $(SPEC_PATH)
$(MAKE) prune-spec
uv run datamodel-codegen \ uv run datamodel-codegen \
--input $(SPEC_PATH) \ --input $(SPEC_PATH) \
--output src/wrenn/models/_generated.py \ --output src/wrenn/models/_generated.py \
@ -40,6 +42,10 @@ test-code-runner:
check: lint test 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: gen-docs:
mkdir -p docs mkdir -p docs
uv run pydoc-markdown > docs/reference.md uv run pydoc-markdown > docs/reference.md

View File

@ -172,6 +172,8 @@ import sys
# Stream a new command # Stream a new command
for event in capsule.commands.stream("python", args=["-u", "train.py"]): for event in capsule.commands.stream("python", args=["-u", "train.py"]):
match event.type: match event.type:
case "start":
print(f"PID: {event.pid}")
case "stdout": case "stdout":
print(event.data, end="") print(event.data, end="")
case "stderr": case "stderr":
@ -181,8 +183,11 @@ for event in capsule.commands.stream("python", args=["-u", "train.py"]):
# Connect to a running background process # Connect to a running background process
for event in capsule.commands.connect(handle.pid): for event in capsule.commands.connect(handle.pid):
if event.type == "stdout": match event.type:
print(event.data, end="") case "start":
print(f"PID: {event.pid}")
case "stdout":
print(event.data, end="")
``` ```
#### Process Management #### Process Management
@ -211,6 +216,7 @@ capsule.files.exists("/app/main.py") # True
# List directory # List directory
entries = capsule.files.list("/home/user", depth=1) entries = capsule.files.list("/home/user", depth=1)
# FileEntry has: name, type (file/dir), size, modified_at
for entry in entries: for entry in entries:
print(entry.name, entry.type, entry.size) print(entry.name, entry.type, entry.size)
@ -289,8 +295,27 @@ value = capsule.git.get_config("user.name", cwd="/app") # str | None
capsule.git.remote_add("upstream", "https://github.com/org/repo.git", cwd="/app") capsule.git.remote_add("upstream", "https://github.com/org/repo.git", cwd="/app")
url = capsule.git.remote_get("origin", cwd="/app") # str | None url = capsule.git.remote_get("origin", cwd="/app") # str | None
# Reset and restore
capsule.git.reset(mode="hard", ref="HEAD~1", cwd="/app")
capsule.git.restore(["file.txt"], staged=True, cwd="/app")
``` ```
#### Persistent Credential Store
For workflows that need repeated authenticated operations, you can persist credentials via the git credential store:
```python
capsule.git.dangerously_authenticate(
username="user",
password="ghp_token",
host="github.com",
protocol="https",
)
```
> **Warning:** Credentials are written in plaintext inside the capsule and are accessible to any process running there. Prefer per-operation `username`/`password` on `clone`, `push`, and `pull` instead.
Git errors raise `GitCommandError` (or `GitAuthError` for authentication failures), both inheriting from `GitError`: Git errors raise `GitCommandError` (or `GitAuthError` for authentication failures), both inheriting from `GitError`:
```python ```python
@ -308,7 +333,7 @@ except GitAuthError as e:
```python ```python
import sys import sys
with capsule.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term: with capsule.pty(cmd="/bin/bash", cols=80, rows=24, cwd="/home/user") as term:
term.write(b"ls -la\n") term.write(b"ls -la\n")
for event in term: for event in term:
if event.type == "output": if event.type == "output":
@ -451,9 +476,10 @@ result = capsule.run_code("print('running on custom template')")
| `logs` | `Logs` | `.stdout: list[str]` and `.stderr: list[str]` chunks | | `logs` | `Logs` | `.stdout: list[str]` and `.stderr: list[str]` chunks |
| `error` | `ExecutionError \| None` | `.name`, `.value`, `.traceback` | | `error` | `ExecutionError \| None` | `.name`, `.value`, `.traceback` |
| `execution_count` | `int \| None` | Jupyter cell execution counter | | `execution_count` | `int \| None` | Jupyter cell execution counter |
| `timed_out` | `bool` | ``True`` when execution was cut short by the timeout |
| `text` | `str \| None` | (property) `text/plain` of the main `execute_result` | | `text` | `str \| None` | (property) `text/plain` of the main `execute_result` |
Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `pdf`, `latex`, `json`, `javascript`, plus `extra` for unknown types. The `text` field is Jupyter's `text/plain` bundle verbatim — the Python `repr()` of the cell's last expression. So `run_code("'hi'").text` is `"'hi'"` (with quotes), and `run_code("42").text` is `"42"`. This preserves the distinction between the string `'2'` and the int `2`. Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `gif`, `pdf`, `latex`, `json`, `javascript`, `plotly`, plus `extra` for unknown types. The `text` field is Jupyter's `text/plain` bundle verbatim — the Python `repr()` of the cell's last expression. So `run_code("'hi'").text` is `"'hi'"` (with quotes), and `run_code("42").text` is `"42"`. This preserves the distinction between the string `'2'` and the int `2`.
### Code Runner + Commands/Files ### Code Runner + Commands/Files
@ -527,15 +553,15 @@ The SDK maps server error codes to typed exceptions:
```python ```python
from wrenn import ( from wrenn import (
WrennError, WrennError,
WrennValidationError, # 400 WrennValidationError, # 400
WrennAuthenticationError, # 401 WrennAuthenticationError, # 401
WrennForbiddenError, # 403 WrennForbiddenError, # 403
WrennNotFoundError, # 404 WrennNotFoundError, # 404
WrennConflictError, # 409 WrennConflictError, # 409
WrennHostHasCapsulesError, # 409 (host has running capsules) WrennHostHasCapsulesError, # 409 (host has running capsules)
WrennAgentError, # 502 WrennInternalError, # 500
WrennInternalError, # 500 WrennAgentError, # 502
WrennHostUnavailableError, # 503 WrennHostUnavailableError, # 503
) )
try: try:
@ -603,7 +629,7 @@ with WrennClient(api_key="wrn_...") as client:
# Snapshots # Snapshots
template = client.snapshots.create(capsule_id="cl-abc", name="my-snap") template = client.snapshots.create(capsule_id="cl-abc", name="my-snap")
templates = client.snapshots.list() templates = client.snapshots.list(type="custom") # optional type filter
client.snapshots.delete("my-snap") client.snapshots.delete("my-snap")
``` ```

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. - `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> <a id="wrenn.client.AsyncCapsulesResource"></a>
## AsyncCapsulesResource Objects ## AsyncCapsulesResource Objects
@ -326,6 +451,129 @@ Reset the inactivity timer for a capsule.
- `WrennNotFoundError` - If no capsule with the given ID exists. - `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> <a id="wrenn.client.SnapshotsResource"></a>
## SnapshotsResource Objects ## SnapshotsResource Objects
@ -484,7 +732,11 @@ class WrennClient()
Synchronous client for the Wrenn API. 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**: **Arguments**:
@ -497,6 +749,15 @@ Authenticates with an API key.
- `timeout` - HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds), - `timeout` - HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
or ``None`` for the default (30s read/write/pool, 10s connect). 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> <a id="wrenn.client.WrennClient.http"></a>
#### http #### http
@ -528,7 +789,8 @@ class AsyncWrennClient()
Asynchronous client for the Wrenn API. 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**: **Arguments**:
@ -541,6 +803,16 @@ Authenticates with an API key.
- `timeout` - HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds), - `timeout` - HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
or ``None`` for the default (30s read/write/pool, 10s connect). 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> <a id="wrenn.client.AsyncWrennClient.http"></a>
#### http #### http
@ -1936,18 +2208,6 @@ Send SIGKILL to the PTY process.
# wrenn.models.\_generated # 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> <a id="wrenn.models._generated.Peaks"></a>
## Peaks Objects ## Peaks Objects
@ -1978,16 +2238,6 @@ class Encoding(StrEnum)
Output encoding. "base64" when stdout/stderr contain binary data. 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> <a id="wrenn.models._generated.Outcome"></a>
## Outcome Objects ## Outcome Objects

View File

@ -1,6 +1,6 @@
[project] [project]
name = "wrenn" name = "wrenn"
version = "0.1.4" version = "0.3.0"
description = "Python SDK for Wrenn" description = "Python SDK for Wrenn"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
@ -23,7 +23,6 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"certifi>=2026.2.25", "certifi>=2026.2.25",
"email-validator>=2.3.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"httpx-ws>=0.9.0", "httpx-ws>=0.9.0",
"pydantic>=2.12.5", "pydantic>=2.12.5",
@ -41,6 +40,7 @@ dev = [
"pydoc-markdown>=4.8.2", "pydoc-markdown>=4.8.2",
"pytest>=9.0.3", "pytest>=9.0.3",
"pytest-asyncio>=1.3.0", "pytest-asyncio>=1.3.0",
"pyyaml>=6.0.3",
"respx>=0.23.1", "respx>=0.23.1",
"ruff>=0.15.10", "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.models import FileEntry
from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession
__version__ = "0.1.4" __version__ = "0.3.0"
__all__ = [ __all__ = [
"__version__", "__version__",

View File

@ -3,6 +3,8 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import time import time
from collections.abc import AsyncIterator, Iterator
from datetime import date
import httpx import httpx
@ -13,10 +15,14 @@ from wrenn._config import (
ENV_BASE_URL, ENV_BASE_URL,
ENV_PROXY_DOMAIN, ENV_PROXY_DOMAIN,
) )
from wrenn.exceptions import handle_response from wrenn.exceptions import _raise_for_status, handle_response
from wrenn.models import ( from wrenn.models import (
CapsuleMetrics,
CapsuleStats,
SSEEvent,
Template, Template,
UsageResponse,
) )
from wrenn.models import ( from wrenn.models import (
Capsule as CapsuleModel, Capsule as CapsuleModel,
@ -151,6 +157,46 @@ def _snapshot_list_params(type: str | None) -> dict:
return params 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: class CapsulesResource:
"""Sync capsule control-plane operations.""" """Sync capsule control-plane operations."""
@ -260,6 +306,106 @@ class CapsulesResource:
resp = self._http.post(f"/v1/capsules/{id}/ping") resp = self._http.post(f"/v1/capsules/{id}/ping")
handle_response(resp) 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: class AsyncCapsulesResource:
"""Async capsule control-plane operations.""" """Async capsule control-plane operations."""
@ -370,6 +516,118 @@ class AsyncCapsulesResource:
resp = await self._http.post(f"/v1/capsules/{id}/ping") resp = await self._http.post(f"/v1/capsules/{id}/ping")
handle_response(resp) 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: class SnapshotsResource:
"""Sync snapshot operations.""" """Sync snapshot operations."""
@ -486,7 +744,11 @@ class AsyncSnapshotsResource:
class WrennClient: class WrennClient:
"""Synchronous client for the Wrenn API. """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: Args:
api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var. 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. is the default ``app.wrenn.dev`` host, else the ``base_url`` host.
timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds), timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
or ``None`` for the default (30s read/write/pool, 10s connect). 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__( def __init__(
@ -517,6 +788,7 @@ class WrennClient:
self.capsules = CapsulesResource(self._http) self.capsules = CapsulesResource(self._http)
self.snapshots = SnapshotsResource(self._http) self.snapshots = SnapshotsResource(self._http)
self.events = EventsResource(self._http)
@property @property
def http(self) -> httpx.Client: def http(self) -> httpx.Client:
@ -542,7 +814,8 @@ class WrennClient:
class AsyncWrennClient: class AsyncWrennClient:
"""Asynchronous client for the Wrenn API. """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: Args:
api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var. 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. is the default ``app.wrenn.dev`` host, else the ``base_url`` host.
timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds), timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
or ``None`` for the default (30s read/write/pool, 10s connect). 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__( def __init__(
@ -573,6 +856,7 @@ class AsyncWrennClient:
self.capsules = AsyncCapsulesResource(self._http) self.capsules = AsyncCapsulesResource(self._http)
self.snapshots = AsyncSnapshotsResource(self._http) self.snapshots = AsyncSnapshotsResource(self._http)
self.events = AsyncEventsResource(self._http)
@property @property
def http(self) -> httpx.AsyncClient: def http(self) -> httpx.AsyncClient:

View File

@ -164,4 +164,17 @@ def __getattr__(name: str) -> type:
stacklevel=2, stacklevel=2,
) )
return WrennHostHasCapsulesError return WrennHostHasCapsulesError
if name in ("GitError", "GitCommandError", "GitAuthError"):
from wrenn._git.exceptions import (
GitAuthError as _GitAuthError,
GitCommandError as _GitCommandError,
GitError as _GitError,
)
_m: dict[str, type] = {
"GitError": _GitError,
"GitCommandError": _GitCommandError,
"GitAuthError": _GitAuthError,
}
return _m[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@ -1,65 +1,31 @@
from wrenn.models._generated import ( from wrenn.models._generated import (
APIKeyResponse, Actor,
Capsule, Capsule,
CreateAPIKeyRequest, CapsuleMetrics,
CreateCapsuleRequest, CapsuleStats,
CreateHostRequest,
CreateHostResponse,
CreateSnapshotRequest,
Encoding,
Error,
Error1,
ExecRequest,
ExecResponse,
FileEntry, FileEntry,
Host,
ListDirRequest,
ListDirResponse, ListDirResponse,
LoginRequest,
MakeDirRequest,
MakeDirResponse, MakeDirResponse,
ReadFileRequest, MetricPoint,
RegisterHostRequest, Resource,
RegisterHostResponse, SSEEvent,
RemoveRequest,
SignupRequest,
Status, Status,
Status1,
Template, Template,
Type, UsageResponse,
Type1,
Type2,
) )
__all__ = [ __all__ = [
"APIKeyResponse", "Actor",
"CreateAPIKeyRequest",
"CreateHostRequest",
"CreateHostResponse",
"CreateCapsuleRequest",
"CreateSnapshotRequest",
"Encoding",
"Error",
"Error1",
"ExecRequest",
"ExecResponse",
"FileEntry",
"Host",
"ListDirRequest",
"ListDirResponse",
"LoginRequest",
"MakeDirRequest",
"MakeDirResponse",
"ReadFileRequest",
"RegisterHostRequest",
"RegisterHostResponse",
"RemoveRequest",
"Capsule", "Capsule",
"SignupRequest", "CapsuleMetrics",
"CapsuleStats",
"FileEntry",
"ListDirResponse",
"MakeDirResponse",
"MetricPoint",
"Resource",
"SSEEvent",
"Status", "Status",
"Status1",
"Template", "Template",
"Type", "UsageResponse",
"Type1",
"Type2",
] ]

View File

@ -1,79 +1,20 @@
# generated by datamodel-codegen: # generated by datamodel-codegen:
# filename: openapi.yaml # filename: openapi.yaml
# timestamp: 2026-05-19T08:54:50+00:00 # timestamp: 2026-06-22T22:24:45+00:00
from __future__ import annotations from __future__ import annotations
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
from typing import Annotated, Any
from datetime import date as date_aliased from datetime import date as date_aliased
from enum import StrEnum from enum import StrEnum
from typing import Annotated
from pydantic import AwareDatetime, BaseModel, Field
class SignupRequest(BaseModel):
email: EmailStr
password: Annotated[str, Field(min_length=8)]
name: Annotated[str, Field(max_length=100)]
class LoginRequest(BaseModel):
email: EmailStr
password: str
class SignupResponse(BaseModel):
message: Annotated[
str | None,
Field(description="Confirmation message instructing user to check email"),
] = None
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.
"""
user_id: str | None = None
team_id: str | None = None
email: str | None = None
name: str | None = None
role: str | None = None
is_admin: bool | None = None
class CreateAPIKeyRequest(BaseModel):
name: str | None = "Unnamed API Key"
class APIKeyResponse(BaseModel):
id: str | None = None
team_id: str | None = None
name: str | None = None
key_prefix: Annotated[
str | None, Field(description='Display prefix (e.g. "wrn_ab12cd34...")')
] = None
created_at: AwareDatetime | None = None
last_used: AwareDatetime | None = None
key: Annotated[
str | None,
Field(
description="Full plaintext key. Only returned on creation, never again."
),
] = None
class CreateCapsuleRequest(BaseModel): class CreateCapsuleRequest(BaseModel):
template: str | None = "minimal" template: str | None = "minimal-ubuntu"
vcpus: int | None = 1 vcpus: int | None = 1
memory_mb: int | None = 512 memory_mb: int | None = 512
disk_size_mb: Annotated[
int | None,
Field(
description="Maximum size of the per-capsule copy-on-write disk in MB. Capped at 5 GB by default; the actual size is max(disk_size_mb, origin rootfs size).\n"
),
] = 5120
timeout_sec: Annotated[ timeout_sec: Annotated[
int | None, int | None,
Field( Field(
@ -81,6 +22,12 @@ class CreateCapsuleRequest(BaseModel):
ge=0, ge=0,
), ),
] = 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): class Point(BaseModel):
@ -107,7 +54,6 @@ class Current(BaseModel):
running_count: int | None = None running_count: int | None = None
vcpus_reserved: int | None = None vcpus_reserved: int | None = None
memory_mb_reserved: int | None = None memory_mb_reserved: int | None = None
sampled_at: AwareDatetime | None = None
class Peaks(BaseModel): class Peaks(BaseModel):
@ -148,6 +94,7 @@ class Status(StrEnum):
running = "running" running = "running"
pausing = "pausing" pausing = "pausing"
paused = "paused" paused = "paused"
snapshotting = "snapshotting"
resuming = "resuming" resuming = "resuming"
stopping = "stopping" stopping = "stopping"
hibernated = "hibernated" hibernated = "hibernated"
@ -163,8 +110,6 @@ class Capsule(BaseModel):
vcpus: int | None = None vcpus: int | None = None
memory_mb: int | None = None memory_mb: int | None = None
timeout_sec: int | None = None timeout_sec: int | None = None
guest_ip: str | None = None
host_ip: str | None = None
created_at: AwareDatetime | None = None created_at: AwareDatetime | None = None
started_at: AwareDatetime | None = None started_at: AwareDatetime | None = None
last_active_at: AwareDatetime | None = None last_active_at: AwareDatetime | None = None
@ -172,10 +117,18 @@ class Capsule(BaseModel):
metadata: Annotated[ metadata: Annotated[
dict[str, str] | None, dict[str, str] | None,
Field( 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[
int | None, Field(description="Maximum disk capacity in MiB.")
] = None
disk_used_mb: Annotated[
int | None,
Field(
description="Current disk usage in MiB. Only populated on individual capsule GET; omitted in list responses."
), ),
] = None ] = None
disk_size_mb: int | None = None
class CreateSnapshotRequest(BaseModel): class CreateSnapshotRequest(BaseModel):
@ -203,7 +156,13 @@ class Template(BaseModel):
platform: Annotated[ platform: Annotated[
bool | None, bool | None,
Field( Field(
description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal` rootfs). False for team-owned\nsnapshot templates.\n" description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal-ubuntu` rootfs). False for team-owned\nsnapshot templates.\n"
),
] = None
protected: Annotated[
bool | None,
Field(
description="True for built-in system base templates (minimal-ubuntu,\nminimal-alpine, minimal-arch, minimal-fedora). Protected templates\ncannot be deleted.\n"
), ),
] = None ] = None
metadata: dict[str, str] | None = None metadata: dict[str, str] | None = None
@ -231,12 +190,14 @@ class ExecRequest(BaseModel):
envs: Annotated[ envs: Annotated[
dict[str, str] | None, dict[str, str] | None,
Field( Field(
description="Environment variables for the process (background exec only)" description="Environment variables for the process (applies to both foreground and background exec)"
), ),
] = None ] = None
cwd: Annotated[ cwd: Annotated[
str | None, str | None,
Field(description="Working directory for the process (background exec only)"), Field(
description="Working directory for the process (applies to both foreground and background exec)"
),
] = None ] = None
@ -319,12 +280,6 @@ class FileEntry(BaseModel):
symlink_target: str | None = None symlink_target: str | None = None
class MakeDirRequest(BaseModel):
path: Annotated[
str, Field(description="Directory path to create inside the capsule")
]
class MakeDirResponse(BaseModel): class MakeDirResponse(BaseModel):
entry: FileEntry | None = None entry: FileEntry | None = None
@ -333,158 +288,6 @@ class RemoveRequest(BaseModel):
path: Annotated[str, Field(description="Path to remove inside the capsule")] path: Annotated[str, Field(description="Path to remove inside the capsule")]
class Type2(StrEnum):
"""
Host type. Regular hosts are shared; BYOC hosts belong to a team.
"""
regular = "regular"
byoc = "byoc"
class CreateHostRequest(BaseModel):
type: Annotated[
Type2,
Field(
description="Host type. Regular hosts are shared; BYOC hosts belong to a team."
),
]
team_id: Annotated[str | None, Field(description="Required for BYOC hosts.")] = None
provider: Annotated[
str | None,
Field(description="Cloud provider (e.g. aws, gcp, hetzner, bare-metal)."),
] = None
availability_zone: Annotated[
str | None, Field(description="Availability zone (e.g. us-east, eu-west).")
] = None
class RegisterHostRequest(BaseModel):
token: Annotated[
str, Field(description="One-time registration token from POST /v1/hosts.")
]
arch: Annotated[
str | None, Field(description="CPU architecture (e.g. x86_64, aarch64).")
] = None
cpu_cores: int | None = None
memory_mb: int | None = None
disk_gb: int | None = None
address: Annotated[str, Field(description="Host agent address (ip:port).")]
class Type3(StrEnum):
regular = "regular"
byoc = "byoc"
class Status1(StrEnum):
pending = "pending"
online = "online"
offline = "offline"
draining = "draining"
unreachable = "unreachable"
class Host(BaseModel):
id: str | None = None
type: Type3 | None = None
team_id: str | None = None
provider: str | None = None
availability_zone: str | None = None
arch: str | None = None
cpu_cores: int | None = None
memory_mb: int | None = None
disk_gb: int | None = None
address: str | None = None
status: Status1 | None = None
last_heartbeat_at: AwareDatetime | None = None
created_by: str | None = None
created_at: AwareDatetime | None = None
updated_at: AwareDatetime | None = None
class RefreshHostTokenRequest(BaseModel):
refresh_token: Annotated[
str,
Field(
description="Refresh token obtained from registration or a previous refresh."
),
]
class RefreshHostTokenResponse(BaseModel):
host: Host | None = None
token: Annotated[
str | None, Field(description="New host JWT. Valid for 7 days.")
] = None
refresh_token: Annotated[
str | None,
Field(
description="New refresh token. Valid for 60 days; old token is revoked."
),
] = None
class HostDeletePreview(BaseModel):
host: Host | None = None
sandbox_ids: Annotated[
list[str] | None,
Field(description="IDs of capsules that would be destroyed on force-delete."),
] = None
class Error(BaseModel):
code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None
message: str | None = None
sandbox_ids: Annotated[
list[str] | None, Field(description="IDs of active capsules blocking deletion.")
] = None
class HostHasCapsulesError(BaseModel):
error: Error | None = None
class AddTagRequest(BaseModel):
tag: str
class UserSearchResult(BaseModel):
user_id: str | None = None
email: str | None = None
class Team(BaseModel):
id: str | None = None
name: str | None = None
slug: Annotated[
str | None, Field(description="Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)")
] = None
created_at: AwareDatetime | None = None
class Role(StrEnum):
owner = "owner"
admin = "admin"
member = "member"
class TeamWithRole(Team):
role: Role | None = None
class TeamMember(BaseModel):
user_id: str | None = None
email: str | None = None
role: Role | None = None
joined_at: AwareDatetime | None = None
class TeamDetail(BaseModel):
team: Team | None = None
members: list[TeamMember] | None = None
class Range1(StrEnum): class Range1(StrEnum):
field_5m = "5m" field_5m = "5m"
field_10m = "10m" field_10m = "10m"
@ -514,141 +317,16 @@ class MetricPoint(BaseModel):
] = None ] = None
class Provider(StrEnum): class Error1(BaseModel):
discord = "discord"
slack = "slack"
teams = "teams"
googlechat = "googlechat"
telegram = "telegram"
matrix = "matrix"
webhook = "webhook"
class Event(StrEnum):
capsule_create = "capsule.create"
capsule_pause = "capsule.pause"
capsule_resume = "capsule.resume"
capsule_destroy = "capsule.destroy"
template_snapshot_create = "template.snapshot.create"
template_snapshot_delete = "template.snapshot.delete"
host_up = "host.up"
host_down = "host.down"
class CreateChannelRequest(BaseModel):
name: Annotated[str, Field(description="Unique channel name within the team.")]
provider: Provider
config: Annotated[
dict[str, str],
Field(
description='Provider-specific configuration fields. Discord/Slack/Teams/Google Chat: {"webhook_url": "..."}. Telegram: {"bot_token": "...", "chat_id": "..."}. Matrix: {"homeserver_url": "...", "access_token": "...", "room_id": "..."}. Webhook: {"url": "...", "secret": "..."} (secret is auto-generated if omitted).\n'
),
]
events: list[Event]
class TestChannelRequest(BaseModel):
provider: Provider
config: Annotated[
dict[str, str],
Field(
description="Provider-specific configuration fields (same as CreateChannelRequest.config)."
),
]
class RotateConfigRequest(BaseModel):
config: Annotated[
dict[str, str],
Field(
description="New provider configuration fields. Must include all required fields for the channel's provider. Replaces the existing config entirely.\n"
),
]
class UpdateChannelRequest(BaseModel):
name: str
events: list[Event]
class ChannelResponse(BaseModel):
id: str | None = None
team_id: str | None = None
name: str | None = None
provider: Provider | None = None
events: list[str] | None = None
created_at: AwareDatetime | None = None
updated_at: AwareDatetime | None = None
secret: Annotated[
str | None,
Field(description="Webhook secret. Only returned on creation, never again."),
] = None
class MeResponse(BaseModel):
name: str | None = None
email: EmailStr | None = None
has_password: Annotated[
bool | None,
Field(
description="Whether the user has a password set (false for OAuth-only accounts)"
),
] = None
providers: Annotated[
list[str] | None,
Field(description='List of linked OAuth provider names (e.g. ["github"])'),
] = None
class ChangePasswordRequest(BaseModel):
current_password: Annotated[
str | None, Field(description="Required when changing an existing password")
] = None
new_password: Annotated[str, Field(min_length=8)]
confirm_password: Annotated[
str | None,
Field(
description="Required when adding a password to an OAuth-only account (must match new_password)"
),
] = None
class Error2(BaseModel):
code: str | None = None code: str | None = None
message: str | None = None message: str | None = None
class Error1(BaseModel): class Error(BaseModel):
error: Error2 | None = None error: Error1 | None = None
class ActorType(StrEnum): class Event(StrEnum):
user = "user"
api_key = "api_key"
host = "host"
system = "system"
class Status2(StrEnum):
success = "success"
failure = "failure"
class AuditLogEntry(BaseModel):
id: str | None = None
actor_type: ActorType | None = None
actor_id: str | None = None
actor_name: str | None = None
resource_type: str | None = None
resource_id: str | None = None
action: str | None = None
scope: str | None = None
status: Status2 | None = None
metadata: dict[str, Any] | None = None
created_at: AwareDatetime | None = None
class Event2(StrEnum):
connected = "connected" connected = "connected"
capsule_create = "capsule.create" capsule_create = "capsule.create"
capsule_pause = "capsule.pause" capsule_pause = "capsule.pause"
@ -678,14 +356,14 @@ class Resource(BaseModel):
type: str | None = None type: str | None = None
class Type4(StrEnum): class Type2(StrEnum):
user = "user" user = "user"
api_key = "api_key" api_key = "api_key"
system = "system" system = "system"
class Actor(BaseModel): class Actor(BaseModel):
type: Type4 | None = None type: Type2 | None = None
id: str | None = None id: str | None = None
name: str | None = None name: str | None = None
@ -697,7 +375,7 @@ class SSEEvent(BaseModel):
""" """
event: Event2 | None = None event: Event | None = None
outcome: Annotated[ outcome: Annotated[
Outcome | None, Outcome | None,
Field( Field(
@ -726,30 +404,6 @@ class ListDirResponse(BaseModel):
entries: list[FileEntry] | None = None entries: list[FileEntry] | None = None
class CreateHostResponse(BaseModel):
host: Host | None = None
registration_token: Annotated[
str | None,
Field(
description="One-time registration token for the host agent. Expires in 1 hour."
),
] = None
class RegisterHostResponse(BaseModel):
host: Host | None = None
token: Annotated[
str | None,
Field(description="Host JWT for X-Host-Token header. Valid for 7 days."),
] = None
refresh_token: Annotated[
str | None,
Field(
description="Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use."
),
] = None
class CapsuleMetrics(BaseModel): class CapsuleMetrics(BaseModel):
sandbox_id: str | None = None sandbox_id: str | None = None
range: Range1 | None = None range: Range1 | None = None

View File

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

View File

@ -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"
@ -103,6 +110,212 @@ class TestCapsules:
client.capsules.ping("sb-1") client.capsules.ping("sb-1")
assert route.called 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: class TestSnapshots:
@respx.mock @respx.mock
@ -262,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):

View File

@ -430,12 +430,10 @@ class TestCodeRunnerMimeTypes:
def test_requests_status_code(self): def test_requests_status_code(self):
ex = self._run( ex = self._run(
"import requests\n" "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" "r.status_code\n"
) )
if ex.error is not None: assert ex.text == "418"
pytest.skip(f"network unavailable: {ex.error.name}")
assert ex.text == "204"
class TestCodeRunnerIsolation: class TestCodeRunnerIsolation:

View File

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

View File

@ -1,11 +1,11 @@
from __future__ import annotations from __future__ import annotations
import base64
import json import json
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
import respx import respx
from wsproto.events import BytesMessage, TextMessage
from wrenn.capsule import Capsule from wrenn.capsule import Capsule
from wrenn.models import FileEntry from wrenn.models import FileEntry
@ -13,9 +13,18 @@ from wrenn.pty import (
AsyncPtySession, AsyncPtySession,
PtyEventType, PtyEventType,
PtySession, 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" BASE = "https://app.wrenn.dev/api"
@ -74,32 +83,32 @@ class TestFilesList:
"entries": [ "entries": [
{ {
"name": "main.py", "name": "main.py",
"path": "/home/user/main.py", "path": "/home/wrenn-user/main.py",
"type": "file", "type": "file",
"size": 1024, "size": 1024,
"mode": 33188, "mode": 33188,
"permissions": "-rw-r--r--", "permissions": "-rw-r--r--",
"owner": "root", "owner": "wrenn-user",
"group": "root", "group": "wrenn-user",
"modified_at": 1712899200, "modified_at": 1712899200,
"symlink_target": None, "symlink_target": None,
}, },
{ {
"name": "config", "name": "config",
"path": "/home/user/config", "path": "/home/wrenn-user/config",
"type": "directory", "type": "directory",
"size": 4096, "size": 4096,
"mode": 16877, "mode": 16877,
"permissions": "drwxr-xr-x", "permissions": "drwxr-xr-x",
"owner": "root", "owner": "wrenn-user",
"group": "root", "group": "wrenn-user",
"modified_at": 1712899100, "modified_at": 1712899100,
"symlink_target": None, "symlink_target": None,
}, },
] ]
}, },
) )
entries = cap.files.list("/home/user") entries = cap.files.list("/home/wrenn-user")
assert len(entries) == 2 assert len(entries) == 2
assert isinstance(entries[0], FileEntry) assert isinstance(entries[0], FileEntry)
assert entries[0].name == "main.py" assert entries[0].name == "main.py"
@ -113,7 +122,7 @@ class TestFilesList:
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond( route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
200, json={"entries": []} 200, json={"entries": []}
) )
cap.files.list("/home/user", depth=3) cap.files.list("/home/wrenn-user", depth=3)
body = json.loads(route.calls[0].request.content) body = json.loads(route.calls[0].request.content)
assert body["depth"] == 3 assert body["depth"] == 3
@ -136,19 +145,19 @@ class TestFilesMakeDir:
json={ json={
"entry": { "entry": {
"name": "data", "name": "data",
"path": "/home/user/data", "path": "/home/wrenn-user/data",
"type": "directory", "type": "directory",
"size": 4096, "size": 4096,
"mode": 16877, "mode": 16877,
"permissions": "drwxr-xr-x", "permissions": "drwxr-xr-x",
"owner": "root", "owner": "wrenn-user",
"group": "root", "group": "wrenn-user",
"modified_at": 1712899200, "modified_at": 1712899200,
"symlink_target": None, "symlink_target": None,
} }
}, },
) )
entry = cap.files.make_dir("/home/user/data") entry = cap.files.make_dir("/home/wrenn-user/data")
assert isinstance(entry, FileEntry) assert isinstance(entry, FileEntry)
assert entry.name == "data" assert entry.name == "data"
assert entry.type == "directory" assert entry.type == "directory"
@ -166,20 +175,20 @@ class TestFilesMakeDir:
"entries": [ "entries": [
{ {
"name": "data", "name": "data",
"path": "/home/user/data", "path": "/home/wrenn-user/data",
"type": "directory", "type": "directory",
"size": 4096, "size": 4096,
"mode": 16877, "mode": 16877,
"permissions": "drwxr-xr-x", "permissions": "drwxr-xr-x",
"owner": "root", "owner": "wrenn-user",
"group": "root", "group": "wrenn-user",
"modified_at": 1712899200, "modified_at": 1712899200,
"symlink_target": None, "symlink_target": None,
} }
] ]
}, },
) )
entry = cap.files.make_dir("/home/user/data") entry = cap.files.make_dir("/home/wrenn-user/data")
assert entry.name == "data" assert entry.name == "data"
@ -188,7 +197,7 @@ class TestFilesRemove:
def test_remove_succeeds(self): def test_remove_succeeds(self):
cap = _make_capsule() cap = _make_capsule()
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204) route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204)
cap.files.remove("/home/user/old_data") cap.files.remove("/home/wrenn-user/old_data")
assert route.called assert route.called
@respx.mock @respx.mock
@ -226,50 +235,37 @@ class TestFilesExists:
class TestPtyEventParsing: class TestPtyEventParsing:
def test_started_event(self): def test_started_event(self):
raw = {"type": "started", "tag": "pty-a1b2c3d4", "pid": 42} 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.type == PtyEventType.started
assert event.pid == 42 assert event.pid == 42
assert event.tag == "pty-a1b2c3d4" 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): def test_exit_event(self):
raw = {"type": "exit", "exit_code": 0} raw = {"type": "exit", "exit_code": 0}
event = _parse_pty_event(raw) event = _parse_control_event(raw)
assert event.type == PtyEventType.exit assert event.type == PtyEventType.exit
assert event.exit_code == 0 assert event.exit_code == 0
def test_error_event(self): def test_error_event(self):
raw = {"type": "error", "data": "process not found", "fatal": True} 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.type == PtyEventType.error
assert event.data == "process not found" assert event.data == "process not found"
assert event.fatal is True assert event.fatal is True
def test_ping_event(self): def test_ping_event(self):
raw = {"type": "ping"} raw = {"type": "ping"}
event = _parse_pty_event(raw) event = _parse_control_event(raw)
assert event.type == PtyEventType.ping assert event.type == PtyEventType.ping
class TestPtySessionWrite: class TestPtySessionWrite:
def test_write_sends_base64_input(self): def test_write_sends_binary_frame(self):
ws = MagicMock() ws = MagicMock()
session = PtySession(ws, "cl-abc") session = PtySession(ws, "cl-abc")
session.write(b"ls -la\n") session.write(b"ls -la\n")
sent = json.loads(ws.send_text.call_args[0][0]) ws.send_bytes.assert_called_once_with(b"ls -la\n")
assert sent["type"] == "input" ws.send_text.assert_not_called()
assert base64.b64decode(sent["data"]) == b"ls -la\n"
class TestPtySessionResize: class TestPtySessionResize:
@ -303,12 +299,11 @@ class TestPtySessionKill:
class TestPtySessionIteration: class TestPtySessionIteration:
def test_iter_yields_events_until_exit(self): def test_iter_yields_events_until_exit(self):
ws = MagicMock() ws = MagicMock()
messages = [ ws.receive.side_effect = [
json.dumps({"type": "started", "tag": "pty-abc12345", "pid": 1}), _text({"type": "started", "tag": "pty-abc12345", "pid": 1}),
json.dumps({"type": "output", "data": base64.b64encode(b"hello").decode()}), _bytes(b"hello"),
json.dumps({"type": "exit", "exit_code": 0}), _text({"type": "exit", "exit_code": 0}),
] ]
ws.receive_text.side_effect = messages
session = PtySession(ws, "cl-abc") session = PtySession(ws, "cl-abc")
events = list(session) events = list(session)
assert len(events) == 3 assert len(events) == 3
@ -320,12 +315,22 @@ class TestPtySessionIteration:
assert events[2].type == PtyEventType.exit assert events[2].type == PtyEventType.exit
assert events[2].exit_code == 0 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): def test_iter_stops_on_fatal_error(self):
ws = MagicMock() ws = MagicMock()
messages = [ ws.receive.side_effect = [
json.dumps({"type": "error", "data": "fatal", "fatal": True}), _text({"type": "error", "data": "fatal", "fatal": True}),
] ]
ws.receive_text.side_effect = messages
session = PtySession(ws, "cl-abc") session = PtySession(ws, "cl-abc")
events = list(session) events = list(session)
assert len(events) == 1 assert len(events) == 1
@ -335,7 +340,7 @@ class TestPtySessionIteration:
import httpx_ws import httpx_ws
ws = MagicMock() ws = MagicMock()
ws.receive_text.side_effect = httpx_ws.WebSocketDisconnect() ws.receive.side_effect = httpx_ws.WebSocketDisconnect()
session = PtySession(ws, "cl-abc") session = PtySession(ws, "cl-abc")
events = list(session) events = list(session)
assert events == [] assert events == []
@ -344,9 +349,9 @@ class TestPtySessionIteration:
class TestPtySessionPong: class TestPtySessionPong:
def test_ping_triggers_pong(self): def test_ping_triggers_pong(self):
ws = MagicMock() ws = MagicMock()
ws.receive_text.side_effect = [ ws.receive.side_effect = [
json.dumps({"type": "ping"}), _text({"type": "ping"}),
json.dumps({"type": "exit", "exit_code": 0}), _text({"type": "exit", "exit_code": 0}),
] ]
session = PtySession(ws, "cl-abc") session = PtySession(ws, "cl-abc")
events = list(session) events = list(session)
@ -356,9 +361,9 @@ class TestPtySessionPong:
def test_no_pong_without_ping(self): def test_no_pong_without_ping(self):
ws = MagicMock() ws = MagicMock()
ws.receive_text.side_effect = [ ws.receive.side_effect = [
json.dumps({"type": "output", "data": ""}), _bytes(b""),
json.dumps({"type": "exit", "exit_code": 0}), _text({"type": "exit", "exit_code": 0}),
] ]
session = PtySession(ws, "cl-abc") session = PtySession(ws, "cl-abc")
list(session) list(session)
@ -411,7 +416,7 @@ class TestPtySessionSendStart:
cols=120, cols=120,
rows=40, rows=40,
envs={"TERM": "xterm-256color"}, envs={"TERM": "xterm-256color"},
cwd="/home/user", cwd="/home/wrenn-user",
) )
sent = json.loads(ws.send_text.call_args[0][0]) sent = json.loads(ws.send_text.call_args[0][0])
assert sent["cmd"] == "/bin/zsh" assert sent["cmd"] == "/bin/zsh"
@ -431,13 +436,12 @@ class TestPtySessionSendConnect:
class TestAsyncPtySession: class TestAsyncPtySession:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_async_write_sends_base64(self): async def test_async_write_sends_binary_frame(self):
ws = AsyncMock() ws = AsyncMock()
session = AsyncPtySession(ws, "cl-abc") session = AsyncPtySession(ws, "cl-abc")
await session.write(b"hello") await session.write(b"hello")
sent = json.loads(ws.send_text.call_args[0][0]) ws.send_bytes.assert_awaited_once_with(b"hello")
assert sent["type"] == "input" ws.send_text.assert_not_called()
assert base64.b64decode(sent["data"]) == b"hello"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_async_resize(self): async def test_async_resize(self):
@ -486,9 +490,9 @@ class TestAsyncPtySession:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_async_ping_triggers_pong(self): async def test_async_ping_triggers_pong(self):
ws = AsyncMock() ws = AsyncMock()
ws.receive_text.side_effect = [ ws.receive.side_effect = [
json.dumps({"type": "ping"}), _text({"type": "ping"}),
json.dumps({"type": "exit", "exit_code": 0}), _text({"type": "exit", "exit_code": 0}),
] ]
session = AsyncPtySession(ws, "cl-abc") session = AsyncPtySession(ws, "cl-abc")
events = [e async for e in session] events = [e async for e in session]
@ -508,12 +512,11 @@ class TestAsyncPtySession:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_async_iteration(self): async def test_async_iteration(self):
ws = AsyncMock() ws = AsyncMock()
messages = [ ws.receive.side_effect = [
json.dumps({"type": "started", "tag": "pty-xyz", "pid": 5}), _text({"type": "started", "tag": "pty-xyz", "pid": 5}),
json.dumps({"type": "output", "data": base64.b64encode(b"hi").decode()}), _bytes(b"hi"),
json.dumps({"type": "exit", "exit_code": 0}), _text({"type": "exit", "exit_code": 0}),
] ]
ws.receive_text.side_effect = messages
session = AsyncPtySession(ws, "cl-abc") session = AsyncPtySession(ws, "cl-abc")
events = [] events = []
async for event in session: async for event in session:
@ -522,6 +525,8 @@ class TestAsyncPtySession:
assert events[0].type == PtyEventType.started assert events[0].type == PtyEventType.started
assert session.tag == "pty-xyz" assert session.tag == "pty-xyz"
assert session.pid == 5 assert session.pid == 5
assert events[1].type == PtyEventType.output
assert events[1].data == b"hi"
assert events[2].type == PtyEventType.exit assert events[2].type == PtyEventType.exit

View File

@ -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
@ -323,7 +333,7 @@ class TestFiles:
class TestGit: class TestGit:
"""Shared capsule for git operation tests. """Shared capsule for git operation tests.
Initializes a repo at /root (default cwd) since the exec API Initializes a repo at /home/wrenn-user (default cwd) since the exec API
does not support the cwd parameter. does not support the cwd parameter.
""" """
@ -344,14 +354,14 @@ class TestGit:
pass pass
def test_init_created_repo(self): def test_init_created_repo(self):
assert self.capsule.files.exists("/root/.git") assert self.capsule.files.exists("/home/wrenn-user/.git")
def test_status_clean(self): def test_status_clean(self):
status = self.capsule.git.status() status = self.capsule.git.status()
assert status.branch == "main" assert status.branch == "main"
def test_add_and_commit(self): def test_add_and_commit(self):
self.capsule.files.write("/root/hello.txt", "hello git") self.capsule.files.write("/home/wrenn-user/hello.txt", "hello git")
self.capsule.git.add(all=True) self.capsule.git.add(all=True)
result = self.capsule.git.commit("initial commit") result = self.capsule.git.commit("initial commit")
assert result.exit_code == 0 assert result.exit_code == 0
@ -361,14 +371,14 @@ class TestGit:
assert status.is_clean assert status.is_clean
def test_status_with_changes(self): def test_status_with_changes(self):
self.capsule.files.write("/root/dirty.txt", "uncommitted") self.capsule.files.write("/home/wrenn-user/dirty.txt", "uncommitted")
try: try:
status = self.capsule.git.status() status = self.capsule.git.status()
assert not status.is_clean assert not status.is_clean
paths = [f.path for f in status.files] paths = [f.path for f in status.files]
assert "dirty.txt" in paths assert "dirty.txt" in paths
finally: finally:
self.capsule.files.remove("/root/dirty.txt") self.capsule.files.remove("/home/wrenn-user/dirty.txt")
def test_branches(self): def test_branches(self):
branches = self.capsule.git.branches() branches = self.capsule.git.branches()
@ -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"

View File

@ -75,7 +75,7 @@ class TestCommandEnvironment:
def test_default_cwd_is_home(self): def test_default_cwd_is_home(self):
result = self.capsule.commands.run("pwd") result = self.capsule.commands.run("pwd")
assert result.stdout.strip() == "/root" assert result.stdout.strip() == "/home/wrenn-user"
def test_cwd_resolves_relative_paths(self): def test_cwd_resolves_relative_paths(self):
self.capsule.files.make_dir("/tmp/cwd_probe/sub") self.capsule.files.make_dir("/tmp/cwd_probe/sub")
@ -90,7 +90,7 @@ class TestCommandEnvironment:
# Each run is a fresh process — `cd` in one does not affect the next. # Each run is a fresh process — `cd` in one does not affect the next.
self.capsule.commands.run("cd /tmp") self.capsule.commands.run("cd /tmp")
result = self.capsule.commands.run("pwd") result = self.capsule.commands.run("pwd")
assert result.stdout.strip() == "/root" assert result.stdout.strip() == "/home/wrenn-user"
def test_single_env_var(self): def test_single_env_var(self):
result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"}) result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"})
@ -115,9 +115,29 @@ class TestCommandEnvironment:
def test_base_environment_present(self): def test_base_environment_present(self):
result = self.capsule.commands.run("echo $HOME; echo $PATH") result = self.capsule.commands.run("echo $HOME; echo $PATH")
lines = result.stdout.strip().splitlines() lines = result.stdout.strip().splitlines()
assert lines[0] == "/root" assert lines[0] == "/home/wrenn-user"
assert "/usr/bin" in lines[1] assert "/usr/bin" in lines[1]
def test_sudo_available(self):
result = self.capsule.commands.run("which sudo")
assert result.exit_code == 0
def test_sudo_runs_without_password(self):
result = self.capsule.commands.run("sudo whoami")
assert result.exit_code == 0
assert result.stdout.strip() == "root"
def test_sudo_can_write_to_protected_path(self):
result = self.capsule.commands.run(
"sudo touch /opt/sudo-test-marker && cat /opt/sudo-test-marker"
)
assert result.exit_code == 0
def test_sudo_can_read_root_owned_file(self):
result = self.capsule.commands.run("sudo cat /etc/shadow | head -1")
assert result.exit_code == 0
assert "root" in result.stdout
# ══════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════
# Long-running commands # Long-running commands
@ -143,7 +163,7 @@ class TestLongRunningCommands:
def test_apt_get_install(self): def test_apt_get_install(self):
result = self.capsule.commands.run( result = self.capsule.commands.run(
"apt-get update -qq && apt-get install -y -qq cowsay", timeout=300 "sudo apt-get update -qq && sudo apt-get install -y -qq cowsay", timeout=300
) )
assert result.exit_code == 0 assert result.exit_code == 0
@ -388,7 +408,9 @@ class TestGitClone:
def setup_class(cls): def setup_class(cls):
_ensure_env() _ensure_env()
cls.capsule = Capsule(wait=True) cls.capsule = Capsule(wait=True)
cls.capsule.git.clone(WRENN_REPO, "/root/wrenn", depth=1, timeout=300) cls.capsule.git.clone(
WRENN_REPO, "/home/wrenn-user/wrenn", depth=1, timeout=300
)
@classmethod @classmethod
def teardown_class(cls): def teardown_class(cls):
@ -398,66 +420,74 @@ class TestGitClone:
pass pass
def test_clone_created_repo(self): def test_clone_created_repo(self):
assert self.capsule.files.exists("/root/wrenn/.git") assert self.capsule.files.exists("/home/wrenn-user/wrenn/.git")
def test_clone_checked_out_files(self): def test_clone_checked_out_files(self):
entries = self.capsule.files.list("/root/wrenn") entries = self.capsule.files.list("/home/wrenn-user/wrenn")
names = [e.name for e in entries] names = [e.name for e in entries]
assert "README.md" in names assert "README.md" in names
def test_status_of_clone_is_clean(self): def test_status_of_clone_is_clean(self):
status = self.capsule.git.status(cwd="/root/wrenn") status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
assert status.branch == "main" assert status.branch == "main"
assert status.is_clean assert status.is_clean
def test_branches_lists_main(self): def test_branches_lists_main(self):
branches = self.capsule.git.branches(cwd="/root/wrenn") branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn")
names = [b.name for b in branches] names = [b.name for b in branches]
assert "main" in names assert "main" in names
assert any(b.is_current for b in branches) assert any(b.is_current for b in branches)
def test_remote_get_origin(self): def test_remote_get_origin(self):
url = self.capsule.git.remote_get("origin", cwd="/root/wrenn") url = self.capsule.git.remote_get("origin", cwd="/home/wrenn-user/wrenn")
assert url is not None assert url is not None
assert "wrennhq/wrenn" in url assert "wrennhq/wrenn" in url
def test_git_log_has_commit(self): def test_git_log_has_commit(self):
result = self.capsule.commands.run("git log --oneline -1", cwd="/root/wrenn") result = self.capsule.commands.run(
"git log --oneline -1", cwd="/home/wrenn-user/wrenn"
)
assert result.exit_code == 0 assert result.exit_code == 0
assert result.stdout.strip() assert result.stdout.strip()
def test_modify_add_commit(self): def test_modify_add_commit(self):
marker = uuid.uuid4().hex marker = uuid.uuid4().hex
self.capsule.git.configure_user( self.capsule.git.configure_user(
"CI Bot", "ci@example.com", cwd="/root/wrenn", scope="local" "CI Bot", "ci@example.com", cwd="/home/wrenn-user/wrenn", scope="local"
) )
self.capsule.files.write(f"/root/wrenn/sdk_probe_{marker}.txt", marker) self.capsule.files.write(
self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/root/wrenn") f"/home/wrenn-user/wrenn/sdk_probe_{marker}.txt", marker
)
self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/home/wrenn-user/wrenn")
staged = self.capsule.git.status(cwd="/root/wrenn") staged = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
assert staged.has_staged assert staged.has_staged
result = self.capsule.git.commit("probe commit", cwd="/root/wrenn") result = self.capsule.git.commit("probe commit", cwd="/home/wrenn-user/wrenn")
assert result.exit_code == 0 assert result.exit_code == 0
after = self.capsule.git.status(cwd="/root/wrenn") after = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
assert after.is_clean assert after.is_clean
assert after.ahead >= 1 assert after.ahead >= 1
def test_create_and_checkout_branch_in_clone(self): def test_create_and_checkout_branch_in_clone(self):
self.capsule.git.create_branch("sdk-feature", cwd="/root/wrenn") self.capsule.git.create_branch("sdk-feature", cwd="/home/wrenn-user/wrenn")
branches = self.capsule.git.branches(cwd="/root/wrenn") branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn")
current = [b for b in branches if b.is_current] current = [b for b in branches if b.is_current]
assert current and current[0].name == "sdk-feature" assert current and current[0].name == "sdk-feature"
self.capsule.git.checkout_branch("main", cwd="/root/wrenn") self.capsule.git.checkout_branch("main", cwd="/home/wrenn-user/wrenn")
def test_diff_via_commands(self): def test_diff_via_commands(self):
self.capsule.files.write("/root/wrenn/README.md", "overwritten\n") self.capsule.files.write("/home/wrenn-user/wrenn/README.md", "overwritten\n")
try: try:
result = self.capsule.commands.run("git diff --stat", cwd="/root/wrenn") result = self.capsule.commands.run(
"git diff --stat", cwd="/home/wrenn-user/wrenn"
)
assert "README.md" in result.stdout assert "README.md" in result.stdout
finally: finally:
self.capsule.git.restore(["README.md"], worktree=True, cwd="/root/wrenn") self.capsule.git.restore(
["README.md"], worktree=True, cwd="/home/wrenn-user/wrenn"
)
class TestGitErrors: class TestGitErrors:
@ -481,7 +511,7 @@ class TestGitErrors:
with pytest.raises(GitError): with pytest.raises(GitError):
self.capsule.git.clone( self.capsule.git.clone(
"https://github.com/wrennhq/this-repo-does-not-exist-xyz", "https://github.com/wrennhq/this-repo-does-not-exist-xyz",
"/root/missing", "/home/wrenn-user/missing",
timeout=120, timeout=120,
) )
@ -493,7 +523,11 @@ class TestGitErrors:
def test_clone_with_branch(self): def test_clone_with_branch(self):
self.capsule.git.clone( self.capsule.git.clone(
WRENN_REPO, "/root/wrenn-main", branch="main", depth=1, timeout=300 WRENN_REPO,
"/home/wrenn-user/wrenn-main",
branch="main",
depth=1,
timeout=300,
) )
status = self.capsule.git.status(cwd="/root/wrenn-main") status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn-main")
assert status.branch == "main" assert status.branch == "main"

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" }, { 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]] [[package]]
name = "docspec" name = "docspec"
version = "2.2.1" version = "2.2.1"
@ -328,19 +319,6 @@ version = "0.11"
source = { registry = "https://pypi.org/simple" } 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" } 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]] [[package]]
name = "filelock" name = "filelock"
version = "3.29.0" version = "3.29.0"
@ -1166,11 +1144,10 @@ wheels = [
[[package]] [[package]]
name = "wrenn" name = "wrenn"
version = "0.1.4" version = "0.3.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "certifi" }, { name = "certifi" },
{ name = "email-validator" },
{ name = "httpx" }, { name = "httpx" },
{ name = "httpx-ws" }, { name = "httpx-ws" },
{ name = "pydantic" }, { name = "pydantic" },
@ -1184,6 +1161,7 @@ dev = [
{ name = "pydoc-markdown" }, { name = "pydoc-markdown" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pyyaml" },
{ name = "respx" }, { name = "respx" },
{ name = "ruff" }, { name = "ruff" },
] ]
@ -1191,7 +1169,6 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "certifi", specifier = ">=2026.2.25" }, { name = "certifi", specifier = ">=2026.2.25" },
{ name = "email-validator", specifier = ">=2.3.0" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "httpx-ws", specifier = ">=0.9.0" }, { name = "httpx-ws", specifier = ">=0.9.0" },
{ name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic", specifier = ">=2.12.5" },
@ -1205,6 +1182,7 @@ dev = [
{ name = "pydoc-markdown", specifier = ">=4.8.2" }, { name = "pydoc-markdown", specifier = ">=4.8.2" },
{ name = "pytest", specifier = ">=9.0.3" }, { name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-asyncio", specifier = ">=1.3.0" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "respx", specifier = ">=0.23.1" }, { name = "respx", specifier = ">=0.23.1" },
{ name = "ruff", specifier = ">=0.15.10" }, { name = "ruff", specifier = ">=0.15.10" },
] ]