This commit is contained in:
Tasnim Kabir Sadik
2026-05-02 21:59:18 +06:00
parent be573d07a3
commit c9794efae6
16 changed files with 217 additions and 113 deletions

1
.gitignore vendored
View File

@ -176,6 +176,7 @@ cython_debug/
CODE_EXECUTION.md CODE_EXECUTION.md
.opencode/
# AI # AI
.code-review-graph/ .code-review-graph/
.claude .claude

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,6 +1,6 @@
[project] [project]
name = "wrenn" name = "wrenn"
version = "0.1.1" version = "0.1.2"
description = "Python SDK for Wrenn" description = "Python SDK for Wrenn"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"

View File

@ -1,33 +1,5 @@
from __future__ import annotations from __future__ import annotations
import os
from dataclasses import dataclass
DEFAULT_BASE_URL = "https://app.wrenn.dev/api" DEFAULT_BASE_URL = "https://app.wrenn.dev/api"
ENV_API_KEY = "WRENN_API_KEY" ENV_API_KEY = "WRENN_API_KEY"
ENV_BASE_URL = "WRENN_BASE_URL" ENV_BASE_URL = "WRENN_BASE_URL"
@dataclass(frozen=True)
class ConnectionConfig:
"""Resolved credentials and base URL for Wrenn API calls."""
api_key: str
base_url: str
@classmethod
def from_env(
cls,
api_key: str | None = None,
base_url: str | None = None,
) -> ConnectionConfig:
resolved_key = api_key or os.environ.get(ENV_API_KEY)
if not resolved_key:
raise ValueError(
f"No API key provided. Pass api_key= or set the {ENV_API_KEY} environment variable."
)
resolved_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL)
return cls(api_key=resolved_key, base_url=resolved_url)
def auth_headers(self) -> dict[str, str]:
return {"X-API-Key": self.api_key}

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
import builtins import builtins
import time import time
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
@ -242,8 +243,10 @@ class AsyncCapsule:
if info.status == Status.running: if info.status == Status.running:
self._info = info self._info = info
return return
if info.status in (Status.error, Status.stopped, Status.paused): 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:
info = await self._client.capsules.resume(self._id)
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")
@ -389,8 +392,8 @@ class AsyncCapsule:
) -> None: ) -> None:
try: try:
await self._instance_destroy() await self._instance_destroy()
except Exception: except Exception as exc:
pass logging.warning("Failed to destroy capsule %s: %s", self._id, exc)
try: try:
await self._client.aclose() await self._client.aclose()
except Exception: except Exception:

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
import builtins import builtins
import time import time
from collections.abc import Iterator from collections.abc import Iterator
@ -99,17 +100,24 @@ class Capsule:
self._id: str = _capsule_id self._id: str = _capsule_id
self._client = _client self._client = _client
self._info = _info self._info = _info
if self._id is None:
self._client.close()
raise RuntimeError("API returned a capsule without an ID")
else: else:
# Public construction: create a capsule immediately
self._client = WrennClient(api_key=api_key, base_url=base_url) self._client = WrennClient(api_key=api_key, base_url=base_url)
self._info = self._client.capsules.create( try:
template=template, self._info = self._client.capsules.create(
vcpus=vcpus, template=template,
memory_mb=memory_mb, vcpus=vcpus,
timeout_sec=timeout, memory_mb=memory_mb,
) timeout_sec=timeout,
assert self._info.id is not None )
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:
self._client.close()
raise
self.commands = Commands(self._id, self._client.http) self.commands = Commands(self._id, self._client.http)
self.files = Files(self._id, self._client.http) self.files = Files(self._id, self._client.http)
@ -317,8 +325,10 @@ class Capsule:
if info.status == Status.running: if info.status == Status.running:
self._info = info self._info = info
return return
if info.status in (Status.error, Status.stopped, Status.paused): 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:
info = self._client.capsules.resume(self._id)
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")
@ -463,8 +473,8 @@ class Capsule:
) -> None: ) -> None:
try: try:
self._instance_destroy() self._instance_destroy()
except Exception: except Exception as exc:
pass logging.warning("Failed to destroy capsule %s: %s", self._id, exc)
try: try:
self._client.close() self._client.close()
except Exception: except Exception:

