fix: update SDK for v0.2.0 API compatibility
Some checks failed
ci/woodpecker/pr/check Pipeline failed
Some checks failed
ci/woodpecker/pr/check Pipeline failed
Sync OpenAPI spec to v0.2.0, fix type annotation shadowing by using builtins.list in annotated signatures, guard poll interval lookup against None status, and reorder capsule ID assignment to validate before storing.
This commit is contained in:
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