feat: prune SDK to API-key surface, add stats/usage/metrics/events
All checks were successful
ci/woodpecker/push/unit Pipeline was successful
All checks were successful
ci/woodpecker/push/unit Pipeline was successful
Strip session-only routes (auth, me, teams, hosts, channels, admin, audit-logs) from api/openapi.yaml via new scripts/prune_openapi.py, which is wired into `make generate` and `make prune-spec`. Spec drops from 4787 to 1717 lines; regenerated _generated.py drops from 906 to 415 lines with all obsolete models removed. Add SDK methods on CapsulesResource / AsyncCapsulesResource for the apiKey-usable routes that were previously unwrapped: - capsules.stats(range=...) -> CapsuleStats - capsules.usage(from_=..., to=...) -> UsageResponse (accepts date) - capsules.metrics(id, range=...) -> CapsuleMetrics Add EventsResource / AsyncEventsResource exposing WrennClient.events.stream() over /v1/events/stream (SSE). Parser handles `:keepalive` comments and multi-line `data:` frames. Re-export new models (CapsuleStats, CapsuleMetrics, MetricPoint, UsageResponse, SSEEvent, Resource, Actor) from wrenn.models. Drop email-validator runtime dep (no EmailStr remains). Bump version to 0.3.0 (pyproject + __version__). Add pyyaml to dev deps and types-PyYAML to the pre-commit mypy hook for the prune script. Normalize docstrings on new methods/classes to Google style with Example:: blocks where useful. Regenerate docs/reference.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -13,7 +13,7 @@ repos:
|
|||||||
- pydantic>=2.12.5
|
- pydantic>=2.12.5
|
||||||
- httpx>=0.28.1
|
- httpx>=0.28.1
|
||||||
- httpx-ws>=0.9.0
|
- httpx-ws>=0.9.0
|
||||||
- email-validator>=2.3.0
|
- types-PyYAML
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
|
|||||||
8
Makefile
8
Makefile
@ -1,5 +1,5 @@
|
|||||||
# Makefile
|
# Makefile
|
||||||
.PHONY: generate lint test check test-integration test-code-runner
|
.PHONY: generate gen-docs lint test check test-integration test-code-runner prune-spec
|
||||||
|
|
||||||
# Variables
|
# Variables
|
||||||
SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml"
|
SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml"
|
||||||
@ -12,6 +12,8 @@ generate:
|
|||||||
|
|
||||||
curl -fsSL $(SPEC_URL) -o $(SPEC_PATH)
|
curl -fsSL $(SPEC_URL) -o $(SPEC_PATH)
|
||||||
|
|
||||||
|
$(MAKE) prune-spec
|
||||||
|
|
||||||
uv run datamodel-codegen \
|
uv run datamodel-codegen \
|
||||||
--input $(SPEC_PATH) \
|
--input $(SPEC_PATH) \
|
||||||
--output src/wrenn/models/_generated.py \
|
--output src/wrenn/models/_generated.py \
|
||||||
@ -40,6 +42,10 @@ test-code-runner:
|
|||||||
|
|
||||||
check: lint test
|
check: lint test
|
||||||
|
|
||||||
|
prune-spec:
|
||||||
|
@echo "Pruning spec down to the SDK's API-key surface..."
|
||||||
|
uv run python scripts/prune_openapi.py $(SPEC_PATH)
|
||||||
|
|
||||||
gen-docs:
|
gen-docs:
|
||||||
mkdir -p docs
|
mkdir -p docs
|
||||||
uv run pydoc-markdown > docs/reference.md
|
uv run pydoc-markdown > docs/reference.md
|
||||||
|
|||||||
3954
api/openapi.yaml
3954
api/openapi.yaml
File diff suppressed because it is too large
Load Diff
@ -166,6 +166,131 @@ Reset the inactivity timer for a capsule.
|
|||||||
|
|
||||||
- `WrennNotFoundError` - If no capsule with the given ID exists.
|
- `WrennNotFoundError` - If no capsule with the given ID exists.
|
||||||
|
|
||||||
|
<a id="wrenn.client.CapsulesResource.stats"></a>
|
||||||
|
|
||||||
|
#### stats
|
||||||
|
|
||||||
|
```python
|
||||||
|
def stats(range: str | None = None) -> CapsuleStats
|
||||||
|
```
|
||||||
|
|
||||||
|
Get aggregate capsule usage stats for the authenticated team.
|
||||||
|
|
||||||
|
**Arguments**:
|
||||||
|
|
||||||
|
- `range` _str | None_ - Time window. One of ``5m``, ``1h``, ``6h``,
|
||||||
|
``24h``, ``30d``. Defaults to ``1h`` server-side.
|
||||||
|
|
||||||
|
|
||||||
|
**Returns**:
|
||||||
|
|
||||||
|
- `CapsuleStats` - Current running counts plus 30-day peaks and a
|
||||||
|
chart-ready time series.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
stats = wrenn.capsules.stats(range="24h")
|
||||||
|
print(stats.current.running_count, stats.peaks.vcpus)
|
||||||
|
|
||||||
|
<a id="wrenn.client.CapsulesResource.usage"></a>
|
||||||
|
|
||||||
|
#### usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
def usage(from_: str | date | None = None,
|
||||||
|
to: str | date | None = None) -> UsageResponse
|
||||||
|
```
|
||||||
|
|
||||||
|
Get per-day CPU and RAM usage for the team.
|
||||||
|
|
||||||
|
**Arguments**:
|
||||||
|
|
||||||
|
- `from_` _str | date | None_ - Start date (``YYYY-MM-DD`` string or
|
||||||
|
``date``). Defaults to 30 days ago server-side.
|
||||||
|
- `to` _str | date | None_ - End date. Defaults to today.
|
||||||
|
|
||||||
|
|
||||||
|
**Returns**:
|
||||||
|
|
||||||
|
- `UsageResponse` - Daily ``cpu_minutes`` / ``ram_mb_minutes`` points.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
usage = wrenn.capsules.usage(from_=today - timedelta(days=7), to=today)
|
||||||
|
for point in usage.points:
|
||||||
|
print(point.date, point.cpu_minutes, point.ram_mb_minutes)
|
||||||
|
|
||||||
|
<a id="wrenn.client.CapsulesResource.metrics"></a>
|
||||||
|
|
||||||
|
#### metrics
|
||||||
|
|
||||||
|
```python
|
||||||
|
def metrics(id: str, range: str | None = None) -> CapsuleMetrics
|
||||||
|
```
|
||||||
|
|
||||||
|
Get time-series CPU, memory, and disk metrics for a capsule.
|
||||||
|
|
||||||
|
**Arguments**:
|
||||||
|
|
||||||
|
- `id` _str_ - Capsule ID.
|
||||||
|
- `range` _str | None_ - One of ``10m`` (500ms samples),
|
||||||
|
``2h`` (30s averages), ``24h`` (5-minute averages). Defaults
|
||||||
|
to ``10m`` server-side.
|
||||||
|
|
||||||
|
|
||||||
|
**Returns**:
|
||||||
|
|
||||||
|
- `CapsuleMetrics` - Sampled :class:`MetricPoint` series.
|
||||||
|
|
||||||
|
|
||||||
|
**Raises**:
|
||||||
|
|
||||||
|
- `WrennNotFoundError` - If the capsule does not exist or has been
|
||||||
|
destroyed.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
m = wrenn.capsules.metrics("sb-abc123", range="2h")
|
||||||
|
for point in m.points:
|
||||||
|
print(point.timestamp_unix, point.cpu_pct, point.mem_bytes)
|
||||||
|
|
||||||
|
<a id="wrenn.client.EventsResource"></a>
|
||||||
|
|
||||||
|
## EventsResource Objects
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EventsResource()
|
||||||
|
```
|
||||||
|
|
||||||
|
Sync server-sent event stream of capsule/template/host lifecycle events.
|
||||||
|
|
||||||
|
<a id="wrenn.client.EventsResource.stream"></a>
|
||||||
|
|
||||||
|
#### stream
|
||||||
|
|
||||||
|
```python
|
||||||
|
def stream() -> Iterator[SSEEvent]
|
||||||
|
```
|
||||||
|
|
||||||
|
Stream lifecycle events for the team in real time.
|
||||||
|
|
||||||
|
The connection is held open by the server; iterate the result to
|
||||||
|
receive :class:`SSEEvent` payloads as they arrive. Close the
|
||||||
|
iterator (or break out of the loop) to disconnect.
|
||||||
|
|
||||||
|
**Yields**:
|
||||||
|
|
||||||
|
- `SSEEvent` - One event per server frame.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
with WrennClient() as wrenn:
|
||||||
|
for ev in wrenn.events.stream():
|
||||||
|
print(ev.event, ev.resource)
|
||||||
|
|
||||||
<a id="wrenn.client.AsyncCapsulesResource"></a>
|
<a id="wrenn.client.AsyncCapsulesResource"></a>
|
||||||
|
|
||||||
## AsyncCapsulesResource Objects
|
## AsyncCapsulesResource Objects
|
||||||
@ -326,6 +451,129 @@ Reset the inactivity timer for a capsule.
|
|||||||
|
|
||||||
- `WrennNotFoundError` - If no capsule with the given ID exists.
|
- `WrennNotFoundError` - If no capsule with the given ID exists.
|
||||||
|
|
||||||
|
<a id="wrenn.client.AsyncCapsulesResource.stats"></a>
|
||||||
|
|
||||||
|
#### stats
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def stats(range: str | None = None) -> CapsuleStats
|
||||||
|
```
|
||||||
|
|
||||||
|
Get aggregate capsule usage stats for the authenticated team.
|
||||||
|
|
||||||
|
**Arguments**:
|
||||||
|
|
||||||
|
- `range` _str | None_ - Time window. One of ``5m``, ``1h``, ``6h``,
|
||||||
|
``24h``, ``30d``. Defaults to ``1h`` server-side.
|
||||||
|
|
||||||
|
|
||||||
|
**Returns**:
|
||||||
|
|
||||||
|
- `CapsuleStats` - Current running counts plus 30-day peaks and a
|
||||||
|
chart-ready time series.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
stats = await wrenn.capsules.stats(range="24h")
|
||||||
|
print(stats.current.running_count, stats.peaks.vcpus)
|
||||||
|
|
||||||
|
<a id="wrenn.client.AsyncCapsulesResource.usage"></a>
|
||||||
|
|
||||||
|
#### usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def usage(from_: str | date | None = None,
|
||||||
|
to: str | date | None = None) -> UsageResponse
|
||||||
|
```
|
||||||
|
|
||||||
|
Get per-day CPU and RAM usage for the team.
|
||||||
|
|
||||||
|
**Arguments**:
|
||||||
|
|
||||||
|
- `from_` _str | date | None_ - Start date (``YYYY-MM-DD`` string or
|
||||||
|
``date``). Defaults to 30 days ago server-side.
|
||||||
|
- `to` _str | date | None_ - End date. Defaults to today.
|
||||||
|
|
||||||
|
|
||||||
|
**Returns**:
|
||||||
|
|
||||||
|
- `UsageResponse` - Daily ``cpu_minutes`` / ``ram_mb_minutes`` points.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
usage = await wrenn.capsules.usage(
|
||||||
|
from_=today - timedelta(days=7), to=today
|
||||||
|
)
|
||||||
|
for point in usage.points:
|
||||||
|
print(point.date, point.cpu_minutes, point.ram_mb_minutes)
|
||||||
|
|
||||||
|
<a id="wrenn.client.AsyncCapsulesResource.metrics"></a>
|
||||||
|
|
||||||
|
#### metrics
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def metrics(id: str, range: str | None = None) -> CapsuleMetrics
|
||||||
|
```
|
||||||
|
|
||||||
|
Get time-series CPU, memory, and disk metrics for a capsule.
|
||||||
|
|
||||||
|
**Arguments**:
|
||||||
|
|
||||||
|
- `id` _str_ - Capsule ID.
|
||||||
|
- `range` _str | None_ - One of ``10m`` (500ms samples),
|
||||||
|
``2h`` (30s averages), ``24h`` (5-minute averages). Defaults
|
||||||
|
to ``10m`` server-side.
|
||||||
|
|
||||||
|
|
||||||
|
**Returns**:
|
||||||
|
|
||||||
|
- `CapsuleMetrics` - Sampled :class:`MetricPoint` series.
|
||||||
|
|
||||||
|
|
||||||
|
**Raises**:
|
||||||
|
|
||||||
|
- `WrennNotFoundError` - If the capsule does not exist or has been
|
||||||
|
destroyed.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
m = await wrenn.capsules.metrics("sb-abc123", range="2h")
|
||||||
|
for point in m.points:
|
||||||
|
print(point.timestamp_unix, point.cpu_pct, point.mem_bytes)
|
||||||
|
|
||||||
|
<a id="wrenn.client.AsyncEventsResource"></a>
|
||||||
|
|
||||||
|
## AsyncEventsResource Objects
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AsyncEventsResource()
|
||||||
|
```
|
||||||
|
|
||||||
|
Async server-sent event stream of capsule/template/host lifecycle events.
|
||||||
|
|
||||||
|
<a id="wrenn.client.AsyncEventsResource.stream"></a>
|
||||||
|
|
||||||
|
#### stream
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def stream() -> AsyncIterator[SSEEvent]
|
||||||
|
```
|
||||||
|
|
||||||
|
Stream lifecycle events for the team in real time.
|
||||||
|
|
||||||
|
**Yields**:
|
||||||
|
|
||||||
|
- `SSEEvent` - One event per server frame.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
async with AsyncWrennClient() as wrenn:
|
||||||
|
async for ev in wrenn.events.stream():
|
||||||
|
print(ev.event, ev.resource)
|
||||||
|
|
||||||
<a id="wrenn.client.SnapshotsResource"></a>
|
<a id="wrenn.client.SnapshotsResource"></a>
|
||||||
|
|
||||||
## SnapshotsResource Objects
|
## SnapshotsResource Objects
|
||||||
@ -484,7 +732,11 @@ class WrennClient()
|
|||||||
|
|
||||||
Synchronous client for the Wrenn API.
|
Synchronous client for the Wrenn API.
|
||||||
|
|
||||||
Authenticates with an API key.
|
Authenticates with an API key. Exposes three resources:
|
||||||
|
|
||||||
|
- :attr:`capsules` — capsule lifecycle, stats, usage, metrics
|
||||||
|
- :attr:`snapshots` — template snapshot management
|
||||||
|
- :attr:`events` — server-sent lifecycle event stream
|
||||||
|
|
||||||
**Arguments**:
|
**Arguments**:
|
||||||
|
|
||||||
@ -497,6 +749,15 @@ Authenticates with an API key.
|
|||||||
- `timeout` - HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
|
- `timeout` - HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
|
||||||
or ``None`` for the default (30s read/write/pool, 10s connect).
|
or ``None`` for the default (30s read/write/pool, 10s connect).
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
from wrenn import WrennClient
|
||||||
|
|
||||||
|
with WrennClient() as wrenn: # reads WRENN_API_KEY
|
||||||
|
capsule = wrenn.capsules.create(template="minimal-ubuntu")
|
||||||
|
print(capsule.id, capsule.status)
|
||||||
|
wrenn.capsules.destroy(capsule.id)
|
||||||
|
|
||||||
<a id="wrenn.client.WrennClient.http"></a>
|
<a id="wrenn.client.WrennClient.http"></a>
|
||||||
|
|
||||||
#### http
|
#### http
|
||||||
@ -528,7 +789,8 @@ class AsyncWrennClient()
|
|||||||
|
|
||||||
Asynchronous client for the Wrenn API.
|
Asynchronous client for the Wrenn API.
|
||||||
|
|
||||||
Authenticates with an API key.
|
Authenticates with an API key. Mirrors :class:`WrennClient` with
|
||||||
|
``await``-able methods on every resource.
|
||||||
|
|
||||||
**Arguments**:
|
**Arguments**:
|
||||||
|
|
||||||
@ -541,6 +803,16 @@ Authenticates with an API key.
|
|||||||
- `timeout` - HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
|
- `timeout` - HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
|
||||||
or ``None`` for the default (30s read/write/pool, 10s connect).
|
or ``None`` for the default (30s read/write/pool, 10s connect).
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
from wrenn import AsyncWrennClient
|
||||||
|
|
||||||
|
async with AsyncWrennClient() as wrenn:
|
||||||
|
capsule = await wrenn.capsules.create(template="minimal-ubuntu")
|
||||||
|
async for event in wrenn.events.stream():
|
||||||
|
if event.resource and event.resource.id == capsule.id:
|
||||||
|
break
|
||||||
|
|
||||||
<a id="wrenn.client.AsyncWrennClient.http"></a>
|
<a id="wrenn.client.AsyncWrennClient.http"></a>
|
||||||
|
|
||||||
#### http
|
#### http
|
||||||
@ -1936,18 +2208,6 @@ Send SIGKILL to the PTY process.
|
|||||||
|
|
||||||
# wrenn.models.\_generated
|
# wrenn.models.\_generated
|
||||||
|
|
||||||
<a id="wrenn.models._generated.SessionResponse"></a>
|
|
||||||
|
|
||||||
## SessionResponse Objects
|
|
||||||
|
|
||||||
```python
|
|
||||||
class SessionResponse(BaseModel)
|
|
||||||
```
|
|
||||||
|
|
||||||
Returned by login, activate, and switch-team. The actual auth credential
|
|
||||||
is the wrenn_sid cookie set on the response. The body carries identity
|
|
||||||
data the SPA needs to bootstrap.
|
|
||||||
|
|
||||||
<a id="wrenn.models._generated.Peaks"></a>
|
<a id="wrenn.models._generated.Peaks"></a>
|
||||||
|
|
||||||
## Peaks Objects
|
## Peaks Objects
|
||||||
@ -1978,16 +2238,6 @@ class Encoding(StrEnum)
|
|||||||
|
|
||||||
Output encoding. "base64" when stdout/stderr contain binary data.
|
Output encoding. "base64" when stdout/stderr contain binary data.
|
||||||
|
|
||||||
<a id="wrenn.models._generated.Type2"></a>
|
|
||||||
|
|
||||||
## Type2 Objects
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Type2(StrEnum)
|
|
||||||
```
|
|
||||||
|
|
||||||
Host type. Regular hosts are shared; BYOC hosts belong to a team.
|
|
||||||
|
|
||||||
<a id="wrenn.models._generated.Outcome"></a>
|
<a id="wrenn.models._generated.Outcome"></a>
|
||||||
|
|
||||||
## Outcome Objects
|
## Outcome Objects
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "wrenn"
|
name = "wrenn"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
description = "Python SDK for Wrenn"
|
description = "Python SDK for Wrenn"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@ -23,7 +23,6 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"certifi>=2026.2.25",
|
"certifi>=2026.2.25",
|
||||||
"email-validator>=2.3.0",
|
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"httpx-ws>=0.9.0",
|
"httpx-ws>=0.9.0",
|
||||||
"pydantic>=2.12.5",
|
"pydantic>=2.12.5",
|
||||||
@ -41,6 +40,7 @@ dev = [
|
|||||||
"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",
|
||||||
|
"pyyaml>=6.0.3",
|
||||||
"respx>=0.23.1",
|
"respx>=0.23.1",
|
||||||
"ruff>=0.15.10",
|
"ruff>=0.15.10",
|
||||||
]
|
]
|
||||||
|
|||||||
179
scripts/prune_openapi.py
Normal file
179
scripts/prune_openapi.py
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
"""Prune the upstream Wrenn OpenAPI spec down to the SDK's surface.
|
||||||
|
|
||||||
|
The upstream spec at ``wrennhq/wrenn/internal/api/openapi.yaml`` covers
|
||||||
|
the whole control plane: account/team/host/channel/admin routes that
|
||||||
|
require an interactive ``wrenn_sid`` cookie. The Python SDK only ever
|
||||||
|
authenticates with an API key, so those routes are dead surface area
|
||||||
|
here. This script keeps only paths that list ``apiKeyAuth`` (and the
|
||||||
|
schemas they transitively reach) and rewrites the spec in place.
|
||||||
|
|
||||||
|
Run via ``make generate`` (curl ➜ prune ➜ datamodel-codegen) or
|
||||||
|
``uv run python scripts/prune_openapi.py [path]`` directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
KEEP_PATHS: set[str] = {
|
||||||
|
"/v1/capsules",
|
||||||
|
"/v1/capsules/stats",
|
||||||
|
"/v1/capsules/usage",
|
||||||
|
"/v1/capsules/{id}",
|
||||||
|
"/v1/capsules/{id}/exec",
|
||||||
|
"/v1/capsules/{id}/exec/stream",
|
||||||
|
"/v1/capsules/{id}/processes",
|
||||||
|
"/v1/capsules/{id}/processes/{selector}",
|
||||||
|
"/v1/capsules/{id}/processes/{selector}/stream",
|
||||||
|
"/v1/capsules/{id}/ping",
|
||||||
|
"/v1/capsules/{id}/metrics",
|
||||||
|
"/v1/capsules/{id}/pause",
|
||||||
|
"/v1/capsules/{id}/resume",
|
||||||
|
"/v1/capsules/{id}/files/write",
|
||||||
|
"/v1/capsules/{id}/files/read",
|
||||||
|
"/v1/capsules/{id}/files/list",
|
||||||
|
"/v1/capsules/{id}/files/mkdir",
|
||||||
|
"/v1/capsules/{id}/files/remove",
|
||||||
|
"/v1/capsules/{id}/files/stream/write",
|
||||||
|
"/v1/capsules/{id}/files/stream/read",
|
||||||
|
"/v1/capsules/{id}/pty",
|
||||||
|
"/v1/snapshots",
|
||||||
|
"/v1/snapshots/{name}",
|
||||||
|
"/v1/events/stream",
|
||||||
|
}
|
||||||
|
|
||||||
|
KEEP_SECURITY_SCHEMES: set[str] = {"apiKeyAuth"}
|
||||||
|
|
||||||
|
REF_RE = re.compile(r"#/components/(schemas|responses)/([A-Za-z0-9_]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_refs(node: object, schemas: dict, responses: dict, seen: set[str]) -> None:
|
||||||
|
"""Depth-first walk collecting ``schemas:Name`` / ``responses:Name`` refs."""
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for k, v in node.items():
|
||||||
|
if k == "$ref" and isinstance(v, str):
|
||||||
|
m = REF_RE.search(v)
|
||||||
|
if m:
|
||||||
|
kind, name = m.group(1), m.group(2)
|
||||||
|
key = f"{kind}:{name}"
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
target = (schemas if kind == "schemas" else responses).get(name)
|
||||||
|
if target is not None:
|
||||||
|
_walk_refs(target, schemas, responses, seen)
|
||||||
|
else:
|
||||||
|
_walk_refs(v, schemas, responses, seen)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for item in node:
|
||||||
|
_walk_refs(item, schemas, responses, seen)
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_security(operation: dict) -> None:
|
||||||
|
sec = operation.get("security")
|
||||||
|
if not isinstance(sec, list):
|
||||||
|
return
|
||||||
|
kept = [
|
||||||
|
entry
|
||||||
|
for entry in sec
|
||||||
|
if isinstance(entry, dict)
|
||||||
|
and any(name in KEEP_SECURITY_SCHEMES for name in entry)
|
||||||
|
]
|
||||||
|
if kept:
|
||||||
|
operation["security"] = kept
|
||||||
|
else:
|
||||||
|
operation.pop("security", None)
|
||||||
|
|
||||||
|
|
||||||
|
def prune(spec: dict) -> dict:
|
||||||
|
paths = spec.get("paths", {})
|
||||||
|
components = spec.get("components", {})
|
||||||
|
schemas = components.get("schemas", {}) or {}
|
||||||
|
responses = components.get("responses", {}) or {}
|
||||||
|
|
||||||
|
missing = KEEP_PATHS - set(paths)
|
||||||
|
if missing:
|
||||||
|
raise SystemExit(
|
||||||
|
"prune_openapi: upstream spec is missing expected paths: "
|
||||||
|
+ ", ".join(sorted(missing))
|
||||||
|
)
|
||||||
|
|
||||||
|
kept_paths: dict[str, dict] = {}
|
||||||
|
for path, item in paths.items():
|
||||||
|
if path not in KEEP_PATHS:
|
||||||
|
continue
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
kept_paths[path] = item
|
||||||
|
continue
|
||||||
|
for method, op in list(item.items()):
|
||||||
|
if isinstance(op, dict):
|
||||||
|
_filter_security(op)
|
||||||
|
if "security" in op and not op["security"]:
|
||||||
|
del item[method]
|
||||||
|
kept_paths[path] = item
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
_walk_refs(kept_paths, schemas, responses, seen)
|
||||||
|
|
||||||
|
# Walk again to close over inter-schema refs we just pulled in.
|
||||||
|
while True:
|
||||||
|
before = len(seen)
|
||||||
|
for key in list(seen):
|
||||||
|
kind, name = key.split(":", 1)
|
||||||
|
target = (schemas if kind == "schemas" else responses).get(name)
|
||||||
|
if target is not None:
|
||||||
|
_walk_refs(target, schemas, responses, seen)
|
||||||
|
if len(seen) == before:
|
||||||
|
break
|
||||||
|
|
||||||
|
kept_schemas = {n: schemas[n] for n in schemas if f"schemas:{n}" in seen}
|
||||||
|
kept_responses = {n: responses[n] for n in responses if f"responses:{n}" in seen}
|
||||||
|
|
||||||
|
sec_schemes = components.get("securitySchemes", {}) or {}
|
||||||
|
kept_sec_schemes = {
|
||||||
|
n: sec_schemes[n] for n in sec_schemes if n in KEEP_SECURITY_SCHEMES
|
||||||
|
}
|
||||||
|
|
||||||
|
new_components: dict = {}
|
||||||
|
if kept_responses:
|
||||||
|
new_components["responses"] = kept_responses
|
||||||
|
if kept_sec_schemes:
|
||||||
|
new_components["securitySchemes"] = kept_sec_schemes
|
||||||
|
if kept_schemas:
|
||||||
|
new_components["schemas"] = kept_schemas
|
||||||
|
|
||||||
|
spec["paths"] = kept_paths
|
||||||
|
spec["components"] = new_components
|
||||||
|
|
||||||
|
top_security = spec.get("security")
|
||||||
|
if isinstance(top_security, list):
|
||||||
|
spec["security"] = [
|
||||||
|
e
|
||||||
|
for e in top_security
|
||||||
|
if isinstance(e, dict) and any(name in KEEP_SECURITY_SCHEMES for name in e)
|
||||||
|
]
|
||||||
|
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
target = Path(argv[1]) if len(argv) > 1 else Path("api/openapi.yaml")
|
||||||
|
spec = yaml.safe_load(target.read_text())
|
||||||
|
pruned = prune(spec)
|
||||||
|
target.write_text(
|
||||||
|
yaml.safe_dump(pruned, sort_keys=False, width=120, allow_unicode=True)
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"prune_openapi: kept {len(pruned['paths'])} paths, "
|
||||||
|
f"{len(pruned['components'].get('schemas', {}))} schemas in {target}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv))
|
||||||
@ -37,7 +37,7 @@ from wrenn.exceptions import (
|
|||||||
from wrenn.models import FileEntry
|
from wrenn.models import FileEntry
|
||||||
from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession
|
from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession
|
||||||
|
|
||||||
__version__ = "0.1.4"
|
__version__ = "0.3.0"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"__version__",
|
"__version__",
|
||||||
|
|||||||
@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import AsyncIterator, Iterator
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@ -13,10 +15,14 @@ from wrenn._config import (
|
|||||||
ENV_BASE_URL,
|
ENV_BASE_URL,
|
||||||
ENV_PROXY_DOMAIN,
|
ENV_PROXY_DOMAIN,
|
||||||
)
|
)
|
||||||
from wrenn.exceptions import handle_response
|
from wrenn.exceptions import _raise_for_status, handle_response
|
||||||
|
|
||||||
from wrenn.models import (
|
from wrenn.models import (
|
||||||
|
CapsuleMetrics,
|
||||||
|
CapsuleStats,
|
||||||
|
SSEEvent,
|
||||||
Template,
|
Template,
|
||||||
|
UsageResponse,
|
||||||
)
|
)
|
||||||
from wrenn.models import (
|
from wrenn.models import (
|
||||||
Capsule as CapsuleModel,
|
Capsule as CapsuleModel,
|
||||||
@ -151,6 +157,46 @@ def _snapshot_list_params(type: str | None) -> dict:
|
|||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def _date_param(value: str | date | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value.isoformat()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _usage_params(from_: str | date | None, to: str | date | None) -> dict:
|
||||||
|
params: dict = {}
|
||||||
|
if (v := _date_param(from_)) is not None:
|
||||||
|
params["from"] = v
|
||||||
|
if (v := _date_param(to)) is not None:
|
||||||
|
params["to"] = v
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def _range_params(range: str | None) -> dict:
|
||||||
|
return {"range": range} if range is not None else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_sse_events(lines: Iterator[str]) -> Iterator[SSEEvent]:
|
||||||
|
"""Parse SSE ``data:`` frames into :class:`SSEEvent` objects.
|
||||||
|
|
||||||
|
Ignores ``event:`` names and ``:keepalive`` comments — the payload's
|
||||||
|
own ``event`` field carries the kind.
|
||||||
|
"""
|
||||||
|
data_lines: list[str] = []
|
||||||
|
for raw in lines:
|
||||||
|
if raw == "":
|
||||||
|
if data_lines:
|
||||||
|
yield SSEEvent.model_validate_json("\n".join(data_lines))
|
||||||
|
data_lines = []
|
||||||
|
continue
|
||||||
|
if raw.startswith(":"):
|
||||||
|
continue
|
||||||
|
if raw.startswith("data:"):
|
||||||
|
data_lines.append(raw[5:].lstrip())
|
||||||
|
|
||||||
|
|
||||||
class CapsulesResource:
|
class CapsulesResource:
|
||||||
"""Sync capsule control-plane operations."""
|
"""Sync capsule control-plane operations."""
|
||||||
|
|
||||||
@ -260,6 +306,104 @@ class CapsulesResource:
|
|||||||
resp = self._http.post(f"/v1/capsules/{id}/ping")
|
resp = self._http.post(f"/v1/capsules/{id}/ping")
|
||||||
handle_response(resp)
|
handle_response(resp)
|
||||||
|
|
||||||
|
def stats(self, range: str | None = None) -> CapsuleStats:
|
||||||
|
"""Get aggregate capsule usage stats for the authenticated team.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
range (str | None): Time window. One of ``5m``, ``1h``, ``6h``,
|
||||||
|
``24h``, ``30d``. Defaults to ``1h`` server-side.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CapsuleStats: Current running counts plus 30-day peaks and a
|
||||||
|
chart-ready time series.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
stats = wrenn.capsules.stats(range="24h")
|
||||||
|
print(stats.current.running_count, stats.peaks.vcpus)
|
||||||
|
"""
|
||||||
|
resp = self._http.get("/v1/capsules/stats", params=_range_params(range))
|
||||||
|
return CapsuleStats.model_validate(handle_response(resp))
|
||||||
|
|
||||||
|
def usage(
|
||||||
|
self,
|
||||||
|
from_: str | date | None = None,
|
||||||
|
to: str | date | None = None,
|
||||||
|
) -> UsageResponse:
|
||||||
|
"""Get per-day CPU and RAM usage for the team.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
from_ (str | date | None): Start date (``YYYY-MM-DD`` string or
|
||||||
|
``date``). Defaults to 30 days ago server-side.
|
||||||
|
to (str | date | None): End date. Defaults to today.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UsageResponse: Daily ``cpu_minutes`` / ``ram_mb_minutes`` points.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
usage = wrenn.capsules.usage(from_=today - timedelta(days=7), to=today)
|
||||||
|
for point in usage.points:
|
||||||
|
print(point.date, point.cpu_minutes, point.ram_mb_minutes)
|
||||||
|
"""
|
||||||
|
resp = self._http.get("/v1/capsules/usage", params=_usage_params(from_, to))
|
||||||
|
return UsageResponse.model_validate(handle_response(resp))
|
||||||
|
|
||||||
|
def metrics(self, id: str, range: str | None = None) -> CapsuleMetrics:
|
||||||
|
"""Get time-series CPU, memory, and disk metrics for a capsule.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id (str): Capsule ID.
|
||||||
|
range (str | None): One of ``10m`` (500ms samples),
|
||||||
|
``2h`` (30s averages), ``24h`` (5-minute averages). Defaults
|
||||||
|
to ``10m`` server-side.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CapsuleMetrics: Sampled :class:`MetricPoint` series.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WrennNotFoundError: If the capsule does not exist or has been
|
||||||
|
destroyed.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
m = wrenn.capsules.metrics("sb-abc123", range="2h")
|
||||||
|
for point in m.points:
|
||||||
|
print(point.timestamp_unix, point.cpu_pct, point.mem_bytes)
|
||||||
|
"""
|
||||||
|
resp = self._http.get(f"/v1/capsules/{id}/metrics", params=_range_params(range))
|
||||||
|
return CapsuleMetrics.model_validate(handle_response(resp))
|
||||||
|
|
||||||
|
|
||||||
|
class EventsResource:
|
||||||
|
"""Sync server-sent event stream of capsule/template/host lifecycle events."""
|
||||||
|
|
||||||
|
def __init__(self, http: httpx.Client) -> None:
|
||||||
|
self._http = http
|
||||||
|
|
||||||
|
def stream(self) -> Iterator[SSEEvent]:
|
||||||
|
"""Stream lifecycle events for the team in real time.
|
||||||
|
|
||||||
|
The connection is held open by the server; iterate the result to
|
||||||
|
receive :class:`SSEEvent` payloads as they arrive. Close the
|
||||||
|
iterator (or break out of the loop) to disconnect.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
SSEEvent: One event per server frame.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
with WrennClient() as wrenn:
|
||||||
|
for ev in wrenn.events.stream():
|
||||||
|
print(ev.event, ev.resource)
|
||||||
|
"""
|
||||||
|
with self._http.stream("GET", "/v1/events/stream", timeout=None) as resp:
|
||||||
|
_raise_for_status(resp)
|
||||||
|
yield from _iter_sse_events(resp.iter_lines())
|
||||||
|
|
||||||
|
|
||||||
class AsyncCapsulesResource:
|
class AsyncCapsulesResource:
|
||||||
"""Async capsule control-plane operations."""
|
"""Async capsule control-plane operations."""
|
||||||
@ -370,6 +514,116 @@ class AsyncCapsulesResource:
|
|||||||
resp = await self._http.post(f"/v1/capsules/{id}/ping")
|
resp = await self._http.post(f"/v1/capsules/{id}/ping")
|
||||||
handle_response(resp)
|
handle_response(resp)
|
||||||
|
|
||||||
|
async def stats(self, range: str | None = None) -> CapsuleStats:
|
||||||
|
"""Get aggregate capsule usage stats for the authenticated team.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
range (str | None): Time window. One of ``5m``, ``1h``, ``6h``,
|
||||||
|
``24h``, ``30d``. Defaults to ``1h`` server-side.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CapsuleStats: Current running counts plus 30-day peaks and a
|
||||||
|
chart-ready time series.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
stats = await wrenn.capsules.stats(range="24h")
|
||||||
|
print(stats.current.running_count, stats.peaks.vcpus)
|
||||||
|
"""
|
||||||
|
resp = await self._http.get("/v1/capsules/stats", params=_range_params(range))
|
||||||
|
return CapsuleStats.model_validate(handle_response(resp))
|
||||||
|
|
||||||
|
async def usage(
|
||||||
|
self,
|
||||||
|
from_: str | date | None = None,
|
||||||
|
to: str | date | None = None,
|
||||||
|
) -> UsageResponse:
|
||||||
|
"""Get per-day CPU and RAM usage for the team.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
from_ (str | date | None): Start date (``YYYY-MM-DD`` string or
|
||||||
|
``date``). Defaults to 30 days ago server-side.
|
||||||
|
to (str | date | None): End date. Defaults to today.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UsageResponse: Daily ``cpu_minutes`` / ``ram_mb_minutes`` points.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
usage = await wrenn.capsules.usage(
|
||||||
|
from_=today - timedelta(days=7), to=today
|
||||||
|
)
|
||||||
|
for point in usage.points:
|
||||||
|
print(point.date, point.cpu_minutes, point.ram_mb_minutes)
|
||||||
|
"""
|
||||||
|
resp = await self._http.get(
|
||||||
|
"/v1/capsules/usage", params=_usage_params(from_, to)
|
||||||
|
)
|
||||||
|
return UsageResponse.model_validate(handle_response(resp))
|
||||||
|
|
||||||
|
async def metrics(self, id: str, range: str | None = None) -> CapsuleMetrics:
|
||||||
|
"""Get time-series CPU, memory, and disk metrics for a capsule.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id (str): Capsule ID.
|
||||||
|
range (str | None): One of ``10m`` (500ms samples),
|
||||||
|
``2h`` (30s averages), ``24h`` (5-minute averages). Defaults
|
||||||
|
to ``10m`` server-side.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CapsuleMetrics: Sampled :class:`MetricPoint` series.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WrennNotFoundError: If the capsule does not exist or has been
|
||||||
|
destroyed.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
m = await wrenn.capsules.metrics("sb-abc123", range="2h")
|
||||||
|
for point in m.points:
|
||||||
|
print(point.timestamp_unix, point.cpu_pct, point.mem_bytes)
|
||||||
|
"""
|
||||||
|
resp = await self._http.get(
|
||||||
|
f"/v1/capsules/{id}/metrics", params=_range_params(range)
|
||||||
|
)
|
||||||
|
return CapsuleMetrics.model_validate(handle_response(resp))
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncEventsResource:
|
||||||
|
"""Async server-sent event stream of capsule/template/host lifecycle events."""
|
||||||
|
|
||||||
|
def __init__(self, http: httpx.AsyncClient) -> None:
|
||||||
|
self._http = http
|
||||||
|
|
||||||
|
async def stream(self) -> AsyncIterator[SSEEvent]:
|
||||||
|
"""Stream lifecycle events for the team in real time.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
SSEEvent: One event per server frame.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
async with AsyncWrennClient() as wrenn:
|
||||||
|
async for ev in wrenn.events.stream():
|
||||||
|
print(ev.event, ev.resource)
|
||||||
|
"""
|
||||||
|
async with self._http.stream("GET", "/v1/events/stream", timeout=None) as resp:
|
||||||
|
_raise_for_status(resp)
|
||||||
|
data_lines: list[str] = []
|
||||||
|
async for raw in resp.aiter_lines():
|
||||||
|
if raw == "":
|
||||||
|
if data_lines:
|
||||||
|
yield SSEEvent.model_validate_json("\n".join(data_lines))
|
||||||
|
data_lines = []
|
||||||
|
continue
|
||||||
|
if raw.startswith(":"):
|
||||||
|
continue
|
||||||
|
if raw.startswith("data:"):
|
||||||
|
data_lines.append(raw[5:].lstrip())
|
||||||
|
|
||||||
|
|
||||||
class SnapshotsResource:
|
class SnapshotsResource:
|
||||||
"""Sync snapshot operations."""
|
"""Sync snapshot operations."""
|
||||||
@ -486,7 +740,11 @@ class AsyncSnapshotsResource:
|
|||||||
class WrennClient:
|
class WrennClient:
|
||||||
"""Synchronous client for the Wrenn API.
|
"""Synchronous client for the Wrenn API.
|
||||||
|
|
||||||
Authenticates with an API key.
|
Authenticates with an API key. Exposes three resources:
|
||||||
|
|
||||||
|
- :attr:`capsules` — capsule lifecycle, stats, usage, metrics
|
||||||
|
- :attr:`snapshots` — template snapshot management
|
||||||
|
- :attr:`events` — server-sent lifecycle event stream
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var.
|
api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var.
|
||||||
@ -497,6 +755,15 @@ class WrennClient:
|
|||||||
is the default ``app.wrenn.dev`` host, else the ``base_url`` host.
|
is the default ``app.wrenn.dev`` host, else the ``base_url`` host.
|
||||||
timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
|
timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
|
||||||
or ``None`` for the default (30s read/write/pool, 10s connect).
|
or ``None`` for the default (30s read/write/pool, 10s connect).
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
from wrenn import WrennClient
|
||||||
|
|
||||||
|
with WrennClient() as wrenn: # reads WRENN_API_KEY
|
||||||
|
capsule = wrenn.capsules.create(template="minimal-ubuntu")
|
||||||
|
print(capsule.id, capsule.status)
|
||||||
|
wrenn.capsules.destroy(capsule.id)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -517,6 +784,7 @@ class WrennClient:
|
|||||||
|
|
||||||
self.capsules = CapsulesResource(self._http)
|
self.capsules = CapsulesResource(self._http)
|
||||||
self.snapshots = SnapshotsResource(self._http)
|
self.snapshots = SnapshotsResource(self._http)
|
||||||
|
self.events = EventsResource(self._http)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def http(self) -> httpx.Client:
|
def http(self) -> httpx.Client:
|
||||||
@ -542,7 +810,8 @@ class WrennClient:
|
|||||||
class AsyncWrennClient:
|
class AsyncWrennClient:
|
||||||
"""Asynchronous client for the Wrenn API.
|
"""Asynchronous client for the Wrenn API.
|
||||||
|
|
||||||
Authenticates with an API key.
|
Authenticates with an API key. Mirrors :class:`WrennClient` with
|
||||||
|
``await``-able methods on every resource.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var.
|
api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var.
|
||||||
@ -553,6 +822,16 @@ class AsyncWrennClient:
|
|||||||
is the default ``app.wrenn.dev`` host, else the ``base_url`` host.
|
is the default ``app.wrenn.dev`` host, else the ``base_url`` host.
|
||||||
timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
|
timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds),
|
||||||
or ``None`` for the default (30s read/write/pool, 10s connect).
|
or ``None`` for the default (30s read/write/pool, 10s connect).
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
from wrenn import AsyncWrennClient
|
||||||
|
|
||||||
|
async with AsyncWrennClient() as wrenn:
|
||||||
|
capsule = await wrenn.capsules.create(template="minimal-ubuntu")
|
||||||
|
async for event in wrenn.events.stream():
|
||||||
|
if event.resource and event.resource.id == capsule.id:
|
||||||
|
break
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -573,6 +852,7 @@ class AsyncWrennClient:
|
|||||||
|
|
||||||
self.capsules = AsyncCapsulesResource(self._http)
|
self.capsules = AsyncCapsulesResource(self._http)
|
||||||
self.snapshots = AsyncSnapshotsResource(self._http)
|
self.snapshots = AsyncSnapshotsResource(self._http)
|
||||||
|
self.events = AsyncEventsResource(self._http)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def http(self) -> httpx.AsyncClient:
|
def http(self) -> httpx.AsyncClient:
|
||||||
|
|||||||
@ -1,17 +1,31 @@
|
|||||||
from wrenn.models._generated import (
|
from wrenn.models._generated import (
|
||||||
|
Actor,
|
||||||
Capsule,
|
Capsule,
|
||||||
|
CapsuleMetrics,
|
||||||
|
CapsuleStats,
|
||||||
FileEntry,
|
FileEntry,
|
||||||
ListDirResponse,
|
ListDirResponse,
|
||||||
MakeDirResponse,
|
MakeDirResponse,
|
||||||
|
MetricPoint,
|
||||||
|
Resource,
|
||||||
|
SSEEvent,
|
||||||
Status,
|
Status,
|
||||||
Template,
|
Template,
|
||||||
|
UsageResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"Actor",
|
||||||
"Capsule",
|
"Capsule",
|
||||||
|
"CapsuleMetrics",
|
||||||
|
"CapsuleStats",
|
||||||
"FileEntry",
|
"FileEntry",
|
||||||
"ListDirResponse",
|
"ListDirResponse",
|
||||||
"MakeDirResponse",
|
"MakeDirResponse",
|
||||||
|
"MetricPoint",
|
||||||
|
"Resource",
|
||||||
|
"SSEEvent",
|
||||||
"Status",
|
"Status",
|
||||||
"Template",
|
"Template",
|
||||||
|
"UsageResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,13 +1,91 @@
|
|||||||
# generated by datamodel-codegen:
|
# generated by datamodel-codegen:
|
||||||
# filename: openapi.yaml
|
# filename: openapi.yaml
|
||||||
# timestamp: 2026-05-23T11:20:02+00:00
|
# timestamp: 2026-06-22T19:04:03+00:00
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from pydantic import AwareDatetime, BaseModel, Field
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
from pydantic import AwareDatetime, BaseModel, Field
|
||||||
|
from datetime import date as date_aliased
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class CreateCapsuleRequest(BaseModel):
|
||||||
|
template: str | None = "minimal-ubuntu"
|
||||||
|
vcpus: int | None = 1
|
||||||
|
memory_mb: int | None = 512
|
||||||
|
timeout_sec: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(
|
||||||
|
description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause. Positive values below 60 are silently clamped to 60 (the agent's startup envelope).\n",
|
||||||
|
ge=0,
|
||||||
|
),
|
||||||
|
] = 0
|
||||||
|
metadata: Annotated[
|
||||||
|
dict[str, str] | None,
|
||||||
|
Field(
|
||||||
|
description="Optional user-supplied key/value labels attached to the capsule. Max 20 keys; keys match [a-zA-Z0-9][a-zA-Z0-9._-]{0,63}; values are at most 64 characters. Reserved system keys (kernel_version, vmm_version, agent_version, envd_version) are rejected. Metadata is set at create-time only.\n"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Point(BaseModel):
|
||||||
|
date: date_aliased | None = None
|
||||||
|
cpu_minutes: float | None = None
|
||||||
|
ram_mb_minutes: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UsageResponse(BaseModel):
|
||||||
|
from_: Annotated[date_aliased | None, Field(alias="from")] = None
|
||||||
|
to: date_aliased | None = None
|
||||||
|
points: list[Point] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Range(StrEnum):
|
||||||
|
field_5m = "5m"
|
||||||
|
field_1h = "1h"
|
||||||
|
field_6h = "6h"
|
||||||
|
field_24h = "24h"
|
||||||
|
field_30d = "30d"
|
||||||
|
|
||||||
|
|
||||||
|
class Current(BaseModel):
|
||||||
|
running_count: int | None = None
|
||||||
|
vcpus_reserved: int | None = None
|
||||||
|
memory_mb_reserved: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Peaks(BaseModel):
|
||||||
|
"""
|
||||||
|
Maximum values over the last 30 days.
|
||||||
|
"""
|
||||||
|
|
||||||
|
running_count: int | None = None
|
||||||
|
vcpus: int | None = None
|
||||||
|
memory_mb: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Series(BaseModel):
|
||||||
|
"""
|
||||||
|
Parallel arrays for chart rendering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
labels: list[AwareDatetime] | None = None
|
||||||
|
running: list[int] | None = None
|
||||||
|
vcpus: list[int] | None = None
|
||||||
|
memory_mb: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapsuleStats(BaseModel):
|
||||||
|
range: Range | None = None
|
||||||
|
current: Current | None = None
|
||||||
|
peaks: Annotated[
|
||||||
|
Peaks | None, Field(description="Maximum values over the last 30 days.")
|
||||||
|
] = None
|
||||||
|
series: Annotated[
|
||||||
|
Series | None, Field(description="Parallel arrays for chart rendering.")
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
class Status(StrEnum):
|
class Status(StrEnum):
|
||||||
pending = "pending"
|
pending = "pending"
|
||||||
starting = "starting"
|
starting = "starting"
|
||||||
@ -37,7 +115,7 @@ class Capsule(BaseModel):
|
|||||||
metadata: Annotated[
|
metadata: Annotated[
|
||||||
dict[str, str] | None,
|
dict[str, str] | None,
|
||||||
Field(
|
Field(
|
||||||
description="Free-form key/value labels attached at create-time. Also carries\nagent-side version info (kernel_version, vmm_version,\nagent_version, envd_version) when running.\n"
|
description="User-supplied key/value labels attached at create-time. Once the\ncapsule is running this also carries agent-side system version info\n(kernel_version, vmm_version, agent_version, envd_version); those\nreserved keys are owned by the system and cannot be set by users.\n"
|
||||||
),
|
),
|
||||||
] = None
|
] = None
|
||||||
disk_size_mb: Annotated[
|
disk_size_mb: Annotated[
|
||||||
@ -51,6 +129,16 @@ class Capsule(BaseModel):
|
|||||||
] = None
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateSnapshotRequest(BaseModel):
|
||||||
|
sandbox_id: Annotated[
|
||||||
|
str, Field(description="ID of the running capsule to snapshot.")
|
||||||
|
]
|
||||||
|
name: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Name for the snapshot template. Auto-generated if omitted."),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
class Type(StrEnum):
|
class Type(StrEnum):
|
||||||
base = "base"
|
base = "base"
|
||||||
snapshot = "snapshot"
|
snapshot = "snapshot"
|
||||||
@ -78,7 +166,96 @@ class Template(BaseModel):
|
|||||||
metadata: dict[str, str] | None = None
|
metadata: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class Type2(StrEnum):
|
class ExecRequest(BaseModel):
|
||||||
|
cmd: str
|
||||||
|
args: list[str] | None = None
|
||||||
|
timeout_sec: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(description="Timeout in seconds (foreground exec only, default 30)"),
|
||||||
|
] = 30
|
||||||
|
background: Annotated[
|
||||||
|
bool | None,
|
||||||
|
Field(
|
||||||
|
description="If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202)"
|
||||||
|
),
|
||||||
|
] = False
|
||||||
|
tag: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true."
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
envs: Annotated[
|
||||||
|
dict[str, str] | None,
|
||||||
|
Field(
|
||||||
|
description="Environment variables for the process (applies to both foreground and background exec)"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
cwd: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="Working directory for the process (applies to both foreground and background exec)"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundExecResponse(BaseModel):
|
||||||
|
sandbox_id: str | None = None
|
||||||
|
cmd: str | None = None
|
||||||
|
pid: int | None = None
|
||||||
|
tag: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessEntry(BaseModel):
|
||||||
|
pid: int | None = None
|
||||||
|
tag: str | None = None
|
||||||
|
cmd: str | None = None
|
||||||
|
args: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessListResponse(BaseModel):
|
||||||
|
processes: list[ProcessEntry] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Encoding(StrEnum):
|
||||||
|
"""
|
||||||
|
Output encoding. "base64" when stdout/stderr contain binary data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
utf_8 = "utf-8"
|
||||||
|
base64 = "base64"
|
||||||
|
|
||||||
|
|
||||||
|
class ExecResponse(BaseModel):
|
||||||
|
sandbox_id: str | None = None
|
||||||
|
cmd: str | None = None
|
||||||
|
stdout: str | None = None
|
||||||
|
stderr: str | None = None
|
||||||
|
exit_code: int | None = None
|
||||||
|
duration_ms: int | None = None
|
||||||
|
encoding: Annotated[
|
||||||
|
Encoding | None,
|
||||||
|
Field(
|
||||||
|
description='Output encoding. "base64" when stdout/stderr contain binary data.'
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReadFileRequest(BaseModel):
|
||||||
|
path: Annotated[str, Field(description="Absolute file path inside the capsule")]
|
||||||
|
|
||||||
|
|
||||||
|
class ListDirRequest(BaseModel):
|
||||||
|
path: Annotated[str, Field(description="Directory path inside the capsule")]
|
||||||
|
depth: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(
|
||||||
|
description="Recursion depth (0 = non-recursive, 1 = immediate children)"
|
||||||
|
),
|
||||||
|
] = 1
|
||||||
|
|
||||||
|
|
||||||
|
class Type1(StrEnum):
|
||||||
file = "file"
|
file = "file"
|
||||||
directory = "directory"
|
directory = "directory"
|
||||||
symlink = "symlink"
|
symlink = "symlink"
|
||||||
@ -87,7 +264,7 @@ class Type2(StrEnum):
|
|||||||
class FileEntry(BaseModel):
|
class FileEntry(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
path: str | None = None
|
path: str | None = None
|
||||||
type: Type2 | None = None
|
type: Type1 | None = None
|
||||||
size: int | None = None
|
size: int | None = None
|
||||||
mode: int | None = None
|
mode: int | None = None
|
||||||
permissions: Annotated[
|
permissions: Annotated[
|
||||||
@ -101,9 +278,137 @@ class FileEntry(BaseModel):
|
|||||||
symlink_target: str | None = None
|
symlink_target: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MakeDirRequest(BaseModel):
|
||||||
|
path: Annotated[
|
||||||
|
str, Field(description="Directory path to create inside the capsule")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class MakeDirResponse(BaseModel):
|
class MakeDirResponse(BaseModel):
|
||||||
entry: FileEntry | None = None
|
entry: FileEntry | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RemoveRequest(BaseModel):
|
||||||
|
path: Annotated[str, Field(description="Path to remove inside the capsule")]
|
||||||
|
|
||||||
|
|
||||||
|
class Range1(StrEnum):
|
||||||
|
field_5m = "5m"
|
||||||
|
field_10m = "10m"
|
||||||
|
field_1h = "1h"
|
||||||
|
field_2h = "2h"
|
||||||
|
field_6h = "6h"
|
||||||
|
field_12h = "12h"
|
||||||
|
field_24h = "24h"
|
||||||
|
|
||||||
|
|
||||||
|
class MetricPoint(BaseModel):
|
||||||
|
timestamp_unix: int | None = None
|
||||||
|
cpu_pct: Annotated[
|
||||||
|
float | None,
|
||||||
|
Field(
|
||||||
|
description="CPU utilization percentage (0-100), normalized to vCPU count"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
mem_bytes: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(
|
||||||
|
description="Resident memory in bytes (VmRSS of Cloud Hypervisor process)"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
disk_bytes: Annotated[
|
||||||
|
int | None, Field(description="Allocated disk bytes for the CoW sparse file")
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Error1(BaseModel):
|
||||||
|
code: str | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Error(BaseModel):
|
||||||
|
error: Error1 | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Event(StrEnum):
|
||||||
|
connected = "connected"
|
||||||
|
capsule_create = "capsule.create"
|
||||||
|
capsule_pause = "capsule.pause"
|
||||||
|
capsule_resume = "capsule.resume"
|
||||||
|
capsule_destroy = "capsule.destroy"
|
||||||
|
capsule_state_changed = "capsule.state.changed"
|
||||||
|
template_snapshot_create = "template.snapshot.create"
|
||||||
|
template_snapshot_delete = "template.snapshot.delete"
|
||||||
|
host_up = "host.up"
|
||||||
|
host_down = "host.down"
|
||||||
|
|
||||||
|
|
||||||
|
class Outcome(StrEnum):
|
||||||
|
"""
|
||||||
|
Present for action events (capsule.* except state.changed,
|
||||||
|
template.snapshot.*). Absent for host.up/down, capsule.state.changed,
|
||||||
|
and the connected sentinel.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
success = "success"
|
||||||
|
error = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class Resource(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Type2(StrEnum):
|
||||||
|
user = "user"
|
||||||
|
api_key = "api_key"
|
||||||
|
system = "system"
|
||||||
|
|
||||||
|
|
||||||
|
class Actor(BaseModel):
|
||||||
|
type: Type2 | None = None
|
||||||
|
id: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SSEEvent(BaseModel):
|
||||||
|
"""
|
||||||
|
Wire format of one SSE message body. The event name (`event:` line) is
|
||||||
|
the `kind` and the JSON below is the `data:` line.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
event: Event | None = None
|
||||||
|
outcome: Annotated[
|
||||||
|
Outcome | None,
|
||||||
|
Field(
|
||||||
|
description="Present for action events (capsule.* except state.changed,\ntemplate.snapshot.*). Absent for host.up/down, capsule.state.changed,\nand the connected sentinel.\n"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
resource: Resource | None = None
|
||||||
|
actor: Actor | None = None
|
||||||
|
metadata: Annotated[
|
||||||
|
dict[str, str] | None,
|
||||||
|
Field(
|
||||||
|
description="Event-specific context. Examples: `reason` (ttl_expired,\nhost_failure, cleanup_after_create_error, orphaned),\n`host_ip`, `from`/`to` (for capsule.state.changed).\n"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
error: Annotated[
|
||||||
|
str | None, Field(description="Failure reason; only set when outcome=error.")
|
||||||
|
] = None
|
||||||
|
sandbox: Annotated[
|
||||||
|
Capsule | None,
|
||||||
|
Field(description="Populated for capsule.* events; null if DB lookup failed."),
|
||||||
|
] = None
|
||||||
|
timestamp: AwareDatetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class ListDirResponse(BaseModel):
|
class ListDirResponse(BaseModel):
|
||||||
entries: list[FileEntry] | None = None
|
entries: list[FileEntry] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapsuleMetrics(BaseModel):
|
||||||
|
sandbox_id: str | None = None
|
||||||
|
range: Range1 | None = None
|
||||||
|
points: list[MetricPoint] | None = None
|
||||||
|
|||||||
@ -103,6 +103,73 @@ class TestCapsules:
|
|||||||
client.capsules.ping("sb-1")
|
client.capsules.ping("sb-1")
|
||||||
assert route.called
|
assert route.called
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_stats(self, client):
|
||||||
|
route = respx.get(f"{BASE}/v1/capsules/stats").respond(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"range": "6h",
|
||||||
|
"current": {"running_count": 2, "vcpus_reserved": 4},
|
||||||
|
"peaks": {"running_count": 9},
|
||||||
|
"series": {"running": [1, 2, 2]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
stats = client.capsules.stats(range="6h")
|
||||||
|
assert "range=6h" in str(route.calls[0].request.url)
|
||||||
|
assert stats.current and stats.current.running_count == 2
|
||||||
|
assert stats.peaks and stats.peaks.running_count == 9
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_usage_passes_date_params(self, client):
|
||||||
|
import datetime as _dt
|
||||||
|
|
||||||
|
route = respx.get(f"{BASE}/v1/capsules/usage").respond(
|
||||||
|
200, json={"from": "2026-06-01", "to": "2026-06-22", "points": []}
|
||||||
|
)
|
||||||
|
client.capsules.usage(from_=_dt.date(2026, 6, 1), to="2026-06-22")
|
||||||
|
url = str(route.calls[0].request.url)
|
||||||
|
assert "from=2026-06-01" in url
|
||||||
|
assert "to=2026-06-22" in url
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_metrics(self, client):
|
||||||
|
respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"sandbox_id": "sb-1",
|
||||||
|
"range": "10m",
|
||||||
|
"points": [{"timestamp_unix": 1, "cpu_pct": 12.5, "mem_bytes": 4096}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
m = client.capsules.metrics("sb-1")
|
||||||
|
assert m.sandbox_id == "sb-1"
|
||||||
|
assert m.points and m.points[0].cpu_pct == 12.5
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvents:
|
||||||
|
@respx.mock
|
||||||
|
def test_stream_parses_sse_frames(self, client):
|
||||||
|
body = (
|
||||||
|
": keepalive\n"
|
||||||
|
"event: capsule.create\n"
|
||||||
|
'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||||
|
"\n"
|
||||||
|
"event: capsule.destroy\n"
|
||||||
|
'data: {"event":"capsule.destroy","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "text/event-stream"},
|
||||||
|
content=body,
|
||||||
|
)
|
||||||
|
events = list(client.events.stream())
|
||||||
|
assert [e.event.value for e in events] == [
|
||||||
|
"capsule.create",
|
||||||
|
"capsule.destroy",
|
||||||
|
]
|
||||||
|
assert events[0].resource and events[0].resource.id == "sb-1"
|
||||||
|
|
||||||
|
|
||||||
class TestSnapshots:
|
class TestSnapshots:
|
||||||
@respx.mock
|
@respx.mock
|
||||||
|
|||||||
28
uv.lock
generated
28
uv.lock
generated
@ -285,15 +285,6 @@ 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" },
|
{ 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"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "docspec"
|
name = "docspec"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@ -328,19 +319,6 @@ version = "0.11"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/ce/5d6a3782b9f88097ce3e579265015db3372ae78d12f67629b863a9208c96/docstring_parser-0.11.tar.gz", hash = "sha256:93b3f8f481c7d24e37c5d9f30293c89e2933fa209421c8abd731dd3ef0715ecb", size = 22775, upload-time = "2021-09-30T07:44:10.288Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/ce/5d6a3782b9f88097ce3e579265015db3372ae78d12f67629b863a9208c96/docstring_parser-0.11.tar.gz", hash = "sha256:93b3f8f481c7d24e37c5d9f30293c89e2933fa209421c8abd731dd3ef0715ecb", size = 22775, upload-time = "2021-09-30T07:44:10.288Z" }
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "email-validator"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "dnspython" },
|
|
||||||
{ name = "idna" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
|
||||||
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]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.29.0"
|
version = "3.29.0"
|
||||||
@ -1166,11 +1144,10 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wrenn"
|
name = "wrenn"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
{ name = "email-validator" },
|
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "httpx-ws" },
|
{ name = "httpx-ws" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
@ -1184,6 +1161,7 @@ dev = [
|
|||||||
{ name = "pydoc-markdown" },
|
{ name = "pydoc-markdown" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
{ name = "respx" },
|
{ name = "respx" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
@ -1191,7 +1169,6 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "certifi", specifier = ">=2026.2.25" },
|
{ name = "certifi", specifier = ">=2026.2.25" },
|
||||||
{ name = "email-validator", specifier = ">=2.3.0" },
|
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "httpx-ws", specifier = ">=0.9.0" },
|
{ name = "httpx-ws", specifier = ">=0.9.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.5" },
|
{ name = "pydantic", specifier = ">=2.12.5" },
|
||||||
@ -1205,6 +1182,7 @@ dev = [
|
|||||||
{ 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" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||||
{ name = "respx", specifier = ">=0.23.1" },
|
{ name = "respx", specifier = ">=0.23.1" },
|
||||||
{ name = "ruff", specifier = ">=0.15.10" },
|
{ name = "ruff", specifier = ">=0.15.10" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user