View File

@ -40,6 +40,28 @@ class AsyncCapsule(BaseAsyncCapsule):
self._kernel_id = None self._kernel_id = None
self._proxy_client = None self._proxy_client = None
async def close(self) -> None:
if self._proxy_client is not None:
try:
await self._proxy_client.aclose()
except Exception:
pass
self._proxy_client = None
def __del__(self) -> None:
if self._proxy_client is not None:
try:
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
loop.create_task(self._proxy_client.aclose())
else:
loop.run_until_complete(self._proxy_client.aclose())
except Exception:
pass
self._proxy_client = None
@classmethod @classmethod
async def create( async def create(
cls, cls,
@ -126,8 +148,10 @@ class AsyncCapsule(BaseAsyncCapsule):
request=resp.request, request=resp.request,
response=resp, response=resp,
) )
except httpx.HTTPStatusError: except httpx.HTTPStatusError as exc:
raise if exc.response.status_code < 500:
raise
last_exc = exc
except Exception as exc: except Exception as exc:
last_exc = exc last_exc = exc
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
@ -164,8 +188,6 @@ class AsyncCapsule(BaseAsyncCapsule):
}, },
"buffers": [], "buffers": [],
"channel": "shell", "channel": "shell",
"msg_id": msg_id,
"msg_type": "execute_request",
} }
async def run_code( async def run_code(
@ -201,7 +223,7 @@ class AsyncCapsule(BaseAsyncCapsule):
ws_url = self._jupyter_ws_url(kernel_id) ws_url = self._jupyter_ws_url(kernel_id)
msg = self._jupyter_execute_request(code) msg = self._jupyter_execute_request(code)
msg_id = msg["msg_id"] msg_id = msg["header"]["msg_id"]
execution = Execution() execution = Execution()
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
@ -215,7 +237,7 @@ class AsyncCapsule(BaseAsyncCapsule):
break break
try: try:
data = await asyncio.wait_for(ws.receive_json(), timeout=time_left) data = await asyncio.wait_for(ws.receive_json(), timeout=time_left)
except (asyncio.TimeoutError, Exception): except Exception:
break break
if not data: if not data:
break break

View File

@ -70,6 +70,17 @@ class Capsule(BaseCapsule):
self._kernel_id = None self._kernel_id = None
self._proxy_client = None self._proxy_client = None
def close(self) -> None:
if self._proxy_client is not None:
try:
self._proxy_client.close()
except Exception:
pass
self._proxy_client = None
def __del__(self) -> None:
self.close()
@classmethod @classmethod
def create( def create(
cls, cls,
@ -150,8 +161,10 @@ class Capsule(BaseCapsule):
request=resp.request, request=resp.request,
response=resp, response=resp,
) )
except httpx.HTTPStatusError: except httpx.HTTPStatusError as exc:
raise if exc.response.status_code < 500:
raise
last_exc = exc
except Exception as exc: except Exception as exc:
last_exc = exc last_exc = exc
time.sleep(0.5) time.sleep(0.5)
@ -188,8 +201,6 @@ class Capsule(BaseCapsule):
}, },
"buffers": [], "buffers": [],
"channel": "shell", "channel": "shell",
"msg_id": msg_id,
"msg_type": "execute_request",
} }
def run_code( def run_code(
@ -227,7 +238,7 @@ class Capsule(BaseCapsule):
ws_url = self._jupyter_ws_url(kernel_id) ws_url = self._jupyter_ws_url(kernel_id)
msg = self._jupyter_execute_request(code) msg = self._jupyter_execute_request(code)
msg_id = msg["msg_id"] msg_id = msg["header"]["msg_id"]
execution = Execution() execution = Execution()
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
@ -241,7 +252,7 @@ class Capsule(BaseCapsule):
break break
try: try:
data = ws.receive_json(timeout=time_left) data = ws.receive_json(timeout=time_left)
except (TimeoutError, Exception): except Exception:
break break
if not data: if not data:
break break

View File

@ -5,7 +5,7 @@ import builtins
import json import json
from collections.abc import AsyncIterator, Iterator from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass from dataclasses import dataclass
from typing import overload, Literal from typing import Literal, overload
import httpx import httpx
import httpx_ws import httpx_ws
@ -198,7 +198,15 @@ class Commands:
if tag is not None: if tag is not None:
payload["tag"] = tag payload["tag"] = tag
resp = self._http.post(f"/v1/capsules/{self._capsule_id}/exec", json=payload) http_timeout: httpx.Timeout | None = None
if not background and timeout is not None:
http_timeout = httpx.Timeout(timeout + 10, connect=5.0)
resp = self._http.post(
f"/v1/capsules/{self._capsule_id}/exec",
json=payload,
timeout=http_timeout,
)
data = handle_response(resp) data = handle_response(resp)
assert isinstance(data, dict) assert isinstance(data, dict)
@ -379,8 +387,14 @@ class AsyncCommands:
if tag is not None: if tag is not None:
payload["tag"] = tag payload["tag"] = tag
http_timeout: httpx.Timeout | None = None
if not background and timeout is not None:
http_timeout = httpx.Timeout(timeout + 10, connect=5.0)
resp = await self._http.post( resp = await self._http.post(
f"/v1/capsules/{self._capsule_id}/exec", json=payload f"/v1/capsules/{self._capsule_id}/exec",
json=payload,
timeout=http_timeout,
) )
data = handle_response(resp) data = handle_response(resp)
assert isinstance(data, dict) assert isinstance(data, dict)

View File

@ -110,34 +110,43 @@ _ERROR_MAP: dict[str, type[WrennError]] = {
} }
def handle_response(resp: httpx.Response) -> dict | list: def _raise_for_status(resp: httpx.Response) -> None:
if resp.status_code >= 400: if resp.status_code < 400:
try: return
body = resp.json()
except Exception:
resp.raise_for_status()
raise
err = body.get("error", {}) try:
code = err.get("code", "internal_error") body = resp.json()
message = err.get("message", resp.text) except Exception:
raise WrennInternalError(
code="internal_error",
message=resp.text or f"HTTP {resp.status_code}",
status_code=resp.status_code,
)
exc_cls = _ERROR_MAP.get(code, WrennError) err = body.get("error", {})
code = err.get("code", "internal_error")
message = err.get("message", resp.text)
if exc_cls is WrennHostHasCapsulesError: exc_cls = _ERROR_MAP.get(code, WrennError)
raise WrennHostHasCapsulesError(
code=code,
message=message,
status_code=resp.status_code,
capsule_ids=body.get("sandbox_ids", []),
)
raise exc_cls( if exc_cls is WrennHostHasCapsulesError:
raise WrennHostHasCapsulesError(
code=code, code=code,
message=message, message=message,
status_code=resp.status_code, status_code=resp.status_code,
capsule_ids=body.get("capsule_ids") or body.get("sandbox_ids", []),
) )
raise exc_cls(
code=code,
message=message,
status_code=resp.status_code,
)
def handle_response(resp: httpx.Response) -> dict | list:
_raise_for_status(resp)
if resp.status_code == 204: if resp.status_code == 204:
return {} return {}

View File

@ -5,7 +5,7 @@ from collections.abc import AsyncIterator, Iterator
import httpx import httpx
from wrenn.exceptions import WrennNotFoundError, handle_response from wrenn.exceptions import WrennNotFoundError, _raise_for_status, handle_response
from wrenn.models import FileEntry, ListDirResponse, MakeDirResponse from wrenn.models import FileEntry, ListDirResponse, MakeDirResponse
@ -46,7 +46,7 @@ class Files:
f"/v1/capsules/{self._capsule_id}/files/read", f"/v1/capsules/{self._capsule_id}/files/read",
json={"path": path}, json={"path": path},
) )
resp.raise_for_status() _raise_for_status(resp)
return resp.content return resp.content
def write(self, path: str, data: str | bytes) -> None: def write(self, path: str, data: str | bytes) -> None:
@ -65,7 +65,7 @@ class Files:
files={"file": ("upload", data)}, files={"file": ("upload", data)},
data={"path": path}, data={"path": path},
) )
resp.raise_for_status() _raise_for_status(resp)
def list(self, path: str, depth: int = 1) -> list[FileEntry]: def list(self, path: str, depth: int = 1) -> list[FileEntry]:
"""List directory contents. """List directory contents.
@ -179,7 +179,7 @@ class Files:
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}" "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}"
}, },
) )
resp.raise_for_status() _raise_for_status(resp)
def download_stream(self, path: str) -> Iterator[bytes]: def download_stream(self, path: str) -> Iterator[bytes]:
"""Stream a large file out of the capsule. """Stream a large file out of the capsule.
@ -243,7 +243,7 @@ class AsyncFiles:
f"/v1/capsules/{self._capsule_id}/files/read", f"/v1/capsules/{self._capsule_id}/files/read",
json={"path": path}, json={"path": path},
) )
resp.raise_for_status() _raise_for_status(resp)
return resp.content return resp.content
async def write(self, path: str, data: str | bytes) -> None: async def write(self, path: str, data: str | bytes) -> None:
@ -262,7 +262,7 @@ class AsyncFiles:
files={"file": ("upload", data)}, files={"file": ("upload", data)},
data={"path": path}, data={"path": path},
) )
resp.raise_for_status() _raise_for_status(resp)
async def list(self, path: str, depth: int = 1) -> list[FileEntry]: async def list(self, path: str, depth: int = 1) -> list[FileEntry]:
"""List directory contents. """List directory contents.
@ -377,7 +377,7 @@ class AsyncFiles:
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}" "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}"
}, },
) )
resp.raise_for_status() _raise_for_status(resp)
async def download_stream(self, path: str) -> AsyncIterator[bytes]: async def download_stream(self, path: str) -> AsyncIterator[bytes]:
"""Stream a large file out of the capsule. """Stream a large file out of the capsule.

