2 Commits

Author SHA1 Message Date
800a8566db v0.1.3 2026-05-19 13:23:49 +06:00
a42f0b2e71 v0.1.2 2026-05-02 22:02:36 +06:00
14 changed files with 140 additions and 219 deletions

1
.gitignore vendored
View File

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

56
AGENTS.md Normal file
View File

@ -0,0 +1,56 @@
# 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" openapi: "3.1.0"
info: info:
title: Wrenn API title: Wrenn API
description: AI agent execution platform API. description: MicroVM-based code execution platform API.
version: "0.2.0" version: "0.1.4"
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:
"202": "201":
description: Capsule creation initiated (status will be "starting") description: Capsule created
content: content:
application/json: application/json:
schema: schema:
@ -988,8 +988,8 @@ paths:
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
responses: responses:
"202": "204":
description: Capsule destruction initiated description: Capsule destroyed
/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:
"202": "200":
description: Capsule pause initiated (status will be "pausing") description: Capsule paused (snapshot taken, resources released)
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:
"202": "200":
description: Capsule resume initiated (status will be "resuming") description: Capsule resumed (new VM booted from snapshot)
content: content:
application/json: application/json:
schema: schema:
@ -2035,51 +2035,6 @@ 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
@ -2391,54 +2346,6 @@ 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:
@ -2637,7 +2544,7 @@ components:
type: string type: string
status: status:
type: string type: string
enum: [pending, starting, running, pausing, paused, resuming, stopping, hibernated, stopped, missing, error] enum: [pending, starting, running, paused, hibernated, stopped, missing, error]
template: template:
type: string type: string
vcpus: vcpus:

View File

@ -1964,17 +1964,15 @@ inactivity TTL is set.
#### wait\_ready #### wait\_ready
```python ```python
async def wait_ready(timeout: float = 30) -> None async def wait_ready(timeout: float = 30, interval: float = 0.5) -> 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**:
@ -2536,17 +2534,15 @@ inactivity TTL is set.
#### wait\_ready #### wait\_ready
```python ```python
def wait_ready(timeout: float = 30) -> None def wait_ready(timeout: float = 30, interval: float = 0.5) -> 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**:
@ -2704,6 +2700,17 @@ 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

View File

@ -1,6 +1,6 @@
[project] [project]
name = "wrenn" name = "wrenn"
version = "0.1.1" version = "0.1.3"
description = "Python SDK for Wrenn" description = "Python SDK for Wrenn"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"

View File

