v0.1.5 #13

Merged
pptx704 merged 56 commits from dev into main 2026-05-22 23:01:46 +00:00
40 changed files with 1915 additions and 6838 deletions
Showing only changes of commit e5e4e1a85b - Show all commits

1
.gitignore vendored
View File

@ -181,3 +181,4 @@ CODE_EXECUTION.md
.code-review-graph/
.claude
.mcp.json
AGENTS.md

View File

@ -1,56 +0,0 @@
# AGENTS.md
## Project
Wrenn Python SDK — a client library for the Wrenn microVM platform. e2b drop-in replacement.
Package name: `wrenn`. Python 3.13+, managed with [uv](https://docs.astral.sh/uv/).
## Commands
```bash
uv sync # install deps
make lint # ruff check + format check (no auto-fix)
make test # unit tests only (tests/test_client.py)
make test-integration # all tests including integration (needs live server)
make generate # regenerate models from OpenAPI spec (fetches from remote)
make check # lint + unit test
```
- `make test` only runs `tests/test_client.py`, not all unit tests. To run a specific test file: `uv run pytest tests/test_capsule_features.py -v`
- No typecheck step in Makefile or CI. `mypy` is a dev dependency but not wired up — do not assume it runs.
## Architecture
- `src/wrenn/` — the library package
- `capsule.py` / `async_capsule.py` — high-level `Capsule` / `AsyncCapsule` (main user-facing classes)
- `client.py` — low-level `WrennClient` / `AsyncWrennClient`
- `commands.py` — command execution and streaming
- `files.py` — filesystem operations
- `pty.py` — interactive terminal (PTY) over WebSocket
- `exceptions.py` — typed error hierarchy (`WrennError` base)
- `models/_generated.py`**auto-generated** from OpenAPI spec via `datamodel-codegen` (never edit directly; run `make generate`)
- `sandbox.py` — deprecated `Sandbox` alias for `Capsule`
- `code_interpreter/` — specialized capsule for stateful Jupyter kernel execution
- `tests/` — unit tests use `respx` to mock `httpx`; integration tests are in `tests/integration/`
- `api/openapi.yaml` — downloaded OpenAPI spec used for code generation
## Key Conventions
- Generated code lives in `src/wrenn/models/_generated.py`. Never edit it. Run `make generate` to update.
- `Sandbox` is a deprecated alias for `Capsule`. New code should use `Capsule` / `AsyncCapsule`.
- Dual sync/async API: every major class has an `Async` counterpart.
- Uses `httpx` for HTTP, `httpx-ws` for WebSockets, `pydantic` for models.
- `__init__.py` uses `__getattr__` for lazy deprecated aliases (`Sandbox`, `WrennHostHasSandboxesError`).
## Testing
- Unit tests mock HTTP via `respx` (httpx mocking library).
- Integration tests require env vars: `WRENN_API_KEY` (or `WRENN_TOKEN`), optionally `WRENN_BASE_URL`.
- Integration test fixtures in `tests/integration/conftest.py` create real capsules and clean them up.
- `pytest` marker: `@pytest.mark.integration` for tests needing a live server.
## CI
Woodpecker CI (`.woodpecker/check.yml`) runs on push to `main` and `dev`:
1. `make lint`
2. `make test` (unit tests only — integration tests are not in CI)

View File

@ -1,8 +1,8 @@
openapi: "3.1.0"
info:
title: Wrenn API
description: MicroVM-based code execution platform API.
version: "0.1.3"
description: AI agent execution platform API.
version: "0.2.0"
servers:
- url: http://localhost:8080
@ -866,8 +866,8 @@ paths:
schema:
$ref: "#/components/schemas/CreateCapsuleRequest"
responses:
"201":
description: Capsule created
"202":
description: Capsule creation initiated (status will be "starting")
content:
application/json:
schema:
@ -988,8 +988,8 @@ paths:
security:
- apiKeyAuth: []
responses:
"204":
description: Capsule destroyed
"202":
description: Capsule destruction initiated
/v1/capsules/{id}/exec:
parameters:
@ -1260,8 +1260,8 @@ paths:
destroys all running resources. The capsule exists only as files on
disk and can be resumed later.
responses:
"200":
description: Capsule paused (snapshot taken, resources released)
"202":
description: Capsule pause initiated (status will be "pausing")
content:
application/json:
schema:
@ -1292,8 +1292,8 @@ paths:
memory loading. Boots a fresh Firecracker process, sets up a new
network slot, and waits for envd to become ready.
responses:
"200":
description: Capsule resumed (new VM booted from snapshot)
"202":
description: Capsule resume initiated (status will be "resuming")
content:
application/json:
schema:
@ -2035,6 +2035,51 @@ paths:
schema:
$ref: "#/components/schemas/Error"
/v1/hosts/sandbox-events:
post:
summary: Sandbox lifecycle event callback
operationId: sandboxEventCallback
tags: [hosts]
security:
- hostTokenAuth: []
description: |
Receives autonomous lifecycle events from host agents (e.g. auto-pause
from the TTL reaper). The event is published to an internal Redis stream
for the control plane's event consumer to process.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [event, sandbox_id, host_id]
properties:
event:
type: string
enum: [sandbox.auto_paused]
sandbox_id:
type: string
host_id:
type: string
timestamp:
type: integer
format: int64
responses:
"204":
description: Event accepted
"400":
description: Invalid request
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
description: Host ID mismatch
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/hosts/auth/refresh:
post:
summary: Refresh host JWT
@ -2346,6 +2391,54 @@ paths:
schema:
$ref: "#/components/schemas/Error"
/v1/admin/users/{id}/admin:
put:
summary: Grant or revoke platform admin
operationId: setUserAdmin
tags: [admin]
description: |
Sets the platform admin flag on a user. Cannot remove the last admin.
Requires platform admin access (JWT + is_admin).
The target user's JWT is not re-issued — their frontend will reflect the
change on next login or team switch.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
example: "usr-a1b2c3d4"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [admin]
properties:
admin:
type: boolean
description: true to grant admin, false to revoke.
responses:
"204":
description: Admin status updated
"400":
$ref: "#/components/responses/BadRequest"
"403":
description: Caller is not a platform admin
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: User not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
components:
securitySchemes:
apiKeyAuth:
@ -2544,7 +2637,7 @@ components:
type: string
status:
type: string
enum: [pending, starting, running, paused, hibernated, stopped, missing, error]
enum: [pending, starting, running, pausing, paused, resuming, stopping, hibernated, stopped, missing, error]
template:
type: string
vcpus:

View File

@ -1964,15 +1964,17 @@ inactivity TTL is set.
#### wait\_ready
```python
async def wait_ready(timeout: float = 30, interval: float = 0.5) -> None
async def wait_ready(timeout: float = 30) -> None
```
Await until the capsule status is ``running``.
Polling interval adapts to the current transient status:
0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
**Arguments**:
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
- `interval` _float_ - Polling interval in seconds. Defaults to ``0.5``.
**Raises**:
@ -2534,15 +2536,17 @@ inactivity TTL is set.
#### wait\_ready
```python
def wait_ready(timeout: float = 30, interval: float = 0.5) -> None
def wait_ready(timeout: float = 30) -> None
```
Block until the capsule status is ``running``.
Polling interval adapts to the current transient status:
0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
**Arguments**:
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
- `interval` _float_ - Polling interval in seconds. Defaults to ``0.5``.
**Raises**:
@ -2700,17 +2704,6 @@ Create a snapshot template from this capsule's current state.
# wrenn.\_config
<a id="wrenn._config.ConnectionConfig"></a>
## ConnectionConfig Objects
```python
@dataclass(frozen=True)
class ConnectionConfig()
```
Resolved credentials and base URL for Wrenn API calls.
<a id="wrenn._git._auth"></a>
# wrenn.\_git.\_auth

View File

@ -1,8 +1,8 @@
from __future__ import annotations
import asyncio
import logging
import builtins
import logging
import time
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
@ -140,14 +140,19 @@ class AsyncCapsule:
info = await client.capsules.get(capsule_id)
if info.status == Status.paused:
info = await client.capsules.resume(capsule_id)
await client.capsules.resume(capsule_id)
return cls(
capsule = cls(
_capsule_id=capsule_id,
_client=client,
_info=info,
)
if info.status != Status.running:
await capsule.wait_ready()
return capsule
# ── Dual instance/static lifecycle ──────────────────────────
destroy = _DualMethod("_instance_destroy", "_static_destroy")
@ -224,12 +229,21 @@ class AsyncCapsule:
"""
await self._client.capsules.ping(self._id)
async def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None:
_POLL_INTERVALS: dict[Status, float] = {
Status.starting: 0.5,
Status.resuming: 0.5,
Status.pausing: 2.0,
Status.stopping: 1.0,
}
async def wait_ready(self, timeout: float = 30) -> None:
"""Await until the capsule status is ``running``.
Polling interval adapts to the current transient status:
0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
Args:
timeout (float): Maximum seconds to wait. Defaults to ``30``.
interval (float): Polling interval in seconds. Defaults to ``0.5``.
Raises:
TimeoutError: If the capsule does not reach ``running`` state
@ -246,7 +260,10 @@ class AsyncCapsule:
if info.status in (Status.error, Status.stopped):
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
if info.status == Status.paused:
info = await self._client.capsules.resume(self._id)
await self._client.capsules.resume(self._id)
interval = (
self._POLL_INTERVALS.get(info.status, 0.5) if info.status else 0.5
)
await asyncio.sleep(interval)
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")

View File

@ -1,7 +1,7 @@
from __future__ import annotations
import logging
import builtins
import logging
import time
from collections.abc import Iterator
from contextlib import contextmanager
@ -112,9 +112,9 @@ class Capsule:
memory_mb=memory_mb,
timeout_sec=timeout,
)
self._id = self._info.id
if self._id is None:
if self._info.id is None:
raise RuntimeError("API returned a capsule without an ID")
self._id = self._info.id
except Exception:
self._client.close()
raise
@ -214,14 +214,19 @@ class Capsule:
info = client.capsules.get(capsule_id)
if info.status == Status.paused:
info = client.capsules.resume(capsule_id)
client.capsules.resume(capsule_id)
return cls(
capsule = cls(
_capsule_id=capsule_id,
_client=client,
_info=info,
)
if info.status != Status.running:
capsule.wait_ready()
return capsule
# ── Dual instance/static lifecycle ──────────────────────────
destroy = _DualMethod("_instance_destroy", "_static_destroy")
@ -306,12 +311,21 @@ class Capsule:
"""
self._client.capsules.ping(self._id)
def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None:
_POLL_INTERVALS: dict[Status, float] = {
Status.starting: 0.5,
Status.resuming: 0.5,
Status.pausing: 2.0,
Status.stopping: 1.0,
}
def wait_ready(self, timeout: float = 30) -> None:
"""Block until the capsule status is ``running``.
Polling interval adapts to the current transient status:
0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
Args:
timeout (float): Maximum seconds to wait. Defaults to ``30``.
interval (float): Polling interval in seconds. Defaults to ``0.5``.
Raises:
TimeoutError: If the capsule does not reach ``running`` state
@ -328,7 +342,10 @@ class Capsule:
if info.status in (Status.error, Status.stopped):
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
if info.status == Status.paused:
info = self._client.capsules.resume(self._id)
self._client.capsules.resume(self._id)
interval = (
self._POLL_INTERVALS.get(info.status, 0.5) if info.status else 0.5
)
time.sleep(interval)
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")

View File

@ -111,7 +111,7 @@ class CapsulesResource:
Raises:
WrennNotFoundError: If no capsule with the given ID exists.
"""
resp = self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT)
resp = self._http.post(f"/v1/capsules/{id}/pause")
return CapsuleModel.model_validate(handle_response(resp))
def resume(self, id: str) -> CapsuleModel:
@ -227,7 +227,7 @@ class AsyncCapsulesResource:
Raises:
WrennNotFoundError: If no capsule with the given ID exists.
"""
resp = await self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT)
resp = await self._http.post(f"/v1/capsules/{id}/pause")
return CapsuleModel.model_validate(handle_response(resp))
async def resume(self, id: str) -> CapsuleModel:

View File

@ -150,6 +150,9 @@ def handle_response(resp: httpx.Response) -> dict | list:
if resp.status_code == 204:
return {}
if not resp.content:
return {}
return resp.json()

View File

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: openapi.yaml
# timestamp: 2026-04-22T20:21:34+00:00
# timestamp: 2026-05-15T07:57:28+00:00
from __future__ import annotations
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
@ -133,7 +133,10 @@ class Status(StrEnum):
pending = "pending"
starting = "starting"
running = "running"
pausing = "pausing"
paused = "paused"
resuming = "resuming"
stopping = "stopping"
hibernated = "hibernated"
stopped = "stopped"
missing = "missing"

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import httpx
import respx
from wrenn.capsule import Capsule, _build_proxy_url
@ -30,9 +31,13 @@ class TestCapsuleCreate:
@respx.mock
def test_capsule_constructor_creates(self):
respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-1", "status": "pending", "template": "minimal"}
202, json={"id": "cl-1", "status": "starting", "template": "minimal"}
)
cap = Capsule(
template="minimal",
api_key="wrn_test1234567890abcdef12345678",
base_url=BASE,
)
cap = Capsule(template="minimal", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert cap.capsule_id == "cl-1"
assert hasattr(cap, "commands")
assert hasattr(cap, "files")
@ -40,7 +45,7 @@ class TestCapsuleCreate:
@respx.mock
def test_capsule_create_classmethod(self):
respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-2", "status": "pending"}
202, json={"id": "cl-2", "status": "starting"}
)
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert cap.capsule_id == "cl-2"
@ -48,9 +53,9 @@ class TestCapsuleCreate:
@respx.mock
def test_capsule_context_manager_kills(self):
respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-1", "status": "pending"}
202, json={"id": "cl-1", "status": "starting"}
)
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
with Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as cap:
assert cap.capsule_id == "cl-1"
assert kill_route.called
@ -59,7 +64,7 @@ class TestCapsuleCreate:
def test_capsule_env_var(self, monkeypatch):
monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env_key")
respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-3", "status": "pending"}
202, json={"id": "cl-3", "status": "starting"}
)
cap = Capsule(base_url=BASE)
assert cap.capsule_id == "cl-3"
@ -68,17 +73,21 @@ class TestCapsuleCreate:
class TestCapsuleStaticMethods:
@respx.mock
def test_static_destroy(self):
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
Capsule._static_destroy(
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
)
assert route.called
@respx.mock
def test_static_pause(self):
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
200, json={"id": "cl-1", "status": "paused"}
202, json={"id": "cl-1", "status": "pausing"}
)
info = Capsule._static_pause("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert info.status.value == "paused"
info = Capsule._static_pause(
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
)
assert info.status.value == "pausing"
@respx.mock
def test_static_list(self):
@ -106,18 +115,24 @@ class TestCapsuleConnect:
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
200, json={"id": "cl-1", "status": "running"}
)
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
cap = Capsule.connect(
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
)
assert cap.capsule_id == "cl-1"
@respx.mock
def test_connect_paused_resumes(self):
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
200, json={"id": "cl-1", "status": "paused"}
)
get_route = respx.get(f"{BASE}/v1/capsules/cl-1")
get_route.side_effect = [
httpx.Response(200, json={"id": "cl-1", "status": "paused"}),
httpx.Response(200, json={"id": "cl-1", "status": "running"}),
]
respx.post(f"{BASE}/v1/capsules/cl-1/resume").respond(
200, json={"id": "cl-1", "status": "running"}
202, json={"id": "cl-1", "status": "resuming"}
)
cap = Capsule.connect(
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
)
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert cap.capsule_id == "cl-1"

