10 Commits

Author SHA1 Message Date
6112c71abc test: make process kill integration test resilient
Some checks failed
ci/woodpecker/pr/check Pipeline failed
2026-05-16 17:02:25 +06:00
d9c028564e Merge branch 'bugfix/timeout-related-issues' into dev
Some checks failed
ci/woodpecker/pr/check Pipeline failed
2026-05-02 21:53:33 +06:00
06b4a8cbcb Merge issues fixed
All checks were successful
ci/woodpecker/pr/check Pipeline was successful
2026-05-02 21:46:16 +06:00
04e5dc652f Fix error handling, resource leaks, and logic bugs across the SDK
Bugs fixed:
- files.py: use typed error checking (_raise_for_status) instead of raw
  raise_for_status(), ensuring WrennNotFoundError etc. are raised
  correctly
- exceptions.py: check both "capsule_ids" and "sandbox_ids" response
  keys
  for backwards compatibility
- code_interpreter: retry _ensure_kernel on 5xx errors (only fail on
  4xx),
  remove redundant TimeoutError in bare except, clean up non-standard
  top-level msg_id/msg_type from Jupyter messages

Resource leaks fixed:
- capsule.py: close WrennClient if capsule creation or init fails
- code_interpreter: add close()/__del__ for _proxy_client cleanup when
  not using context manager

Logic fixes:
- pty.py: yield exit events to callers instead of silently discarding
  them
- capsule.py: auto-resume paused capsules in wait_ready instead of
  failing
- capsule.py: log warnings on destroy failure in __exit__ instead of
  silently swallowing errors
2026-05-02 21:34:02 +06:00
4a7db8e204 fix: set httpx read timeout for long-running commands and handle
non-JSON error responses
- Set per-request httpx timeout (command timeout + 10s buffer) in
  Commands.run() and AsyncCommands.run() for foreground exec calls,
  preventing HTTP read timeouts on long-running commands
- Raise WrennInternalError instead of raw httpx.HTTPStatusError when
  handle_response() encounters a non-JSON error body (e.g. 502 from
  a reverse proxy)