@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import builtins
import logging import logging
import builtins
import time import time
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -140,19 +140,14 @@ 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:
await client.capsules.resume(capsule_id) info = await client.capsules.resume(capsule_id)
capsule = cls( return 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")
@ -229,21 +224,12 @@ class AsyncCapsule:
""" """
await self._client.capsules.ping(self._id) await self._client.capsules.ping(self._id)
_POLL_INTERVALS: dict[Status, float] = { async def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None:
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
@ -260,10 +246,7 @@ 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:
await self._client.capsules.resume(self._id) info = 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")

View File

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import builtins
import logging import logging
import builtins
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,
) )
if self._info.id is None:
raise RuntimeError("API returned a capsule without an ID")
self._id = self._info.id self._id = self._info.id
if self._id is None:
raise RuntimeError("API returned a capsule without an ID")
except Exception: except Exception:
self._client.close() self._client.close()
raise raise
@ -214,19 +214,14 @@ class Capsule:
info = client.capsules.get(capsule_id) info = client.capsules.get(capsule_id)
if info.status == Status.paused: if info.status == Status.paused:
client.capsules.resume(capsule_id) info = client.capsules.resume(capsule_id)
capsule = cls( return 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")
@ -311,21 +306,12 @@ class Capsule:
""" """
self._client.capsules.ping(self._id) self._client.capsules.ping(self._id)
_POLL_INTERVALS: dict[Status, float] = { def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None:
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
@ -342,10 +328,7 @@ 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:
self._client.capsules.resume(self._id) info = 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")

View File

@ -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") resp = self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT)
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") resp = await self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT)
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:

View File

@ -150,9 +150,6 @@ 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()

View File

@ -1,6 +1,6 @@
# generated by datamodel-codegen: # generated by datamodel-codegen:
# filename: openapi.yaml # filename: openapi.yaml
# timestamp: 2026-05-15T07:57:28+00:00 # timestamp: 2026-05-04T20:57:00+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,10 +133,7 @@ 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"

View File

@ -1,6 +1,5 @@
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
@ -31,13 +30,9 @@ 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(
202, json={"id": "cl-1", "status": "starting", "template": "minimal"} 201, json={"id": "cl-1", "status": "pending", "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")
@ -45,7 +40,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(
202, json={"id": "cl-2", "status": "starting"} 201, json={"id": "cl-2", "status": "pending"}
) )
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"
@ -53,9 +48,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(
202, json={"id": "cl-1", "status": "starting"} 201, json={"id": "cl-1", "status": "pending"}
) )
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202) kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
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
@ -64,7 +59,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(
202, json={"id": "cl-3", "status": "starting"} 201, json={"id": "cl-3", "status": "pending"}
) )
cap = Capsule(base_url=BASE) cap = Capsule(base_url=BASE)
assert cap.capsule_id == "cl-3" assert cap.capsule_id == "cl-3"
@ -73,21 +68,17 @@ 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(202) route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
Capsule._static_destroy( Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
"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(
202, json={"id": "cl-1", "status": "pausing"} 200, json={"id": "cl-1", "status": "paused"}
) )
info = Capsule._static_pause( info = Capsule._static_pause("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE assert info.status.value == "paused"
)
assert info.status.value == "pausing"
@respx.mock @respx.mock
def test_static_list(self): def test_static_list(self):
@ -115,24 +106,18 @@ 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( cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
"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):
get_route = respx.get(f"{BASE}/v1/capsules/cl-1") respx.get(f"{BASE}/v1/capsules/cl-1").respond(
get_route.side_effect = [ 200, json={"id": "cl-1", "status": "paused"}
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(
202, json={"id": "cl-1", "status": "resuming"} 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"

View File

@ -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(
202, 201,
json={ json={
"id": "sb-1", "id": "sb-1",
"status": "starting", "status": "pending",
"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.starting assert resp.status == Status.pending
@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(
202, json={"id": "sb-2", "status": "starting"} 201, json={"id": "sb-2", "status": "pending"}
) )
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(202) route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(204)
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(
202, json={"id": "sb-1", "status": "pausing"} 200, json={"id": "sb-1", "status": "paused"}
) )
resp = client.capsules.pause("sb-1") resp = client.capsules.pause("sb-1")
assert resp.status == Status.pausing assert resp.status == Status.paused
@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(
202, json={"id": "sb-1", "status": "resuming"} 200, json={"id": "sb-1", "status": "running"}
) )
resp = client.capsules.resume("sb-1") resp = client.capsules.resume("sb-1")
assert resp.status == Status.resuming assert resp.status == Status.running
@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(
202, json={"id": "sb-1", "status": "starting"} 201, json={"id": "sb-1", "status": "pending"}
) )
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"

View File

@ -15,6 +15,17 @@ pytestmark = pytest.mark.integration
_env_loaded = False _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: def _ensure_env() -> None:
global _env_loaded global _env_loaded
if _env_loaded: if _env_loaded:
@ -218,11 +229,7 @@ class TestCommands:
def test_kill_process(self): def test_kill_process(self):
handle = self.capsule.commands.run("sleep 30", background=True) handle = self.capsule.commands.run("sleep 30", background=True)
self.capsule.commands.kill(handle.pid) self.capsule.commands.kill(handle.pid)
time.sleep(0.5) assert _wait_for_pid_dead(self.capsule, handle.pid)
processes = self.capsule.commands.list()
pids = [p.pid for p in processes]
assert handle.pid not in pids
def test_run_duration_ms(self): def test_run_duration_ms(self):
result = self.capsule.commands.run("sleep 1") result = self.capsule.commands.run("sleep 1")

2
uv.lock generated
View File

@ -1,5 +1,5 @@
version = 1 version = 1
revision = 3 revision = 2
requires-python = ">=3.13" requires-python = ">=3.13"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.14'", "python_full_version >= '3.14'",