View File

@ -36,10 +36,10 @@ class TestCapsules:
@respx.mock
def test_create(self, client):
respx.post(f"{BASE}/v1/capsules").respond(
201,
202,
json={
"id": "sb-1",
"status": "pending",
"status": "starting",
"template": "base-python",
"vcpus": 2,
"memory_mb": 1024,
@ -48,12 +48,12 @@ class TestCapsules:
resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024)
assert isinstance(resp, Capsule)
assert resp.id == "sb-1"
assert resp.status == Status.pending
assert resp.status == Status.starting
@respx.mock
def test_create_defaults(self, client):
respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "sb-2", "status": "pending"}
202, json={"id": "sb-2", "status": "starting"}
)
resp = client.capsules.create()
assert resp.id == "sb-2"
@ -77,25 +77,25 @@ class TestCapsules:
@respx.mock
def test_destroy(self, client):
route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(204)
route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(202)
client.capsules.destroy("sb-1")
assert route.called
@respx.mock
def test_pause(self, client):
respx.post(f"{BASE}/v1/capsules/sb-1/pause").respond(
200, json={"id": "sb-1", "status": "paused"}
202, json={"id": "sb-1", "status": "pausing"}
)
resp = client.capsules.pause("sb-1")
assert resp.status == Status.paused
assert resp.status == Status.pausing
@respx.mock
def test_resume(self, client):
respx.post(f"{BASE}/v1/capsules/sb-1/resume").respond(
200, json={"id": "sb-1", "status": "running"}
202, json={"id": "sb-1", "status": "resuming"}
)
resp = client.capsules.resume("sb-1")
assert resp.status == Status.running
assert resp.status == Status.resuming
@respx.mock
def test_ping(self, client):
@ -238,7 +238,7 @@ class TestAsyncClient:
async def test_async_capsules_create(self, async_client):
async with async_client:
respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "sb-1", "status": "pending"}
202, json={"id": "sb-1", "status": "starting"}
)
resp = await async_client.capsules.create(template="base-python")
assert resp.id == "sb-1"