fix: update SDK for v0.2.0 API compatibility #10
1
.gitignore
vendored
1
.gitignore
vendored
@ -181,3 +181,4 @@ CODE_EXECUTION.md
|
|||||||
.code-review-graph/
|
.code-review-graph/
|
||||||
.claude
|
.claude
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
AGENTS.md
|
||||||
|
|||||||
56
AGENTS.md
56
AGENTS.md
@ -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)
|
|
||||||
115
api/openapi.yaml
115
api/openapi.yaml
@ -1,8 +1,8 @@
|
|||||||
openapi: "3.1.0"
|
openapi: "3.1.0"
|
||||||
info:
|
info:
|
||||||
title: Wrenn API
|
title: Wrenn API
|
||||||
description: MicroVM-based code execution platform API.
|
description: AI agent execution platform API.
|
||||||
version: "0.1.3"
|
version: "0.2.0"
|
||||||
|
|
||||||
servers:
|
servers:
|
||||||
- url: http://localhost:8080
|
- url: http://localhost:8080
|
||||||
@ -866,8 +866,8 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/CreateCapsuleRequest"
|
$ref: "#/components/schemas/CreateCapsuleRequest"
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"202":
|
||||||
description: Capsule created
|
description: Capsule creation initiated (status will be "starting")
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@ -988,8 +988,8 @@ paths:
|
|||||||
security:
|
security:
|
||||||
- apiKeyAuth: []
|
- apiKeyAuth: []
|
||||||
responses:
|
responses:
|
||||||
"204":
|
"202":
|
||||||
description: Capsule destroyed
|
description: Capsule destruction initiated
|
||||||
|
|
||||||
/v1/capsules/{id}/exec:
|
/v1/capsules/{id}/exec:
|
||||||
parameters:
|
parameters:
|
||||||
@ -1260,8 +1260,8 @@ paths:
|
|||||||
destroys all running resources. The capsule exists only as files on
|
destroys all running resources. The capsule exists only as files on
|
||||||
disk and can be resumed later.
|
disk and can be resumed later.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"202":
|
||||||
description: Capsule paused (snapshot taken, resources released)
|
description: Capsule pause initiated (status will be "pausing")
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@ -1292,8 +1292,8 @@ paths:
|
|||||||
memory loading. Boots a fresh Firecracker process, sets up a new
|
memory loading. Boots a fresh Firecracker process, sets up a new
|
||||||
network slot, and waits for envd to become ready.
|
network slot, and waits for envd to become ready.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"202":
|
||||||
description: Capsule resumed (new VM booted from snapshot)
|
description: Capsule resume initiated (status will be "resuming")
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@ -2035,6 +2035,51 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Error"
|
$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:
|
/v1/hosts/auth/refresh:
|
||||||
post:
|
post:
|
||||||
summary: Refresh host JWT
|
summary: Refresh host JWT
|
||||||
@ -2346,6 +2391,54 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Error"
|
$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:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
apiKeyAuth:
|
apiKeyAuth:
|
||||||
@ -2544,7 +2637,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum: [pending, starting, running, paused, hibernated, stopped, missing, error]
|
enum: [pending, starting, running, pausing, paused, resuming, stopping, hibernated, stopped, missing, error]
|
||||||
template:
|
template:
|
||||||
type: string
|
type: string
|
||||||
vcpus:
|
vcpus:
|
||||||
|
|||||||
@ -1964,15 +1964,17 @@ inactivity TTL is set.
|
|||||||
#### wait\_ready
|
#### wait\_ready
|
||||||
|
|
||||||
```python
|
```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``.
|
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**:
|
**Arguments**:
|
||||||
|
|
||||||
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
|
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
|
||||||
- `interval` _float_ - Polling interval in seconds. Defaults to ``0.5``.
|
|
||||||
|
|
||||||
|
|
||||||
**Raises**:
|
**Raises**:
|
||||||
@ -2534,15 +2536,17 @@ inactivity TTL is set.
|
|||||||
#### wait\_ready
|
#### wait\_ready
|
||||||
|
|
||||||
```python
|
```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``.
|
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**:
|
**Arguments**:
|
||||||
|
|
||||||
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
|
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
|
||||||
- `interval` _float_ - Polling interval in seconds. Defaults to ``0.5``.
|
|
||||||
|
|
||||||
|
|
||||||
**Raises**:
|
**Raises**:
|
||||||
@ -2700,17 +2704,6 @@ Create a snapshot template from this capsule's current state.
|
|||||||
|
|
||||||
# wrenn.\_config
|
# 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>
|
<a id="wrenn._git._auth"></a>
|
||||||
|
|
||||||
# wrenn.\_git.\_auth
|
# wrenn.\_git.\_auth
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import builtins
|
import builtins
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@ -140,14 +140,19 @@ class AsyncCapsule:
|
|||||||
info = await client.capsules.get(capsule_id)
|
info = await client.capsules.get(capsule_id)
|
||||||
|
|
||||||
if info.status == Status.paused:
|
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,
|
_capsule_id=capsule_id,
|
||||||
_client=client,
|
_client=client,
|
||||||
_info=info,
|
_info=info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if info.status != Status.running:
|
||||||
|
await capsule.wait_ready()
|
||||||
|
|
||||||
|
return capsule
|
||||||
|
|
||||||
# ── Dual instance/static lifecycle ──────────────────────────
|
# ── Dual instance/static lifecycle ──────────────────────────
|
||||||
|
|
||||||
destroy = _DualMethod("_instance_destroy", "_static_destroy")
|
destroy = _DualMethod("_instance_destroy", "_static_destroy")
|
||||||
@ -224,12 +229,21 @@ class AsyncCapsule:
|
|||||||
"""
|
"""
|
||||||
await self._client.capsules.ping(self._id)
|
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``.
|
"""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:
|
Args:
|
||||||
timeout (float): Maximum seconds to wait. Defaults to ``30``.
|
timeout (float): Maximum seconds to wait. Defaults to ``30``.
|
||||||
interval (float): Polling interval in seconds. Defaults to ``0.5``.
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TimeoutError: If the capsule does not reach ``running`` state
|
TimeoutError: If the capsule does not reach ``running`` state
|
||||||
@ -246,7 +260,10 @@ class AsyncCapsule:
|
|||||||
if info.status in (Status.error, Status.stopped):
|
if info.status in (Status.error, Status.stopped):
|
||||||
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
||||||
if info.status == Status.paused:
|
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)
|
await asyncio.sleep(interval)
|
||||||
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import builtins
|
import builtins
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
@ -112,9 +112,9 @@ class Capsule:
|
|||||||
memory_mb=memory_mb,
|
memory_mb=memory_mb,
|
||||||
timeout_sec=timeout,
|
timeout_sec=timeout,
|
||||||
)
|
)
|
||||||
self._id = self._info.id
|
if self._info.id is None:
|
||||||
if self._id is None:
|
|
||||||
raise RuntimeError("API returned a capsule without an ID")
|
raise RuntimeError("API returned a capsule without an ID")
|
||||||
|
self._id = self._info.id
|
||||||
except Exception:
|
except Exception:
|
||||||
self._client.close()
|
self._client.close()
|
||||||
raise
|
raise
|
||||||
@ -214,14 +214,19 @@ class Capsule:
|
|||||||
info = client.capsules.get(capsule_id)
|
info = client.capsules.get(capsule_id)
|
||||||
|
|
||||||
if info.status == Status.paused:
|
if info.status == Status.paused:
|
||||||
info = client.capsules.resume(capsule_id)
|
client.capsules.resume(capsule_id)
|
||||||
|
|
||||||
return cls(
|
capsule = cls(
|
||||||
_capsule_id=capsule_id,
|
_capsule_id=capsule_id,
|
||||||
_client=client,
|
_client=client,
|
||||||
_info=info,
|
_info=info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if info.status != Status.running:
|
||||||
|
capsule.wait_ready()
|
||||||
|
|
||||||
|
return capsule
|
||||||
|
|
||||||
# ── Dual instance/static lifecycle ──────────────────────────
|
# ── Dual instance/static lifecycle ──────────────────────────
|
||||||
|
|
||||||
destroy = _DualMethod("_instance_destroy", "_static_destroy")
|
destroy = _DualMethod("_instance_destroy", "_static_destroy")
|
||||||
@ -306,12 +311,21 @@ class Capsule:
|
|||||||
"""
|
"""
|
||||||
self._client.capsules.ping(self._id)
|
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``.
|
"""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:
|
Args:
|
||||||
timeout (float): Maximum seconds to wait. Defaults to ``30``.
|
timeout (float): Maximum seconds to wait. Defaults to ``30``.
|
||||||
interval (float): Polling interval in seconds. Defaults to ``0.5``.
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TimeoutError: If the capsule does not reach ``running`` state
|
TimeoutError: If the capsule does not reach ``running`` state
|
||||||
@ -328,7 +342,10 @@ class Capsule:
|
|||||||
if info.status in (Status.error, Status.stopped):
|
if info.status in (Status.error, Status.stopped):
|
||||||
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
||||||
if info.status == Status.paused:
|
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)
|
time.sleep(interval)
|
||||||
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
||||||
|
|
||||||
|
|||||||
@ -111,7 +111,7 @@ class CapsulesResource:
|
|||||||
Raises:
|
Raises:
|
||||||
WrennNotFoundError: If no capsule with the given ID exists.
|
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))
|
return CapsuleModel.model_validate(handle_response(resp))
|
||||||
|
|
||||||
def resume(self, id: str) -> CapsuleModel:
|
def resume(self, id: str) -> CapsuleModel:
|
||||||
@ -227,7 +227,7 @@ class AsyncCapsulesResource:
|
|||||||
Raises:
|
Raises:
|
||||||
WrennNotFoundError: If no capsule with the given ID exists.
|
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))
|
return CapsuleModel.model_validate(handle_response(resp))
|
||||||
|
|
||||||
async def resume(self, id: str) -> CapsuleModel:
|
async def resume(self, id: str) -> CapsuleModel:
|
||||||
|
|||||||
@ -150,6 +150,9 @@ def handle_response(resp: httpx.Response) -> dict | list:
|
|||||||
if resp.status_code == 204:
|
if resp.status_code == 204:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
if not resp.content:
|
||||||
|
return {}
|
||||||
|
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# generated by datamodel-codegen:
|
# generated by datamodel-codegen:
|
||||||
# filename: openapi.yaml
|
# filename: openapi.yaml
|
||||||
# timestamp: 2026-04-22T20:21:34+00:00
|
# timestamp: 2026-05-15T07:57:28+00:00
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
|
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
|
||||||
@ -133,7 +133,10 @@ class Status(StrEnum):
|
|||||||
pending = "pending"
|
pending = "pending"
|
||||||
starting = "starting"
|
starting = "starting"
|
||||||
running = "running"
|
running = "running"
|
||||||
|
pausing = "pausing"
|
||||||
paused = "paused"
|
paused = "paused"
|
||||||
|
resuming = "resuming"
|
||||||
|
stopping = "stopping"
|
||||||
hibernated = "hibernated"
|
hibernated = "hibernated"
|
||||||
stopped = "stopped"
|
stopped = "stopped"
|
||||||
missing = "missing"
|
missing = "missing"
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
import respx
|
import respx
|
||||||
|
|
||||||
from wrenn.capsule import Capsule, _build_proxy_url
|
from wrenn.capsule import Capsule, _build_proxy_url
|
||||||
@ -30,9 +31,13 @@ class TestCapsuleCreate:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
def test_capsule_constructor_creates(self):
|
def test_capsule_constructor_creates(self):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
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 cap.capsule_id == "cl-1"
|
||||||
assert hasattr(cap, "commands")
|
assert hasattr(cap, "commands")
|
||||||
assert hasattr(cap, "files")
|
assert hasattr(cap, "files")
|
||||||
@ -40,7 +45,7 @@ class TestCapsuleCreate:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
def test_capsule_create_classmethod(self):
|
def test_capsule_create_classmethod(self):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
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)
|
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||||
assert cap.capsule_id == "cl-2"
|
assert cap.capsule_id == "cl-2"
|
||||||
@ -48,9 +53,9 @@ class TestCapsuleCreate:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
def test_capsule_context_manager_kills(self):
|
def test_capsule_context_manager_kills(self):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
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:
|
with Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as cap:
|
||||||
assert cap.capsule_id == "cl-1"
|
assert cap.capsule_id == "cl-1"
|
||||||
assert kill_route.called
|
assert kill_route.called
|
||||||
@ -59,7 +64,7 @@ class TestCapsuleCreate:
|
|||||||
def test_capsule_env_var(self, monkeypatch):
|
def test_capsule_env_var(self, monkeypatch):
|
||||||
monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env_key")
|
monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env_key")
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
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)
|
cap = Capsule(base_url=BASE)
|
||||||
assert cap.capsule_id == "cl-3"
|
assert cap.capsule_id == "cl-3"
|
||||||
@ -68,17 +73,21 @@ class TestCapsuleCreate:
|
|||||||
class TestCapsuleStaticMethods:
|
class TestCapsuleStaticMethods:
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_static_destroy(self):
|
def test_static_destroy(self):
|
||||||
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
|
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
|
||||||
Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
Capsule._static_destroy(
|
||||||
|
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||||
|
)
|
||||||
assert route.called
|
assert route.called
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_static_pause(self):
|
def test_static_pause(self):
|
||||||
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
|
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)
|
info = Capsule._static_pause(
|
||||||
assert info.status.value == "paused"
|
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||||
|
)
|
||||||
|
assert info.status.value == "pausing"
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_static_list(self):
|
def test_static_list(self):
|
||||||
@ -106,18 +115,24 @@ class TestCapsuleConnect:
|
|||||||
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
||||||
200, json={"id": "cl-1", "status": "running"}
|
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"
|
assert cap.capsule_id == "cl-1"
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_connect_paused_resumes(self):
|
def test_connect_paused_resumes(self):
|
||||||
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
get_route = respx.get(f"{BASE}/v1/capsules/cl-1")
|
||||||
200, json={"id": "cl-1", "status": "paused"}
|
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(
|
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"
|
assert cap.capsule_id == "cl-1"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -36,10 +36,10 @@ class TestCapsules:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
def test_create(self, client):
|
def test_create(self, client):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201,
|
202,
|
||||||
json={
|
json={
|
||||||
"id": "sb-1",
|
"id": "sb-1",
|
||||||
"status": "pending",
|
"status": "starting",
|
||||||
"template": "base-python",
|
"template": "base-python",
|
||||||
"vcpus": 2,
|
"vcpus": 2,
|
||||||
"memory_mb": 1024,
|
"memory_mb": 1024,
|
||||||
@ -48,12 +48,12 @@ class TestCapsules:
|
|||||||
resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024)
|
resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024)
|
||||||
assert isinstance(resp, Capsule)
|
assert isinstance(resp, Capsule)
|
||||||
assert resp.id == "sb-1"
|
assert resp.id == "sb-1"
|
||||||
assert resp.status == Status.pending
|
assert resp.status == Status.starting
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_create_defaults(self, client):
|
def test_create_defaults(self, client):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
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()
|
resp = client.capsules.create()
|
||||||
assert resp.id == "sb-2"
|
assert resp.id == "sb-2"
|
||||||
@ -77,25 +77,25 @@ class TestCapsules:
|
|||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_destroy(self, client):
|
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")
|
client.capsules.destroy("sb-1")
|
||||||
assert route.called
|
assert route.called
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_pause(self, client):
|
def test_pause(self, client):
|
||||||
respx.post(f"{BASE}/v1/capsules/sb-1/pause").respond(
|
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")
|
resp = client.capsules.pause("sb-1")
|
||||||
assert resp.status == Status.paused
|
assert resp.status == Status.pausing
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_resume(self, client):
|
def test_resume(self, client):
|
||||||
respx.post(f"{BASE}/v1/capsules/sb-1/resume").respond(
|
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")
|
resp = client.capsules.resume("sb-1")
|
||||||
assert resp.status == Status.running
|
assert resp.status == Status.resuming
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_ping(self, client):
|
def test_ping(self, client):
|
||||||
@ -238,7 +238,7 @@ class TestAsyncClient:
|
|||||||
async def test_async_capsules_create(self, async_client):
|
async def test_async_capsules_create(self, async_client):
|
||||||
async with async_client:
|
async with async_client:
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
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")
|
resp = await async_client.capsules.create(template="base-python")
|
||||||
assert resp.id == "sb-1"
|
assert resp.id == "sb-1"
|
||||||
|
|||||||
Reference in New Issue
Block a user