Compare commits
35 Commits
v0.1.3
...
e5e4e1a85b
| Author | SHA1 | Date | |
|---|---|---|---|
| e5e4e1a85b | |||
| d9c028564e | |||
| 06b4a8cbcb | |||
| 04e5dc652f | |||
| 4a7db8e204 | |||
| a76be96682 | |||
| dc66ac24d5 | |||
| b5e2b12ef1 | |||
| 213af4aee7 | |||
| aa9477ffe8 | |||
| 2bb3dbd71d | |||
| 3f26a2fbcf | |||
| 2faf0dd0ae | |||
| 68c7d0de42 | |||
| ad64c85393 | |||
| bab53aedbe | |||
| 82e181dd7e | |||
| ee1f55635f | |||
| 6bdf28e2ae | |||
| 61bc040098 | |||
| 7b35ffb60c | |||
| 42bcc792d6 | |||
| 3f97c73b2f | |||
| 7e7ecbd48a | |||
| 7b9a06d1b5 | |||
| 3d0eda5c60 | |||
| eecf1dc65b | |||
| 3cced768a4 | |||
| 0ac9bf79ee | |||
| bf5914c0a8 | |||
| 976af9a209 | |||
| f3fd6865f9 | |||
| 340ed46df6 | |||
| a5bf66c199 | |||
| f51a962fff |
1
.gitignore
vendored
1
.gitignore
vendored
@ -181,3 +181,4 @@ CODE_EXECUTION.md
|
||||
.code-review-graph/
|
||||
.claude
|
||||
.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"
|
||||
info:
|
||||
title: Wrenn API
|
||||
description: MicroVM-based code execution platform API.
|
||||
version: "0.1.4"
|
||||
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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "wrenn"
|
||||
version = "0.1.3"
|
||||
version = "0.1.1"
|
||||
description = "Python SDK for Wrenn"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: openapi.yaml
|
||||
# timestamp: 2026-05-04T20:57:00+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"
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -15,17 +15,6 @@ pytestmark = pytest.mark.integration
|
||||
_env_loaded = False
|
||||
|
||||
|
||||
def _wait_for_pid_dead(capsule: Capsule, pid: int, timeout: float = 5.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
result = capsule.commands.run(f"ps -p {pid} -o stat= 2>/dev/null || true")
|
||||
state = result.stdout.strip()
|
||||
if not state or state.startswith("Z"):
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_env() -> None:
|
||||
global _env_loaded
|
||||
if _env_loaded:
|
||||
@ -229,7 +218,11 @@ class TestCommands:
|
||||
def test_kill_process(self):
|
||||
handle = self.capsule.commands.run("sleep 30", background=True)
|
||||
self.capsule.commands.kill(handle.pid)
|
||||
assert _wait_for_pid_dead(self.capsule, handle.pid)
|
||||
time.sleep(0.5)
|
||||
|
||||
processes = self.capsule.commands.list()
|
||||
pids = [p.pid for p in processes]
|
||||
assert handle.pid not in pids
|
||||
|
||||
def test_run_duration_ms(self):
|
||||
result = self.capsule.commands.run("sleep 1")
|
||||
|
||||
Reference in New Issue
Block a user