View File

@ -153,7 +153,8 @@ class PtySession:
if event.pid is not None: if event.pid is not None:
self._pid = event.pid self._pid = event.pid
if event.type == PtyEventType.exit: if event.type == PtyEventType.exit:
raise StopIteration self._done = True
return event
if event.type == PtyEventType.error and event.fatal: if event.type == PtyEventType.error and event.fatal:
self._done = True self._done = True
return event return event
@ -281,7 +282,8 @@ class AsyncPtySession:
if event.pid is not None: if event.pid is not None:
self._pid = event.pid self._pid = event.pid
if event.type == PtyEventType.exit: if event.type == PtyEventType.exit:
raise StopAsyncIteration self._done = True
return event
if event.type == PtyEventType.error and event.fatal: if event.type == PtyEventType.error and event.fatal:
self._done = True self._done = True
return event return event

View File

@ -32,7 +32,7 @@ class TestCapsuleCreate:
respx.post(f"{BASE}/v1/capsules").respond( respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-1", "status": "pending", "template": "minimal"} 201, json={"id": "cl-1", "status": "pending", "template": "minimal"}
) )
cap = Capsule(template="minimal", api_key="wrn_test1234567890abcdef12345678") 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")
@ -42,7 +42,7 @@ class TestCapsuleCreate:
respx.post(f"{BASE}/v1/capsules").respond( respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-2", "status": "pending"} 201, json={"id": "cl-2", "status": "pending"}
) )
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678") cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert cap.capsule_id == "cl-2" assert cap.capsule_id == "cl-2"
@respx.mock @respx.mock
@ -51,7 +51,7 @@ class TestCapsuleCreate:
201, json={"id": "cl-1", "status": "pending"} 201, json={"id": "cl-1", "status": "pending"}
) )
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204) kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
with Capsule(api_key="wrn_test1234567890abcdef12345678") 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
@ -61,7 +61,7 @@ class TestCapsuleCreate:
respx.post(f"{BASE}/v1/capsules").respond( respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-3", "status": "pending"} 201, json={"id": "cl-3", "status": "pending"}
) )
cap = Capsule() cap = Capsule(base_url=BASE)
assert cap.capsule_id == "cl-3" assert cap.capsule_id == "cl-3"
@ -69,7 +69,7 @@ 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(204)
Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678") Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert route.called assert route.called
@respx.mock @respx.mock
@ -77,7 +77,7 @@ class TestCapsuleStaticMethods:
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"} 200, json={"id": "cl-1", "status": "paused"}
) )
info = Capsule._static_pause("cl-1", api_key="wrn_test1234567890abcdef12345678") info = Capsule._static_pause("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert info.status.value == "paused" assert info.status.value == "paused"
@respx.mock @respx.mock
@ -85,7 +85,7 @@ class TestCapsuleStaticMethods:
respx.get(f"{BASE}/v1/capsules").respond( respx.get(f"{BASE}/v1/capsules").respond(
200, json=[{"id": "cl-1", "status": "running"}] 200, json=[{"id": "cl-1", "status": "running"}]
) )
items = Capsule.list(api_key="wrn_test1234567890abcdef12345678") items = Capsule.list(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert len(items) == 1 assert len(items) == 1
assert items[0].id == "cl-1" assert items[0].id == "cl-1"
@ -95,7 +95,7 @@ class TestCapsuleStaticMethods:
200, json={"id": "cl-1", "status": "running"} 200, json={"id": "cl-1", "status": "running"}
) )
info = Capsule._static_get_info( info = Capsule._static_get_info(
"cl-1", api_key="wrn_test1234567890abcdef12345678" "cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
) )
assert info.id == "cl-1" assert info.id == "cl-1"
@ -106,7 +106,7 @@ 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") 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
@ -117,7 +117,7 @@ class TestCapsuleConnect:
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"} 200, json={"id": "cl-1", "status": "running"}
) )
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678") 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

@ -23,13 +23,13 @@ BASE = "https://app.wrenn.dev/api"
@pytest.fixture @pytest.fixture
def client(): def client():
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: with WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as c:
yield c yield c
@pytest.fixture @pytest.fixture
def async_client(): def async_client():
return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678") return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
class TestCapsules: class TestCapsules:
@ -221,7 +221,8 @@ class TestAuthModes:
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
assert c._http.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678" assert c._http.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
def test_no_auth_raises(self): def test_no_auth_raises(self, monkeypatch):
monkeypatch.delenv("WRENN_API_KEY", raising=False)
with pytest.raises(ValueError, match="No API key"): with pytest.raises(ValueError, match="No API key"):
WrennClient() WrennClient()

View File

@ -23,7 +23,7 @@ def _make_capsule(cap_id: str = "cl-abc") -> Capsule:
respx.post(f"{BASE}/v1/capsules").respond( respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": cap_id, "status": "running"} 201, json={"id": cap_id, "status": "running"}
) )
return Capsule(api_key="wrn_test1234567890abcdef12345678") return Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
class TestFilesRead: class TestFilesRead:
@ -311,12 +311,14 @@ class TestPtySessionIteration:
ws.receive_text.side_effect = messages ws.receive_text.side_effect = messages
session = PtySession(ws, "cl-abc") session = PtySession(ws, "cl-abc")
events = list(session) events = list(session)
assert len(events) == 2 assert len(events) == 3
assert events[0].type == PtyEventType.started assert events[0].type == PtyEventType.started
assert session.tag == "pty-abc12345" assert session.tag == "pty-abc12345"
assert session.pid == 1 assert session.pid == 1
assert events[1].type == PtyEventType.output assert events[1].type == PtyEventType.output
assert events[1].data == b"hello" assert events[1].data == b"hello"
assert events[2].type == PtyEventType.exit
assert events[2].exit_code == 0
def test_iter_stops_on_fatal_error(self): def test_iter_stops_on_fatal_error(self):
ws = MagicMock() ws = MagicMock()
@ -461,10 +463,11 @@ class TestAsyncPtySession:
events = [] events = []
async for event in session: async for event in session:
events.append(event) events.append(event)
assert len(events) == 2 assert len(events) == 3
assert events[0].type == PtyEventType.started assert events[0].type == PtyEventType.started
assert session.tag == "pty-xyz" assert session.tag == "pty-xyz"
assert session.pid == 5 assert session.pid == 5
assert events[2].type == PtyEventType.exit
class TestExports: class TestExports:

View File

@ -73,7 +73,7 @@ def _make_git(respx_mock=None) -> Git:
"""Create a Git instance bound to a test capsule.""" """Create a Git instance bound to a test capsule."""
from wrenn.client import WrennClient from wrenn.client import WrennClient
client = WrennClient(api_key="wrn_test1234567890abcdef12345678") client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
return Git(CAPSULE_ID, client.http) return Git(CAPSULE_ID, client.http)
@ -81,7 +81,7 @@ def _make_async_git() -> AsyncGit:
"""Create an AsyncGit instance bound to a test capsule.""" """Create an AsyncGit instance bound to a test capsule."""
from wrenn.client import AsyncWrennClient from wrenn.client import AsyncWrennClient
client = AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678") client = AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
return AsyncGit(CAPSULE_ID, client.http) return AsyncGit(CAPSULE_ID, client.http)
@ -926,7 +926,7 @@ class TestCapsuleWiring:
respx.post(f"{BASE}/v1/capsules").respond( respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-1", "status": "pending"} 201, json={"id": "cl-1", "status": "pending"}
) )
cap = Capsule(api_key="wrn_test1234567890abcdef12345678") cap = Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert hasattr(cap, "git") assert hasattr(cap, "git")
assert isinstance(cap.git, Git) assert isinstance(cap.git, Git)
@ -1017,7 +1017,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.commands import Commands from wrenn.commands import Commands
client = WrennClient(api_key="wrn_test1234567890abcdef12345678") client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
commands = Commands(CAPSULE_ID, client.http) commands = Commands(CAPSULE_ID, client.http)
route = respx.post(EXEC_URL).respond(200, json=_exec_response(stdout="3\n")) route = respx.post(EXEC_URL).respond(200, json=_exec_response(stdout="3\n"))
@ -1031,7 +1031,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.commands import Commands from wrenn.commands import Commands
client = WrennClient(api_key="wrn_test1234567890abcdef12345678") client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
commands = Commands(CAPSULE_ID, client.http) commands = Commands(CAPSULE_ID, client.http)
route = respx.post(EXEC_URL).respond(200, json=_exec_response()) route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1045,7 +1045,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.commands import Commands from wrenn.commands import Commands
client = WrennClient(api_key="wrn_test1234567890abcdef12345678") client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
commands = Commands(CAPSULE_ID, client.http) commands = Commands(CAPSULE_ID, client.http)
route = respx.post(EXEC_URL).respond(200, json=_exec_response()) route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1059,7 +1059,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.commands import Commands from wrenn.commands import Commands
client = WrennClient(api_key="wrn_test1234567890abcdef12345678") client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
commands = Commands(CAPSULE_ID, client.http) commands = Commands(CAPSULE_ID, client.http)
route = respx.post(EXEC_URL).respond(200, json=_exec_response()) route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1073,7 +1073,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.commands import Commands from wrenn.commands import Commands
client = WrennClient(api_key="wrn_test1234567890abcdef12345678") client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
commands = Commands(CAPSULE_ID, client.http) commands = Commands(CAPSULE_ID, client.http)
route = respx.post(EXEC_URL).respond(200, json=_exec_response()) route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1089,7 +1089,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.commands import Commands from wrenn.commands import Commands
client = WrennClient(api_key="wrn_test1234567890abcdef12345678") client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
commands = Commands(CAPSULE_ID, client.http) commands = Commands(CAPSULE_ID, client.http)
route = respx.post(EXEC_URL).respond(200, json=_exec_response()) route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1119,7 +1119,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.commands import Commands from wrenn.commands import Commands
client = WrennClient(api_key="wrn_test1234567890abcdef12345678") client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
commands = Commands(CAPSULE_ID, client.http) commands = Commands(CAPSULE_ID, client.http)
route = respx.post(EXEC_URL).respond(200, json={"pid": 42, "tag": "bg-1"}) route = respx.post(EXEC_URL).respond(200, json={"pid": 42, "tag": "bg-1"})