v0.1.4 #9

Merged
pptx704 merged 51 commits from dev into main 2026-05-20 21:01:22 +00:00
6 changed files with 9 additions and 187 deletions
Showing only changes of commit 06b4a8cbcb - Show all commits

5
.gitignore vendored
View File

@ -177,4 +177,7 @@ cython_debug/
CODE_EXECUTION.md CODE_EXECUTION.md
.opencode/ .opencode/
.claude/ # 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: when:
event: push event: pull_request
branch: branch:
- main - main
- dev - 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

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

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
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
@ -103,6 +104,7 @@ class AsyncCapsule:
memory_mb=memory_mb, memory_mb=memory_mb,
timeout_sec=timeout, timeout_sec=timeout,
) )
assert info.id is not None
capsule = cls( capsule = cls(
_capsule_id=info.id, _capsule_id=info.id,
_client=client, _client=client,
@ -287,7 +289,7 @@ class AsyncCapsule:
async def pty( async def pty(
self, self,
cmd: str = "/bin/bash", cmd: str = "/bin/bash",
args: list[str] | None = None, args: builtins.list[str] | None = None,
cols: int = 80, cols: int = 80,
rows: int = 24, rows: int = 24,
envs: dict[str, str] | None = None, envs: dict[str, str] | None = None,
@ -319,7 +321,7 @@ class AsyncCapsule:
""" """
async with httpx_ws.aconnect_ws( async with httpx_ws.aconnect_ws(
f"/v1/capsules/{self._id}/pty", client=self._client.http f"/v1/capsules/{self._id}/pty", client=self._client.http
) as ws: ) as ws: # type: httpx_ws.AsyncWebSocketSession
session = AsyncPtySession(ws, self._id) session = AsyncPtySession(ws, self._id)
await session._send_start( await session._send_start(
cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd
@ -338,7 +340,7 @@ class AsyncCapsule:
""" """
async with httpx_ws.aconnect_ws( async with httpx_ws.aconnect_ws(
f"/v1/capsules/{self._id}/pty", client=self._client.http f"/v1/capsules/{self._id}/pty", client=self._client.http
) as ws: ) as ws: # type: httpx_ws.AsyncWebSocketSession
session = AsyncPtySession(ws, self._id) session = AsyncPtySession(ws, self._id)
await session._send_connect(tag) await session._send_connect(tag)
yield session yield session

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
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
@ -96,7 +97,7 @@ class Capsule:
""" """
if _capsule_id is not None: if _capsule_id is not None:
assert _client is not None assert _client is not None
self._id = _capsule_id self._id: str = _capsule_id
self._client = _client self._client = _client
self._info = _info self._info = _info
if self._id is None: if self._id is None:
@ -370,7 +371,7 @@ class Capsule:
def pty( def pty(
self, self,
cmd: str = "/bin/bash", cmd: str = "/bin/bash",
args: list[str] | None = None, args: builtins.list[str] | None = None,
cols: int = 80, cols: int = 80,
rows: int = 24, rows: int = 24,
envs: dict[str, str] | None = None, envs: dict[str, str] | None = None,
@ -401,7 +402,7 @@ class Capsule:
""" """
with httpx_ws.connect_ws( with httpx_ws.connect_ws(
f"/v1/capsules/{self._id}/pty", client=self._client.http f"/v1/capsules/{self._id}/pty", client=self._client.http
) as ws: ) as ws: # type: httpx_ws.WebSocketSession
session = PtySession(ws, self._id) session = PtySession(ws, self._id)
session._send_start( session._send_start(
cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd
@ -420,7 +421,7 @@ class Capsule:
""" """
with httpx_ws.connect_ws( with httpx_ws.connect_ws(
f"/v1/capsules/{self._id}/pty", client=self._client.http f"/v1/capsules/{self._id}/pty", client=self._client.http
) as ws: ) as ws: # type: httpx_ws.WebSocketSession
session = PtySession(ws, self._id) session = PtySession(ws, self._id)
session._send_connect(tag) session._send_connect(tag)
yield session yield session

View File

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

View File

@ -229,7 +229,7 @@ class AsyncCapsule(BaseAsyncCapsule):
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
headers = {"X-API-Key": self._client._api_key} 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)) await ws.send_text(json.dumps(msg))
while time.monotonic() < deadline: while time.monotonic() < deadline:
time_left = deadline - time.monotonic() time_left = deadline - time.monotonic()

View File

@ -244,7 +244,7 @@ class Capsule(BaseCapsule):
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
headers = {"X-API-Key": self._client._api_key} 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)) ws.send_text(json.dumps(msg))
while time.monotonic() < deadline: while time.monotonic() < deadline:
time_left = deadline - time.monotonic() time_left = deadline - time.monotonic()

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
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
@ -207,6 +208,7 @@ class Commands:
timeout=http_timeout, timeout=http_timeout,
) )
data = handle_response(resp) data = handle_response(resp)
assert isinstance(data, dict)
if background: if background:
return CommandHandle( return CommandHandle(
@ -225,6 +227,7 @@ class Commands:
""" """
resp = self._http.get(f"/v1/capsules/{self._capsule_id}/processes") resp = self._http.get(f"/v1/capsules/{self._capsule_id}/processes")
data = handle_response(resp) data = handle_response(resp)
assert isinstance(data, dict)
return [ return [
ProcessInfo( ProcessInfo(
pid=p.get("pid", 0), pid=p.get("pid", 0),
@ -260,7 +263,7 @@ class Commands:
with httpx_ws.connect_ws( with httpx_ws.connect_ws(
f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream", f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream",
self._http, self._http,
) as ws: ) as ws: # type: httpx_ws.WebSocketSession
while True: while True:
try: try:
raw = ws.receive_json() raw = ws.receive_json()
@ -271,7 +274,9 @@ class Commands:
except httpx_ws.WebSocketDisconnect: except httpx_ws.WebSocketDisconnect:
break 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. """Execute a command via WebSocket, streaming output as events.
Args: Args:
@ -288,7 +293,7 @@ class Commands:
with httpx_ws.connect_ws( with httpx_ws.connect_ws(
f"/v1/capsules/{self._capsule_id}/exec/stream", f"/v1/capsules/{self._capsule_id}/exec/stream",
self._http, self._http,
) as ws: ) as ws: # type: httpx_ws.WebSocketSession
if args: if args:
start_msg: dict = {"type": "start", "cmd": cmd, "args": args} start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
else: else:
@ -392,6 +397,7 @@ class AsyncCommands:
timeout=http_timeout, timeout=http_timeout,
) )
data = handle_response(resp) data = handle_response(resp)
assert isinstance(data, dict)
if background: if background:
return CommandHandle( return CommandHandle(
@ -410,6 +416,7 @@ class AsyncCommands:
""" """
resp = await self._http.get(f"/v1/capsules/{self._capsule_id}/processes") resp = await self._http.get(f"/v1/capsules/{self._capsule_id}/processes")
data = handle_response(resp) data = handle_response(resp)
assert isinstance(data, dict)
return [ return [
ProcessInfo( ProcessInfo(
pid=p.get("pid", 0), pid=p.get("pid", 0),
@ -447,7 +454,7 @@ class AsyncCommands:
async with httpx_ws.aconnect_ws( async with httpx_ws.aconnect_ws(
f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream", f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream",
self._http, self._http,
) as ws: ) as ws: # type: httpx_ws.AsyncWebSocketSession
try: try:
while True: while True:
raw = await ws.receive_json() raw = await ws.receive_json()
@ -459,7 +466,7 @@ class AsyncCommands:
pass pass
async def stream( async def stream(
self, cmd: str, args: list[str] | None = None self, cmd: str, args: builtins.list[str] | None = None
) -> AsyncIterator[StreamEvent]: ) -> AsyncIterator[StreamEvent]:
"""Execute a command via WebSocket, streaming output as events. """Execute a command via WebSocket, streaming output as events.
@ -477,7 +484,7 @@ class AsyncCommands:
async with httpx_ws.aconnect_ws( async with httpx_ws.aconnect_ws(
f"/v1/capsules/{self._capsule_id}/exec/stream", f"/v1/capsules/{self._capsule_id}/exec/stream",
self._http, self._http,
) as ws: ) as ws: # type: httpx_ws.AsyncWebSocketSession
if args: if args:
start_msg: dict = {"type": "start", "cmd": cmd, "args": args} start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
else: else:

93
uv.lock generated
View File

@ -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" }, { 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]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.7" 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" }, { 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]] [[package]]
name = "dnspython" name = "dnspython"
version = "2.8.0" 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" }, { 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]] [[package]]
name = "genson" name = "genson"
version = "1.3.0" 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" }, { 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]] [[package]]
name = "idna" name = "idna"
version = "3.11" 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" }, { 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]] [[package]]
name = "nr-date" name = "nr-date"
version = "2.1.0" 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" }, { 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]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.5" 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" }, { 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]] [[package]]
name = "pytokens" name = "pytokens"
version = "0.4.1" 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" }, { 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]] [[package]]
name = "watchdog" name = "watchdog"
version = "6.0.0" version = "6.0.0"
@ -1032,7 +1121,7 @@ wheels = [
[[package]] [[package]]
name = "wrenn" name = "wrenn"
version = "0.1.0" version = "0.1.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "email-validator" }, { name = "email-validator" },
@ -1045,6 +1134,7 @@ dependencies = [
dev = [ dev = [
{ name = "datamodel-code-generator", extra = ["ruff"] }, { name = "datamodel-code-generator", extra = ["ruff"] },
{ name = "mypy" }, { name = "mypy" },
{ name = "pre-commit" },
{ name = "pydoc-markdown" }, { name = "pydoc-markdown" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
@ -1064,6 +1154,7 @@ requires-dist = [
dev = [ dev = [
{ name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" }, { name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" },
{ name = "mypy", specifier = ">=1.20.0" }, { name = "mypy", specifier = ">=1.20.0" },
{ name = "pre-commit", specifier = ">=4.6.0" },
{ name = "pydoc-markdown", specifier = ">=4.8.2" }, { name = "pydoc-markdown", specifier = ">=4.8.2" },
{ name = "pytest", specifier = ">=9.0.3" }, { name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-asyncio", specifier = ">=1.3.0" },