2026-05-02 19:02:39 +06:00
a76be96682 Merge branch 'main' of git.omukk.dev:wrenn/python-sdk into dev 2026-05-02 05:07:13 +06:00
be573d07a3 v0.1.1 (#7)
Co-authored-by: Tasnim Kabir Sadik <tksadik92@gmail.com>
Reviewed-on: #7
Co-authored-by: pptx704 <rafeed@omukk.dev>
Co-committed-by: pptx704 <rafeed@omukk.dev>
2026-05-01 23:06:54 +00:00
dc66ac24d5 Updated woodpecker def
All checks were successful
ci/woodpecker/pr/check Pipeline was successful
2026-05-02 04:50:11 +06:00
b5e2b12ef1 Version bump and other minor changes 2026-05-02 04:45:05 +06:00
213af4aee7 Increased timeout for long running API calls and updated typehints 2026-05-02 04:44:26 +06:00
24 changed files with 432 additions and 143 deletions

6
.gitignore vendored
View File

@ -175,3 +175,9 @@ cython_debug/
.pypirc
CODE_EXECUTION.md
.opencode/
# AI
.code-review-graph/
.claude
.mcp.json

25
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,25 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.10
hooks:
- id: ruff
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.20.0
hooks:
- id: mypy
additional_dependencies:
- pydantic>=2.12.5
- httpx>=0.28.1
- httpx-ws>=0.9.0
- email-validator>=2.3.0
- repo: local
hooks:
- id: unit-tests
name: unit tests
entry: uv run pytest -m "not integration" -x -q
language: system
pass_filenames: false
always_run: true

View File

@ -1,5 +1,5 @@
when:
event: push
event: pull_request
branch:
- main
- dev

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

@ -130,3 +130,42 @@ All values are CSS custom properties in `frontend/src/app.css`.
4. **Legible at speed.** Users scan dashboards in seconds. Strong typographic contrast (serif h1, mono IDs, sans body), consistent patterns, and predictable placement let users orientate instantly without reading everything.
5. **Craft signals trust.** For infrastructure that runs production code, the quality of the UI is a proxy for the quality of the product. Pixel-level decisions matter. Polish is not decoration — it's a trust signal.
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview` + `list_communities`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
|------|----------|
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.

View File

@ -2,7 +2,7 @@ openapi: "3.1.0"
info:
title: Wrenn API
description: MicroVM-based code execution platform API.
version: "0.1.3"
version: "0.1.4"
servers:
- url: http://localhost:8080

View File

@ -1,6 +1,6 @@
[project]
name = "wrenn"
version = "0.1.0"
version = "0.1.1"
description = "Python SDK for Wrenn"
readme = "README.md"
license = "MIT"
@ -36,6 +36,7 @@ build-backend = "hatchling.build"
dev = [
"datamodel-code-generator[ruff]>=0.56.0",
"mypy>=1.20.0",
"pre-commit>=4.6.0",
"pydoc-markdown>=4.8.2",
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",

View File

@ -1,33 +1,5 @@
from __future__ import annotations
import os
from dataclasses import dataclass
DEFAULT_BASE_URL = "https://app.wrenn.dev/api"
ENV_API_KEY = "WRENN_API_KEY"
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,8 @@
from __future__ import annotations
import asyncio
import logging
import builtins
import time
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
@ -102,6 +104,7 @@ class AsyncCapsule:
memory_mb=memory_mb,
timeout_sec=timeout,
)
assert info.id is not None
capsule = cls(
_capsule_id=info.id,
_client=client,
@ -240,8 +243,10 @@ class AsyncCapsule:
if info.status == Status.running:
self._info = info
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")
if info.status == Status.paused:
info = await self._client.capsules.resume(self._id)
await asyncio.sleep(interval)
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
@ -284,7 +289,7 @@ class AsyncCapsule:
async def pty(
self,
cmd: str = "/bin/bash",
args: list[str] | None = None,
args: builtins.list[str] | None = None,
cols: int = 80,
rows: int = 24,
envs: dict[str, str] | None = None,
@ -316,7 +321,7 @@ class AsyncCapsule:
"""
async with httpx_ws.aconnect_ws(
f"/v1/capsules/{self._id}/pty", client=self._client.http
) as ws:
) as ws: # type: httpx_ws.AsyncWebSocketSession
session = AsyncPtySession(ws, self._id)
await session._send_start(
cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd
@ -335,7 +340,7 @@ class AsyncCapsule:
"""
async with httpx_ws.aconnect_ws(
f"/v1/capsules/{self._id}/pty", client=self._client.http
) as ws:
) as ws: # type: httpx_ws.AsyncWebSocketSession
session = AsyncPtySession(ws, self._id)
await session._send_connect(tag)
yield session
@ -387,8 +392,8 @@ class AsyncCapsule:
) -> None:
try:
await self._instance_destroy()
except Exception:
pass
except Exception as exc:
logging.warning("Failed to destroy capsule %s: %s", self._id, exc)
try:
await self._client.aclose()
except Exception:

View File

@ -1,5 +1,7 @@
from __future__ import annotations
import logging
import builtins
import time
from collections.abc import Iterator
from contextlib import contextmanager
@ -94,21 +96,28 @@ class Capsule:
``WRENN_BASE_URL`` or the default production endpoint.
"""
if _capsule_id is not None:
# Internal construction path (from create/connect classmethods)
assert _client is not None
self._id = _capsule_id
self._id: str = _capsule_id
self._client = _client
self._info = _info
if self._id is None:
self._client.close()
raise RuntimeError("API returned a capsule without an ID")
else:
# Public construction: create a capsule immediately
self._client = WrennClient(api_key=api_key, base_url=base_url)
self._info = self._client.capsules.create(
template=template,
vcpus=vcpus,
memory_mb=memory_mb,
timeout_sec=timeout,
)
self._id = self._info.id
try:
self._info = self._client.capsules.create(
template=template,
vcpus=vcpus,
memory_mb=memory_mb,
timeout_sec=timeout,
)
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.files = Files(self._id, self._client.http)
@ -316,8 +325,10 @@ class Capsule:
if info.status == Status.running:
self._info = info
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")
if info.status == Status.paused:
info = self._client.capsules.resume(self._id)
time.sleep(interval)
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
@ -360,7 +371,7 @@ class Capsule:
def pty(
self,
cmd: str = "/bin/bash",
args: list[str] | None = None,
args: builtins.list[str] | None = None,
cols: int = 80,
rows: int = 24,
envs: dict[str, str] | None = None,
@ -391,7 +402,7 @@ class Capsule:
"""
with httpx_ws.connect_ws(
f"/v1/capsules/{self._id}/pty", client=self._client.http
) as ws:
) as ws: # type: httpx_ws.WebSocketSession
session = PtySession(ws, self._id)
session._send_start(
cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd
@ -410,7 +421,7 @@ class Capsule:
"""
with httpx_ws.connect_ws(
f"/v1/capsules/{self._id}/pty", client=self._client.http
) as ws:
) as ws: # type: httpx_ws.WebSocketSession
session = PtySession(ws, self._id)
session._send_connect(tag)
yield session
@ -462,8 +473,8 @@ class Capsule:
) -> None:
try:
self._instance_destroy()
except Exception:
pass
except Exception as exc:
logging.warning("Failed to destroy capsule %s: %s", self._id, exc)
try:
self._client.close()
except Exception:

View File

@ -6,6 +6,7 @@ import httpx
from wrenn._config import DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL
from wrenn.exceptions import handle_response
from wrenn.models import (
Template,
)
@ -13,6 +14,8 @@ from wrenn.models import (
Capsule as CapsuleModel,
)
_LONG_TIMEOUT = httpx.Timeout(60.0)
def _resolve_api_key(api_key: str | None) -> str:
resolved = api_key or os.environ.get(ENV_API_KEY)
@ -108,7 +111,7 @@ class CapsulesResource:
Raises:
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))
def resume(self, id: str) -> CapsuleModel:
@ -224,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")
resp = await self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT)
return CapsuleModel.model_validate(handle_response(resp))
async def resume(self, id: str) -> CapsuleModel:
@ -285,7 +288,9 @@ class SnapshotsResource:
params: dict = {}
if overwrite:
params["overwrite"] = "true"
resp = self._http.post("/v1/snapshots", json=payload, params=params)
resp = self._http.post(
"/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT
)
return Template.model_validate(handle_response(resp))
def list(self, type: str | None = None) -> list[Template]:
@ -347,7 +352,9 @@ class AsyncSnapshotsResource:
params: dict = {}
if overwrite:
params["overwrite"] = "true"
resp = await self._http.post("/v1/snapshots", json=payload, params=params)
resp = await self._http.post(
"/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT
)
return Template.model_validate(handle_response(resp))
async def list(self, type: str | None = None) -> list[Template]:

View File

@ -40,6 +40,28 @@ class AsyncCapsule(BaseAsyncCapsule):
self._kernel_id = 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
async def create(
cls,
@ -126,8 +148,10 @@ class AsyncCapsule(BaseAsyncCapsule):
request=resp.request,
response=resp,
)
except httpx.HTTPStatusError:
raise
except httpx.HTTPStatusError as exc:
if exc.response.status_code < 500:
raise
last_exc = exc
except Exception as exc:
last_exc = exc
await asyncio.sleep(0.5)
@ -164,8 +188,6 @@ class AsyncCapsule(BaseAsyncCapsule):
},
"buffers": [],
"channel": "shell",
"msg_id": msg_id,
"msg_type": "execute_request",
}
async def run_code(
@ -201,13 +223,13 @@ class AsyncCapsule(BaseAsyncCapsule):
ws_url = self._jupyter_ws_url(kernel_id)
msg = self._jupyter_execute_request(code)
msg_id = msg["msg_id"]
msg_id = msg["header"]["msg_id"]
execution = Execution()
deadline = time.monotonic() + timeout
headers = {"X-API-Key": self._client._api_key}
async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws:
async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.AsyncWebSocketSession
await ws.send_text(json.dumps(msg))
while time.monotonic() < deadline:
time_left = deadline - time.monotonic()
@ -215,7 +237,7 @@ class AsyncCapsule(BaseAsyncCapsule):
break
try:
data = await asyncio.wait_for(ws.receive_json(), timeout=time_left)
except (asyncio.TimeoutError, Exception):
except Exception:
break
if not data:
break

View File

@ -70,6 +70,17 @@ class Capsule(BaseCapsule):
self._kernel_id = 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
def create(
cls,
@ -150,8 +161,10 @@ class Capsule(BaseCapsule):
request=resp.request,
response=resp,
)
except httpx.HTTPStatusError:
raise
except httpx.HTTPStatusError as exc:
if exc.response.status_code < 500:
raise
last_exc = exc
except Exception as exc:
last_exc = exc
time.sleep(0.5)
@ -188,8 +201,6 @@ class Capsule(BaseCapsule):
},
"buffers": [],
"channel": "shell",
"msg_id": msg_id,
"msg_type": "execute_request",
}
def run_code(
@ -227,13 +238,13 @@ class Capsule(BaseCapsule):
ws_url = self._jupyter_ws_url(kernel_id)
msg = self._jupyter_execute_request(code)
msg_id = msg["msg_id"]
msg_id = msg["header"]["msg_id"]
execution = Execution()
deadline = time.monotonic() + timeout
headers = {"X-API-Key": self._client._api_key}
with httpx_ws.connect_ws(ws_url, headers=headers) as ws:
with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.WebSocketSession
ws.send_text(json.dumps(msg))
while time.monotonic() < deadline:
time_left = deadline - time.monotonic()
@ -241,7 +252,7 @@ class Capsule(BaseCapsule):
break
try:
data = ws.receive_json(timeout=time_left)
except (TimeoutError, Exception):
except Exception:
break
if not data:
break

View File

@ -1,10 +1,11 @@
from __future__ import annotations
import base64
import builtins
import json
from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass
from typing import overload, Literal
from typing import Literal, overload
import httpx
import httpx_ws
@ -197,8 +198,17 @@ class Commands:
if tag is not None:
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)
assert isinstance(data, dict)
if background:
return CommandHandle(
@ -217,6 +227,7 @@ class Commands:
"""
resp = self._http.get(f"/v1/capsules/{self._capsule_id}/processes")
data = handle_response(resp)
assert isinstance(data, dict)
return [
ProcessInfo(
pid=p.get("pid", 0),
@ -252,7 +263,7 @@ class Commands:
with httpx_ws.connect_ws(
f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream",
self._http,
) as ws:
) as ws: # type: httpx_ws.WebSocketSession
while True:
try:
raw = ws.receive_json()
@ -263,7 +274,9 @@ class Commands:
except httpx_ws.WebSocketDisconnect:
break
def stream(self, cmd: str, args: list[str] | None = None) -> Iterator[StreamEvent]:
def stream(
self, cmd: str, args: builtins.list[str] | None = None
) -> Iterator[StreamEvent]:
"""Execute a command via WebSocket, streaming output as events.
Args:
@ -280,7 +293,7 @@ class Commands:
with httpx_ws.connect_ws(
f"/v1/capsules/{self._capsule_id}/exec/stream",
self._http,
) as ws:
) as ws: # type: httpx_ws.WebSocketSession
if args:
start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
else:
@ -374,10 +387,17 @@ class AsyncCommands:
if tag is not None:
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(
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)
assert isinstance(data, dict)
if background:
return CommandHandle(
@ -396,6 +416,7 @@ class AsyncCommands:
"""
resp = await self._http.get(f"/v1/capsules/{self._capsule_id}/processes")
data = handle_response(resp)
assert isinstance(data, dict)
return [
ProcessInfo(
pid=p.get("pid", 0),
@ -433,7 +454,7 @@ class AsyncCommands:
async with httpx_ws.aconnect_ws(
f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream",
self._http,
) as ws:
) as ws: # type: httpx_ws.AsyncWebSocketSession
try:
while True:
raw = await ws.receive_json()
@ -445,7 +466,7 @@ class AsyncCommands:
pass
async def stream(
self, cmd: str, args: list[str] | None = None
self, cmd: str, args: builtins.list[str] | None = None
) -> AsyncIterator[StreamEvent]:
"""Execute a command via WebSocket, streaming output as events.
@ -463,7 +484,7 @@ class AsyncCommands:
async with httpx_ws.aconnect_ws(
f"/v1/capsules/{self._capsule_id}/exec/stream",
self._http,
) as ws:
) as ws: # type: httpx_ws.AsyncWebSocketSession
if args:
start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
else:

View File

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

View File

@ -5,7 +5,7 @@ from collections.abc import AsyncIterator, Iterator
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
@ -46,7 +46,7 @@ class Files:
f"/v1/capsules/{self._capsule_id}/files/read",
json={"path": path},
)
resp.raise_for_status()
_raise_for_status(resp)
return resp.content
def write(self, path: str, data: str | bytes) -> None:
@ -65,7 +65,7 @@ class Files:
files={"file": ("upload", data)},
data={"path": path},
)
resp.raise_for_status()
_raise_for_status(resp)
def list(self, path: str, depth: int = 1) -> list[FileEntry]:
"""List directory contents.
@ -179,7 +179,7 @@ class Files:
"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]:
"""Stream a large file out of the capsule.
@ -243,7 +243,7 @@ class AsyncFiles:
f"/v1/capsules/{self._capsule_id}/files/read",
json={"path": path},
)
resp.raise_for_status()
_raise_for_status(resp)
return resp.content
async def write(self, path: str, data: str | bytes) -> None:
@ -262,7 +262,7 @@ class AsyncFiles:
files={"file": ("upload", data)},
data={"path": path},
)
resp.raise_for_status()
_raise_for_status(resp)
async def list(self, path: str, depth: int = 1) -> list[FileEntry]:
"""List directory contents.
@ -377,7 +377,7 @@ class AsyncFiles:
"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]:
"""Stream a large file out of the capsule.

View File

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: openapi.yaml
# timestamp: 2026-04-22T20:21:34+00:00
# timestamp: 2026-05-04T20:57:00+00:00
from __future__ import annotations
from pydantic import AwareDatetime, BaseModel, EmailStr, Field

View File

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

View File

@ -32,7 +32,7 @@ class TestCapsuleCreate:
respx.post(f"{BASE}/v1/capsules").respond(
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 hasattr(cap, "commands")
assert hasattr(cap, "files")
@ -42,7 +42,7 @@ class TestCapsuleCreate:
respx.post(f"{BASE}/v1/capsules").respond(
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"
@respx.mock
@ -51,7 +51,7 @@ class TestCapsuleCreate:
201, json={"id": "cl-1", "status": "pending"}
)
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 kill_route.called
@ -61,7 +61,7 @@ class TestCapsuleCreate:
respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-3", "status": "pending"}
)
cap = Capsule()
cap = Capsule(base_url=BASE)
assert cap.capsule_id == "cl-3"
@ -69,7 +69,7 @@ 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")
Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert route.called
@respx.mock
@ -77,7 +77,7 @@ class TestCapsuleStaticMethods:
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
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"
@respx.mock
@ -85,7 +85,7 @@ class TestCapsuleStaticMethods:
respx.get(f"{BASE}/v1/capsules").respond(
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 items[0].id == "cl-1"
@ -95,7 +95,7 @@ class TestCapsuleStaticMethods:
200, json={"id": "cl-1", "status": "running"}
)
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"
@ -106,7 +106,7 @@ 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")
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert cap.capsule_id == "cl-1"
@respx.mock
@ -117,7 +117,7 @@ class TestCapsuleConnect:
respx.post(f"{BASE}/v1/capsules/cl-1/resume").respond(
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"

View File

@ -23,13 +23,13 @@ BASE = "https://app.wrenn.dev/api"
@pytest.fixture
def client():
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
with WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as c:
yield c
@pytest.fixture
def async_client():
return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678")
return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
class TestCapsules:
@ -221,7 +221,8 @@ class TestAuthModes:
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
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"):
WrennClient()

View File

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

View File

@ -73,7 +73,7 @@ def _make_git(respx_mock=None) -> Git:
"""Create a Git instance bound to a test capsule."""
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)
@ -81,7 +81,7 @@ def _make_async_git() -> AsyncGit:
"""Create an AsyncGit instance bound to a test capsule."""
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)
@ -926,7 +926,7 @@ class TestCapsuleWiring:
respx.post(f"{BASE}/v1/capsules").respond(
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 isinstance(cap.git, Git)
@ -1017,7 +1017,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient
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)
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.commands import Commands
client = WrennClient(api_key="wrn_test1234567890abcdef12345678")
client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
commands = Commands(CAPSULE_ID, client.http)
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1045,7 +1045,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient
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)
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1059,7 +1059,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient
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)
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1073,7 +1073,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient
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)
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1089,7 +1089,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient
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)
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1119,7 +1119,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient
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)
route = respx.post(EXEC_URL).respond(200, json={"pid": 42, "tag": "bg-1"})

View File

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

95
uv.lock generated
View File

@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.13"
resolution-markers = [
"python_full_version >= '3.14'",
@ -72,6 +72,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "cfgv"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
@ -226,6 +235,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
]
[[package]]
name = "distlib"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
@ -282,6 +300,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]]
name = "filelock"
version = "3.29.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
]
[[package]]
name = "genson"
version = "1.3.0"
@ -343,6 +370,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/f8/a6bc80313a9e93c888fa10534dfce2ad76ff86911b6f485777ce6de6a073/httpx_ws-0.9.0-py3-none-any.whl", hash = "sha256:71640d2fb1bf9a225775015b33cd755cfd4c5f7e21c885192fe3adc4c387b248", size = 15759, upload-time = "2026-03-28T14:11:11.887Z" },
]
[[package]]
name = "identify"
version = "2.6.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
]
[[package]]
name = "idna"
version = "3.11"
@ -548,6 +584,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
name = "nr-date"
version = "2.1.0"
@ -615,6 +660,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pre-commit"
version = "4.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
@ -745,6 +806,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-discovery"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" },
]
[[package]]
name = "pytokens"
version = "0.4.1"
@ -956,6 +1030,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "virtualenv"
version = "21.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
{ name = "python-discovery" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3f/8b/6331f7a7fe70131c301106ec1e7cf23e2501bf7d4ca3636805801ca191bb/virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e", size = 7614069, upload-time = "2026-04-27T17:05:58.927Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/eb/03bfb1299d4c4510329e470f13f9a4ce793df7fcb5a2fd3510f911066f61/virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", size = 7594690, upload-time = "2026-04-27T17:05:55.468Z" },
]
[[package]]
name = "watchdog"
version = "6.0.0"
@ -1032,7 +1121,7 @@ wheels = [
[[package]]
name = "wrenn"
version = "0.1.0"
version = "0.1.1"
source = { editable = "." }
dependencies = [
{ name = "email-validator" },
@ -1045,6 +1134,7 @@ dependencies = [
dev = [
{ name = "datamodel-code-generator", extra = ["ruff"] },
{ name = "mypy" },
{ name = "pre-commit" },
{ name = "pydoc-markdown" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
@ -1064,6 +1154,7 @@ requires-dist = [
dev = [
{ name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" },
{ name = "mypy", specifier = ">=1.20.0" },
{ name = "pre-commit", specifier = ">=4.6.0" },
{ name = "pydoc-markdown", specifier = ">=4.8.2" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },