Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad454cd909 | |||
| d8ff68f014 | |||
| 45f7f467c5 | |||
| 99cd25bd29 | |||
| f5e510868b | |||
| f3fb5a59c4 | |||
| 875af78e7a | |||
| 03cd2227b5 | |||
| 4924237a23 | |||
| a2d9aac78b | |||
| 4dcbc73003 | |||
| 7b04ba3949 | |||
| de72dfe9c8 | |||
| 27cfe42b3c | |||
| f218bdd63e | |||
| 8bec8ed2c0 | |||
| 9141f2c703 | |||
| 08314b172b | |||
| 98028bab52 | |||
| 7291dbe669 | |||
| 8a62b6207c | |||
| db48e3cfbf | |||
| fbcedc5317 | |||
| 005871441a | |||
| b2ec7f9ab3 | |||
| 9edde7bff5 | |||
| 369c75af24 | |||
| 41ee41e9cd | |||
| fce514c49c | |||
| 87cc16e9e2 | |||
| 08f6a1ab84 | |||
| 51c6987515 | |||
| e057ec2407 | |||
| e5e4e1a85b | |||
| 6112c71abc | |||
| d9c028564e | |||
| 06b4a8cbcb | |||
| 04e5dc652f | |||
| 4a7db8e204 | |||
| a76be96682 | |||
| dc66ac24d5 | |||
| b5e2b12ef1 | |||
| 213af4aee7 | |||
| aa9477ffe8 | |||
| 2bb3dbd71d | |||
| 3f26a2fbcf | |||
| 2faf0dd0ae | |||
| 68c7d0de42 | |||
| ad64c85393 | |||
| bab53aedbe | |||
| 82e181dd7e | |||
| ee1f55635f | |||
| 6bdf28e2ae | |||
| 61bc040098 | |||
| 7b35ffb60c | |||
| 42bcc792d6 | |||
| 3f97c73b2f | |||
| 7e7ecbd48a | |||
| 7b9a06d1b5 | |||
| 3d0eda5c60 | |||
| eecf1dc65b | |||
| 3cced768a4 | |||
| 0ac9bf79ee | |||
| bf5914c0a8 | |||
| 976af9a209 | |||
| f3fd6865f9 | |||
| 340ed46df6 | |||
| a5bf66c199 | |||
| f51a962fff |
@ -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
|
||||||
|
|||||||
36
README.md
36
README.md
@ -172,6 +172,8 @@ import sys
|
|||||||
# Stream a new command
|
# Stream a new command
|
||||||
for event in capsule.commands.stream("python", args=["-u", "train.py"]):
|
for event in capsule.commands.stream("python", args=["-u", "train.py"]):
|
||||||
match event.type:
|
match event.type:
|
||||||
|
case "start":
|
||||||
|
print(f"PID: {event.pid}")
|
||||||
case "stdout":
|
case "stdout":
|
||||||
print(event.data, end="")
|
print(event.data, end="")
|
||||||
case "stderr":
|
case "stderr":
|
||||||
@ -181,7 +183,10 @@ for event in capsule.commands.stream("python", args=["-u", "train.py"]):
|
|||||||
|
|
||||||
# Connect to a running background process
|
# Connect to a running background process
|
||||||
for event in capsule.commands.connect(handle.pid):
|
for event in capsule.commands.connect(handle.pid):
|
||||||
if event.type == "stdout":
|
match event.type:
|
||||||
|
case "start":
|
||||||
|
print(f"PID: {event.pid}")
|
||||||
|
case "stdout":
|
||||||
print(event.data, end="")
|
print(event.data, end="")
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -211,6 +216,7 @@ capsule.files.exists("/app/main.py") # True
|
|||||||
|
|
||||||
# List directory
|
# List directory
|
||||||
entries = capsule.files.list("/home/user", depth=1)
|
entries = capsule.files.list("/home/user", depth=1)
|
||||||
|
# FileEntry has: name, type (file/dir), size, modified_at
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
print(entry.name, entry.type, entry.size)
|
print(entry.name, entry.type, entry.size)
|
||||||
|
|
||||||
@ -289,8 +295,27 @@ value = capsule.git.get_config("user.name", cwd="/app") # str | None
|
|||||||
|
|
||||||
capsule.git.remote_add("upstream", "https://github.com/org/repo.git", cwd="/app")
|
capsule.git.remote_add("upstream", "https://github.com/org/repo.git", cwd="/app")
|
||||||
url = capsule.git.remote_get("origin", cwd="/app") # str | None
|
url = capsule.git.remote_get("origin", cwd="/app") # str | None
|
||||||
|
|
||||||
|
# Reset and restore
|
||||||
|
capsule.git.reset(mode="hard", ref="HEAD~1", cwd="/app")
|
||||||
|
capsule.git.restore(["file.txt"], staged=True, cwd="/app")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Persistent Credential Store
|
||||||
|
|
||||||
|
For workflows that need repeated authenticated operations, you can persist credentials via the git credential store:
|
||||||
|
|
||||||
|
```python
|
||||||
|
capsule.git.dangerously_authenticate(
|
||||||
|
username="user",
|
||||||
|
password="ghp_token",
|
||||||
|
host="github.com",
|
||||||
|
protocol="https",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Warning:** Credentials are written in plaintext inside the capsule and are accessible to any process running there. Prefer per-operation `username`/`password` on `clone`, `push`, and `pull` instead.
|
||||||
|
|
||||||
Git errors raise `GitCommandError` (or `GitAuthError` for authentication failures), both inheriting from `GitError`:
|
Git errors raise `GitCommandError` (or `GitAuthError` for authentication failures), both inheriting from `GitError`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@ -308,7 +333,7 @@ except GitAuthError as e:
|
|||||||
```python
|
```python
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
with capsule.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term:
|
with capsule.pty(cmd="/bin/bash", cols=80, rows=24, cwd="/home/user") as term:
|
||||||
term.write(b"ls -la\n")
|
term.write(b"ls -la\n")
|
||||||
for event in term:
|
for event in term:
|
||||||
if event.type == "output":
|
if event.type == "output":
|
||||||
@ -451,9 +476,10 @@ result = capsule.run_code("print('running on custom template')")
|
|||||||
| `logs` | `Logs` | `.stdout: list[str]` and `.stderr: list[str]` chunks |
|
| `logs` | `Logs` | `.stdout: list[str]` and `.stderr: list[str]` chunks |
|
||||||
| `error` | `ExecutionError \| None` | `.name`, `.value`, `.traceback` |
|
| `error` | `ExecutionError \| None` | `.name`, `.value`, `.traceback` |
|
||||||
| `execution_count` | `int \| None` | Jupyter cell execution counter |
|
| `execution_count` | `int \| None` | Jupyter cell execution counter |
|
||||||
|
| `timed_out` | `bool` | ``True`` when execution was cut short by the timeout |
|
||||||
| `text` | `str \| None` | (property) `text/plain` of the main `execute_result` |
|
| `text` | `str \| None` | (property) `text/plain` of the main `execute_result` |
|
||||||
|
|
||||||
Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `pdf`, `latex`, `json`, `javascript`, plus `extra` for unknown types. The `text` field is Jupyter's `text/plain` bundle verbatim — the Python `repr()` of the cell's last expression. So `run_code("'hi'").text` is `"'hi'"` (with quotes), and `run_code("42").text` is `"42"`. This preserves the distinction between the string `'2'` and the int `2`.
|
Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `gif`, `pdf`, `latex`, `json`, `javascript`, `plotly`, plus `extra` for unknown types. The `text` field is Jupyter's `text/plain` bundle verbatim — the Python `repr()` of the cell's last expression. So `run_code("'hi'").text` is `"'hi'"` (with quotes), and `run_code("42").text` is `"42"`. This preserves the distinction between the string `'2'` and the int `2`.
|
||||||
|
|
||||||
### Code Runner + Commands/Files
|
### Code Runner + Commands/Files
|
||||||
|
|
||||||
@ -533,8 +559,8 @@ from wrenn import (
|
|||||||
WrennNotFoundError, # 404
|
WrennNotFoundError, # 404
|
||||||
WrennConflictError, # 409
|
WrennConflictError, # 409
|
||||||
WrennHostHasCapsulesError, # 409 (host has running capsules)
|
WrennHostHasCapsulesError, # 409 (host has running capsules)
|
||||||
WrennAgentError, # 502
|
|
||||||
WrennInternalError, # 500
|
WrennInternalError, # 500
|
||||||
|
WrennAgentError, # 502
|
||||||
WrennHostUnavailableError, # 503
|
WrennHostUnavailableError, # 503
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -603,7 +629,7 @@ with WrennClient(api_key="wrn_...") as client:
|
|||||||
|
|
||||||
# Snapshots
|
# Snapshots
|
||||||
template = client.snapshots.create(capsule_id="cl-abc", name="my-snap")
|
template = client.snapshots.create(capsule_id="cl-abc", name="my-snap")
|
||||||
templates = client.snapshots.list()
|
templates = client.snapshots.list(type="custom") # optional type filter
|
||||||
client.snapshots.delete("my-snap")
|
client.snapshots.delete("my-snap")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
3563
api/openapi.yaml
3563
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.1.4"
|
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,106 @@ 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:
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
resp.read()
|
||||||
|
_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 +516,118 @@ 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:
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
await resp.aread()
|
||||||
|
_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 +744,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 +759,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 +788,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 +814,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 +826,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 +856,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:
|
||||||
|
|||||||
@ -120,8 +120,7 @@ def _build_exec_payload(
|
|||||||
tag: str | None,
|
tag: str | None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
payload: dict = {
|
payload: dict = {
|
||||||
"cmd": "/bin/sh",
|
"cmd": cmd,
|
||||||
"args": ["-c", cmd],
|
|
||||||
"background": background,
|
"background": background,
|
||||||
}
|
}
|
||||||
if timeout is not None and not background:
|
if timeout is not None and not background:
|
||||||
@ -156,7 +155,7 @@ def _decode_exec_run(
|
|||||||
def _build_stream_start(cmd: str, args: builtins.list[str] | None) -> dict:
|
def _build_stream_start(cmd: str, args: builtins.list[str] | None) -> dict:
|
||||||
if args:
|
if args:
|
||||||
return {"type": "start", "cmd": cmd, "args": args}
|
return {"type": "start", "cmd": cmd, "args": args}
|
||||||
return {"type": "start", "cmd": "/bin/sh", "args": ["-c", cmd]}
|
return {"type": "start", "cmd": cmd}
|
||||||
|
|
||||||
|
|
||||||
def _decode_exec_response(data: dict) -> CommandResult:
|
def _decode_exec_response(data: dict) -> CommandResult:
|
||||||
@ -218,7 +217,9 @@ class Commands:
|
|||||||
"""Execute a shell command inside the capsule.
|
"""Execute a shell command inside the capsule.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cmd (str): Shell command string to execute.
|
cmd (str): Command string to execute. Sent verbatim to the
|
||||||
|
server — no shell wrapping is applied. Provide a full
|
||||||
|
shell-quoted command if you need shell features.
|
||||||
background (bool): If ``True``, launch the process in the
|
background (bool): If ``True``, launch the process in the
|
||||||
background and return a :class:`CommandHandle` immediately.
|
background and return a :class:`CommandHandle` immediately.
|
||||||
Defaults to ``False``.
|
Defaults to ``False``.
|
||||||
@ -308,10 +309,11 @@ class Commands:
|
|||||||
"""Execute a command via WebSocket, streaming output as events.
|
"""Execute a command via WebSocket, streaming output as events.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cmd (str): Command to execute.
|
cmd (str): Command to execute. Sent verbatim to the server as
|
||||||
args (list[str] | None): Additional arguments for the command.
|
the full command string.
|
||||||
When omitted, *cmd* is interpreted as a shell command
|
args (list[str] | None): Optional explicit argument vector. When
|
||||||
string and executed via ``/bin/sh -c``.
|
provided, *cmd* is treated as the program and *args* as its
|
||||||
|
argv tail. When omitted, *cmd* is forwarded as-is.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
StreamEvent: Successive events including :class:`StreamStartEvent`,
|
StreamEvent: Successive events including :class:`StreamStartEvent`,
|
||||||
@ -378,7 +380,9 @@ class AsyncCommands:
|
|||||||
"""Execute a shell command inside the capsule.
|
"""Execute a shell command inside the capsule.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cmd (str): Shell command string to execute.
|
cmd (str): Command string to execute. Sent verbatim to the
|
||||||
|
server — no shell wrapping is applied. Provide a full
|
||||||
|
shell-quoted command if you need shell features.
|
||||||
background (bool): If ``True``, launch the process in the
|
background (bool): If ``True``, launch the process in the
|
||||||
background and return a :class:`CommandHandle` immediately.
|
background and return a :class:`CommandHandle` immediately.
|
||||||
Defaults to ``False``.
|
Defaults to ``False``.
|
||||||
@ -470,10 +474,11 @@ class AsyncCommands:
|
|||||||
"""Execute a command via WebSocket, streaming output as events.
|
"""Execute a command via WebSocket, streaming output as events.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cmd (str): Command to execute.
|
cmd (str): Command to execute. Sent verbatim to the server as
|
||||||
args (list[str] | None): Additional arguments for the command.
|
the full command string.
|
||||||
When omitted, *cmd* is interpreted as a shell command
|
args (list[str] | None): Optional explicit argument vector. When
|
||||||
string and executed via ``/bin/sh -c``.
|
provided, *cmd* is treated as the program and *args* as its
|
||||||
|
argv tail. When omitted, *cmd* is forwarded as-is.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
StreamEvent: Successive events including :class:`StreamStartEvent`,
|
StreamEvent: Successive events including :class:`StreamStartEvent`,
|
||||||
|
|||||||
@ -164,4 +164,17 @@ def __getattr__(name: str) -> type:
|
|||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
return WrennHostHasCapsulesError
|
return WrennHostHasCapsulesError
|
||||||
|
if name in ("GitError", "GitCommandError", "GitAuthError"):
|
||||||
|
from wrenn._git.exceptions import (
|
||||||
|
GitAuthError as _GitAuthError,
|
||||||
|
GitCommandError as _GitCommandError,
|
||||||
|
GitError as _GitError,
|
||||||
|
)
|
||||||
|
|
||||||
|
_m: dict[str, type] = {
|
||||||
|
"GitError": _GitError,
|
||||||
|
"GitCommandError": _GitCommandError,
|
||||||
|
"GitAuthError": _GitAuthError,
|
||||||
|
}
|
||||||
|
return _m[name]
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|||||||
@ -1,65 +1,31 @@
|
|||||||
from wrenn.models._generated import (
|
from wrenn.models._generated import (
|
||||||
APIKeyResponse,
|
Actor,
|
||||||
Capsule,
|
Capsule,
|
||||||
CreateAPIKeyRequest,
|
CapsuleMetrics,
|
||||||
CreateCapsuleRequest,
|
CapsuleStats,
|
||||||
CreateHostRequest,
|
|
||||||
CreateHostResponse,
|
|
||||||
CreateSnapshotRequest,
|
|
||||||
Encoding,
|
|
||||||
Error,
|
|
||||||
Error1,
|
|
||||||
ExecRequest,
|
|
||||||
ExecResponse,
|
|
||||||
FileEntry,
|
FileEntry,
|
||||||
Host,
|
|
||||||
ListDirRequest,
|
|
||||||
ListDirResponse,
|
ListDirResponse,
|
||||||
LoginRequest,
|
|
||||||
MakeDirRequest,
|
|
||||||
MakeDirResponse,
|
MakeDirResponse,
|
||||||
ReadFileRequest,
|
MetricPoint,
|
||||||
RegisterHostRequest,
|
Resource,
|
||||||
RegisterHostResponse,
|
SSEEvent,
|
||||||
RemoveRequest,
|
|
||||||
SignupRequest,
|
|
||||||
Status,
|
Status,
|
||||||
Status1,
|
|
||||||
Template,
|
Template,
|
||||||
Type,
|
UsageResponse,
|
||||||
Type1,
|
|
||||||
Type2,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"APIKeyResponse",
|
"Actor",
|
||||||
"CreateAPIKeyRequest",
|
|
||||||
"CreateHostRequest",
|
|
||||||
"CreateHostResponse",
|
|
||||||
"CreateCapsuleRequest",
|
|
||||||
"CreateSnapshotRequest",
|
|
||||||
"Encoding",
|
|
||||||
"Error",
|
|
||||||
"Error1",
|
|
||||||
"ExecRequest",
|
|
||||||
"ExecResponse",
|
|
||||||
"FileEntry",
|
|
||||||
"Host",
|
|
||||||
"ListDirRequest",
|
|
||||||
"ListDirResponse",
|
|
||||||
"LoginRequest",
|
|
||||||
"MakeDirRequest",
|
|
||||||
"MakeDirResponse",
|
|
||||||
"ReadFileRequest",
|
|
||||||
"RegisterHostRequest",
|
|
||||||
"RegisterHostResponse",
|
|
||||||
"RemoveRequest",
|
|
||||||
"Capsule",
|
"Capsule",
|
||||||
"SignupRequest",
|
"CapsuleMetrics",
|
||||||
|
"CapsuleStats",
|
||||||
|
"FileEntry",
|
||||||
|
"ListDirResponse",
|
||||||
|
"MakeDirResponse",
|
||||||
|
"MetricPoint",
|
||||||
|
"Resource",
|
||||||
|
"SSEEvent",
|
||||||
"Status",
|
"Status",
|
||||||
"Status1",
|
|
||||||
"Template",
|
"Template",
|
||||||
"Type",
|
"UsageResponse",
|
||||||
"Type1",
|
|
||||||
"Type2",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,79 +1,20 @@
|
|||||||
# generated by datamodel-codegen:
|
# generated by datamodel-codegen:
|
||||||
# filename: openapi.yaml
|
# filename: openapi.yaml
|
||||||
# timestamp: 2026-05-19T08:54:50+00:00
|
# timestamp: 2026-06-22T22:24:45+00:00
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
|
|
||||||
from typing import Annotated, Any
|
|
||||||
from datetime import date as date_aliased
|
from datetime import date as date_aliased
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from pydantic import AwareDatetime, BaseModel, Field
|
||||||
class SignupRequest(BaseModel):
|
|
||||||
email: EmailStr
|
|
||||||
password: Annotated[str, Field(min_length=8)]
|
|
||||||
name: Annotated[str, Field(max_length=100)]
|
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
|
||||||
email: EmailStr
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
class SignupResponse(BaseModel):
|
|
||||||
message: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Confirmation message instructing user to check email"),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
user_id: str | None = None
|
|
||||||
team_id: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
name: str | None = None
|
|
||||||
role: str | None = None
|
|
||||||
is_admin: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CreateAPIKeyRequest(BaseModel):
|
|
||||||
name: str | None = "Unnamed API Key"
|
|
||||||
|
|
||||||
|
|
||||||
class APIKeyResponse(BaseModel):
|
|
||||||
id: str | None = None
|
|
||||||
team_id: str | None = None
|
|
||||||
name: str | None = None
|
|
||||||
key_prefix: Annotated[
|
|
||||||
str | None, Field(description='Display prefix (e.g. "wrn_ab12cd34...")')
|
|
||||||
] = None
|
|
||||||
created_at: AwareDatetime | None = None
|
|
||||||
last_used: AwareDatetime | None = None
|
|
||||||
key: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(
|
|
||||||
description="Full plaintext key. Only returned on creation, never again."
|
|
||||||
),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class CreateCapsuleRequest(BaseModel):
|
class CreateCapsuleRequest(BaseModel):
|
||||||
template: str | None = "minimal"
|
template: str | None = "minimal-ubuntu"
|
||||||
vcpus: int | None = 1
|
vcpus: int | None = 1
|
||||||
memory_mb: int | None = 512
|
memory_mb: int | None = 512
|
||||||
disk_size_mb: Annotated[
|
|
||||||
int | None,
|
|
||||||
Field(
|
|
||||||
description="Maximum size of the per-capsule copy-on-write disk in MB. Capped at 5 GB by default; the actual size is max(disk_size_mb, origin rootfs size).\n"
|
|
||||||
),
|
|
||||||
] = 5120
|
|
||||||
timeout_sec: Annotated[
|
timeout_sec: Annotated[
|
||||||
int | None,
|
int | None,
|
||||||
Field(
|
Field(
|
||||||
@ -81,6 +22,12 @@ class CreateCapsuleRequest(BaseModel):
|
|||||||
ge=0,
|
ge=0,
|
||||||
),
|
),
|
||||||
] = 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):
|
class Point(BaseModel):
|
||||||
@ -107,7 +54,6 @@ class Current(BaseModel):
|
|||||||
running_count: int | None = None
|
running_count: int | None = None
|
||||||
vcpus_reserved: int | None = None
|
vcpus_reserved: int | None = None
|
||||||
memory_mb_reserved: int | None = None
|
memory_mb_reserved: int | None = None
|
||||||
sampled_at: AwareDatetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Peaks(BaseModel):
|
class Peaks(BaseModel):
|
||||||
@ -148,6 +94,7 @@ class Status(StrEnum):
|
|||||||
running = "running"
|
running = "running"
|
||||||
pausing = "pausing"
|
pausing = "pausing"
|
||||||
paused = "paused"
|
paused = "paused"
|
||||||
|
snapshotting = "snapshotting"
|
||||||
resuming = "resuming"
|
resuming = "resuming"
|
||||||
stopping = "stopping"
|
stopping = "stopping"
|
||||||
hibernated = "hibernated"
|
hibernated = "hibernated"
|
||||||
@ -163,8 +110,6 @@ class Capsule(BaseModel):
|
|||||||
vcpus: int | None = None
|
vcpus: int | None = None
|
||||||
memory_mb: int | None = None
|
memory_mb: int | None = None
|
||||||
timeout_sec: int | None = None
|
timeout_sec: int | None = None
|
||||||
guest_ip: str | None = None
|
|
||||||
host_ip: str | None = None
|
|
||||||
created_at: AwareDatetime | None = None
|
created_at: AwareDatetime | None = None
|
||||||
started_at: AwareDatetime | None = None
|
started_at: AwareDatetime | None = None
|
||||||
last_active_at: AwareDatetime | None = None
|
last_active_at: AwareDatetime | None = None
|
||||||
@ -172,10 +117,18 @@ 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
|
||||||
|
disk_size_mb: Annotated[
|
||||||
|
int | None, Field(description="Maximum disk capacity in MiB.")
|
||||||
|
] = None
|
||||||
|
disk_used_mb: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(
|
||||||
|
description="Current disk usage in MiB. Only populated on individual capsule GET; omitted in list responses."
|
||||||
),
|
),
|
||||||
] = None
|
] = None
|
||||||
disk_size_mb: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CreateSnapshotRequest(BaseModel):
|
class CreateSnapshotRequest(BaseModel):
|
||||||
@ -203,7 +156,13 @@ class Template(BaseModel):
|
|||||||
platform: Annotated[
|
platform: Annotated[
|
||||||
bool | None,
|
bool | None,
|
||||||
Field(
|
Field(
|
||||||
description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal` rootfs). False for team-owned\nsnapshot templates.\n"
|
description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal-ubuntu` rootfs). False for team-owned\nsnapshot templates.\n"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
protected: Annotated[
|
||||||
|
bool | None,
|
||||||
|
Field(
|
||||||
|
description="True for built-in system base templates (minimal-ubuntu,\nminimal-alpine, minimal-arch, minimal-fedora). Protected templates\ncannot be deleted.\n"
|
||||||
),
|
),
|
||||||
] = None
|
] = None
|
||||||
metadata: dict[str, str] | None = None
|
metadata: dict[str, str] | None = None
|
||||||
@ -231,12 +190,14 @@ class ExecRequest(BaseModel):
|
|||||||
envs: Annotated[
|
envs: Annotated[
|
||||||
dict[str, str] | None,
|
dict[str, str] | None,
|
||||||
Field(
|
Field(
|
||||||
description="Environment variables for the process (background exec only)"
|
description="Environment variables for the process (applies to both foreground and background exec)"
|
||||||
),
|
),
|
||||||
] = None
|
] = None
|
||||||
cwd: Annotated[
|
cwd: Annotated[
|
||||||
str | None,
|
str | None,
|
||||||
Field(description="Working directory for the process (background exec only)"),
|
Field(
|
||||||
|
description="Working directory for the process (applies to both foreground and background exec)"
|
||||||
|
),
|
||||||
] = None
|
] = None
|
||||||
|
|
||||||
|
|
||||||
@ -319,12 +280,6 @@ 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
|
||||||
|
|
||||||
@ -333,158 +288,6 @@ class RemoveRequest(BaseModel):
|
|||||||
path: Annotated[str, Field(description="Path to remove inside the capsule")]
|
path: Annotated[str, Field(description="Path to remove inside the capsule")]
|
||||||
|
|
||||||
|
|
||||||
class Type2(StrEnum):
|
|
||||||
"""
|
|
||||||
Host type. Regular hosts are shared; BYOC hosts belong to a team.
|
|
||||||
"""
|
|
||||||
|
|
||||||
regular = "regular"
|
|
||||||
byoc = "byoc"
|
|
||||||
|
|
||||||
|
|
||||||
class CreateHostRequest(BaseModel):
|
|
||||||
type: Annotated[
|
|
||||||
Type2,
|
|
||||||
Field(
|
|
||||||
description="Host type. Regular hosts are shared; BYOC hosts belong to a team."
|
|
||||||
),
|
|
||||||
]
|
|
||||||
team_id: Annotated[str | None, Field(description="Required for BYOC hosts.")] = None
|
|
||||||
provider: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Cloud provider (e.g. aws, gcp, hetzner, bare-metal)."),
|
|
||||||
] = None
|
|
||||||
availability_zone: Annotated[
|
|
||||||
str | None, Field(description="Availability zone (e.g. us-east, eu-west).")
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterHostRequest(BaseModel):
|
|
||||||
token: Annotated[
|
|
||||||
str, Field(description="One-time registration token from POST /v1/hosts.")
|
|
||||||
]
|
|
||||||
arch: Annotated[
|
|
||||||
str | None, Field(description="CPU architecture (e.g. x86_64, aarch64).")
|
|
||||||
] = None
|
|
||||||
cpu_cores: int | None = None
|
|
||||||
memory_mb: int | None = None
|
|
||||||
disk_gb: int | None = None
|
|
||||||
address: Annotated[str, Field(description="Host agent address (ip:port).")]
|
|
||||||
|
|
||||||
|
|
||||||
class Type3(StrEnum):
|
|
||||||
regular = "regular"
|
|
||||||
byoc = "byoc"
|
|
||||||
|
|
||||||
|
|
||||||
class Status1(StrEnum):
|
|
||||||
pending = "pending"
|
|
||||||
online = "online"
|
|
||||||
offline = "offline"
|
|
||||||
draining = "draining"
|
|
||||||
unreachable = "unreachable"
|
|
||||||
|
|
||||||
|
|
||||||
class Host(BaseModel):
|
|
||||||
id: str | None = None
|
|
||||||
type: Type3 | None = None
|
|
||||||
team_id: str | None = None
|
|
||||||
provider: str | None = None
|
|
||||||
availability_zone: str | None = None
|
|
||||||
arch: str | None = None
|
|
||||||
cpu_cores: int | None = None
|
|
||||||
memory_mb: int | None = None
|
|
||||||
disk_gb: int | None = None
|
|
||||||
address: str | None = None
|
|
||||||
status: Status1 | None = None
|
|
||||||
last_heartbeat_at: AwareDatetime | None = None
|
|
||||||
created_by: str | None = None
|
|
||||||
created_at: AwareDatetime | None = None
|
|
||||||
updated_at: AwareDatetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RefreshHostTokenRequest(BaseModel):
|
|
||||||
refresh_token: Annotated[
|
|
||||||
str,
|
|
||||||
Field(
|
|
||||||
description="Refresh token obtained from registration or a previous refresh."
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class RefreshHostTokenResponse(BaseModel):
|
|
||||||
host: Host | None = None
|
|
||||||
token: Annotated[
|
|
||||||
str | None, Field(description="New host JWT. Valid for 7 days.")
|
|
||||||
] = None
|
|
||||||
refresh_token: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(
|
|
||||||
description="New refresh token. Valid for 60 days; old token is revoked."
|
|
||||||
),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class HostDeletePreview(BaseModel):
|
|
||||||
host: Host | None = None
|
|
||||||
sandbox_ids: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(description="IDs of capsules that would be destroyed on force-delete."),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Error(BaseModel):
|
|
||||||
code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None
|
|
||||||
message: str | None = None
|
|
||||||
sandbox_ids: Annotated[
|
|
||||||
list[str] | None, Field(description="IDs of active capsules blocking deletion.")
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class HostHasCapsulesError(BaseModel):
|
|
||||||
error: Error | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class AddTagRequest(BaseModel):
|
|
||||||
tag: str
|
|
||||||
|
|
||||||
|
|
||||||
class UserSearchResult(BaseModel):
|
|
||||||
user_id: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Team(BaseModel):
|
|
||||||
id: str | None = None
|
|
||||||
name: str | None = None
|
|
||||||
slug: Annotated[
|
|
||||||
str | None, Field(description="Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)")
|
|
||||||
] = None
|
|
||||||
created_at: AwareDatetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Role(StrEnum):
|
|
||||||
owner = "owner"
|
|
||||||
admin = "admin"
|
|
||||||
member = "member"
|
|
||||||
|
|
||||||
|
|
||||||
class TeamWithRole(Team):
|
|
||||||
role: Role | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class TeamMember(BaseModel):
|
|
||||||
user_id: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
role: Role | None = None
|
|
||||||
joined_at: AwareDatetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class TeamDetail(BaseModel):
|
|
||||||
team: Team | None = None
|
|
||||||
members: list[TeamMember] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Range1(StrEnum):
|
class Range1(StrEnum):
|
||||||
field_5m = "5m"
|
field_5m = "5m"
|
||||||
field_10m = "10m"
|
field_10m = "10m"
|
||||||
@ -514,141 +317,16 @@ class MetricPoint(BaseModel):
|
|||||||
] = None
|
] = None
|
||||||
|
|
||||||
|
|
||||||
class Provider(StrEnum):
|
class Error1(BaseModel):
|
||||||
discord = "discord"
|
|
||||||
slack = "slack"
|
|
||||||
teams = "teams"
|
|
||||||
googlechat = "googlechat"
|
|
||||||
telegram = "telegram"
|
|
||||||
matrix = "matrix"
|
|
||||||
webhook = "webhook"
|
|
||||||
|
|
||||||
|
|
||||||
class Event(StrEnum):
|
|
||||||
capsule_create = "capsule.create"
|
|
||||||
capsule_pause = "capsule.pause"
|
|
||||||
capsule_resume = "capsule.resume"
|
|
||||||
capsule_destroy = "capsule.destroy"
|
|
||||||
template_snapshot_create = "template.snapshot.create"
|
|
||||||
template_snapshot_delete = "template.snapshot.delete"
|
|
||||||
host_up = "host.up"
|
|
||||||
host_down = "host.down"
|
|
||||||
|
|
||||||
|
|
||||||
class CreateChannelRequest(BaseModel):
|
|
||||||
name: Annotated[str, Field(description="Unique channel name within the team.")]
|
|
||||||
provider: Provider
|
|
||||||
config: Annotated[
|
|
||||||
dict[str, str],
|
|
||||||
Field(
|
|
||||||
description='Provider-specific configuration fields. Discord/Slack/Teams/Google Chat: {"webhook_url": "..."}. Telegram: {"bot_token": "...", "chat_id": "..."}. Matrix: {"homeserver_url": "...", "access_token": "...", "room_id": "..."}. Webhook: {"url": "...", "secret": "..."} (secret is auto-generated if omitted).\n'
|
|
||||||
),
|
|
||||||
]
|
|
||||||
events: list[Event]
|
|
||||||
|
|
||||||
|
|
||||||
class TestChannelRequest(BaseModel):
|
|
||||||
provider: Provider
|
|
||||||
config: Annotated[
|
|
||||||
dict[str, str],
|
|
||||||
Field(
|
|
||||||
description="Provider-specific configuration fields (same as CreateChannelRequest.config)."
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class RotateConfigRequest(BaseModel):
|
|
||||||
config: Annotated[
|
|
||||||
dict[str, str],
|
|
||||||
Field(
|
|
||||||
description="New provider configuration fields. Must include all required fields for the channel's provider. Replaces the existing config entirely.\n"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateChannelRequest(BaseModel):
|
|
||||||
name: str
|
|
||||||
events: list[Event]
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelResponse(BaseModel):
|
|
||||||
id: str | None = None
|
|
||||||
team_id: str | None = None
|
|
||||||
name: str | None = None
|
|
||||||
provider: Provider | None = None
|
|
||||||
events: list[str] | None = None
|
|
||||||
created_at: AwareDatetime | None = None
|
|
||||||
updated_at: AwareDatetime | None = None
|
|
||||||
secret: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Webhook secret. Only returned on creation, never again."),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class MeResponse(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
email: EmailStr | None = None
|
|
||||||
has_password: Annotated[
|
|
||||||
bool | None,
|
|
||||||
Field(
|
|
||||||
description="Whether the user has a password set (false for OAuth-only accounts)"
|
|
||||||
),
|
|
||||||
] = None
|
|
||||||
providers: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
Field(description='List of linked OAuth provider names (e.g. ["github"])'),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordRequest(BaseModel):
|
|
||||||
current_password: Annotated[
|
|
||||||
str | None, Field(description="Required when changing an existing password")
|
|
||||||
] = None
|
|
||||||
new_password: Annotated[str, Field(min_length=8)]
|
|
||||||
confirm_password: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(
|
|
||||||
description="Required when adding a password to an OAuth-only account (must match new_password)"
|
|
||||||
),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Error2(BaseModel):
|
|
||||||
code: str | None = None
|
code: str | None = None
|
||||||
message: str | None = None
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class Error1(BaseModel):
|
class Error(BaseModel):
|
||||||
error: Error2 | None = None
|
error: Error1 | None = None
|
||||||
|
|
||||||
|
|
||||||
class ActorType(StrEnum):
|
class Event(StrEnum):
|
||||||
user = "user"
|
|
||||||
api_key = "api_key"
|
|
||||||
host = "host"
|
|
||||||
system = "system"
|
|
||||||
|
|
||||||
|
|
||||||
class Status2(StrEnum):
|
|
||||||
success = "success"
|
|
||||||
failure = "failure"
|
|
||||||
|
|
||||||
|
|
||||||
class AuditLogEntry(BaseModel):
|
|
||||||
id: str | None = None
|
|
||||||
actor_type: ActorType | None = None
|
|
||||||
actor_id: str | None = None
|
|
||||||
actor_name: str | None = None
|
|
||||||
resource_type: str | None = None
|
|
||||||
resource_id: str | None = None
|
|
||||||
action: str | None = None
|
|
||||||
scope: str | None = None
|
|
||||||
status: Status2 | None = None
|
|
||||||
metadata: dict[str, Any] | None = None
|
|
||||||
created_at: AwareDatetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Event2(StrEnum):
|
|
||||||
connected = "connected"
|
connected = "connected"
|
||||||
capsule_create = "capsule.create"
|
capsule_create = "capsule.create"
|
||||||
capsule_pause = "capsule.pause"
|
capsule_pause = "capsule.pause"
|
||||||
@ -678,14 +356,14 @@ class Resource(BaseModel):
|
|||||||
type: str | None = None
|
type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class Type4(StrEnum):
|
class Type2(StrEnum):
|
||||||
user = "user"
|
user = "user"
|
||||||
api_key = "api_key"
|
api_key = "api_key"
|
||||||
system = "system"
|
system = "system"
|
||||||
|
|
||||||
|
|
||||||
class Actor(BaseModel):
|
class Actor(BaseModel):
|
||||||
type: Type4 | None = None
|
type: Type2 | None = None
|
||||||
id: str | None = None
|
id: str | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
|
||||||
@ -697,7 +375,7 @@ class SSEEvent(BaseModel):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
event: Event2 | None = None
|
event: Event | None = None
|
||||||
outcome: Annotated[
|
outcome: Annotated[
|
||||||
Outcome | None,
|
Outcome | None,
|
||||||
Field(
|
Field(
|
||||||
@ -726,30 +404,6 @@ class ListDirResponse(BaseModel):
|
|||||||
entries: list[FileEntry] | None = None
|
entries: list[FileEntry] | None = None
|
||||||
|
|
||||||
|
|
||||||
class CreateHostResponse(BaseModel):
|
|
||||||
host: Host | None = None
|
|
||||||
registration_token: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(
|
|
||||||
description="One-time registration token for the host agent. Expires in 1 hour."
|
|
||||||
),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterHostResponse(BaseModel):
|
|
||||||
host: Host | None = None
|
|
||||||
token: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Host JWT for X-Host-Token header. Valid for 7 days."),
|
|
||||||
] = None
|
|
||||||
refresh_token: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(
|
|
||||||
description="Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use."
|
|
||||||
),
|
|
||||||
] = None
|
|
||||||
|
|
||||||
|
|
||||||
class CapsuleMetrics(BaseModel):
|
class CapsuleMetrics(BaseModel):
|
||||||
sandbox_id: str | None = None
|
sandbox_id: str | None = None
|
||||||
range: Range1 | None = None
|
range: Range1 | None = None
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterator, Iterator
|
from collections.abc import AsyncIterator, Iterator
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
@ -8,6 +7,7 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx_ws
|
import httpx_ws
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from wsproto.events import BytesMessage, TextMessage
|
||||||
|
|
||||||
# A clean (``WebSocketDisconnect``) or abrupt (``WebSocketNetworkError``) close
|
# A clean (``WebSocketDisconnect``) or abrupt (``WebSocketNetworkError``) close
|
||||||
# both mean the PTY stream has ended; iteration must stop on either.
|
# both mean the PTY stream has ended; iteration must stop on either.
|
||||||
@ -31,7 +31,8 @@ class PtyEvent(BaseModel):
|
|||||||
fatal: bool | None = None
|
fatal: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
def _parse_pty_event(raw: dict[str, Any]) -> PtyEvent:
|
def _parse_control_event(raw: dict[str, Any]) -> PtyEvent:
|
||||||
|
"""Parse a JSON control frame from the server."""
|
||||||
msg_type = raw.get("type", "")
|
msg_type = raw.get("type", "")
|
||||||
if msg_type == "started":
|
if msg_type == "started":
|
||||||
return PtyEvent(
|
return PtyEvent(
|
||||||
@ -39,10 +40,6 @@ def _parse_pty_event(raw: dict[str, Any]) -> PtyEvent:
|
|||||||
pid=raw.get("pid"),
|
pid=raw.get("pid"),
|
||||||
tag=raw.get("tag"),
|
tag=raw.get("tag"),
|
||||||
)
|
)
|
||||||
if msg_type == "output":
|
|
||||||
raw_data = raw.get("data", "")
|
|
||||||
decoded = base64.b64decode(raw_data) if raw_data else b""
|
|
||||||
return PtyEvent(type=PtyEventType.output, data=decoded)
|
|
||||||
if msg_type == "exit":
|
if msg_type == "exit":
|
||||||
return PtyEvent(type=PtyEventType.exit, exit_code=raw.get("exit_code", -1))
|
return PtyEvent(type=PtyEventType.exit, exit_code=raw.get("exit_code", -1))
|
||||||
if msg_type == "error":
|
if msg_type == "error":
|
||||||
@ -133,10 +130,10 @@ class PtySession:
|
|||||||
"""Send raw bytes to the PTY stdin.
|
"""Send raw bytes to the PTY stdin.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: Raw bytes to send. Base64-encoded internally.
|
data: Raw bytes to send. Delivered as a binary WebSocket frame
|
||||||
|
verbatim — no JSON wrapping, no base64.
|
||||||
"""
|
"""
|
||||||
encoded = base64.b64encode(data).decode("ascii")
|
self._ws.send_bytes(data)
|
||||||
self._ws.send_text(json.dumps({"type": "input", "data": encoded}))
|
|
||||||
|
|
||||||
def resize(self, cols: int, rows: int) -> None:
|
def resize(self, cols: int, rows: int) -> None:
|
||||||
"""Resize the PTY terminal.
|
"""Resize the PTY terminal.
|
||||||
@ -160,13 +157,20 @@ class PtySession:
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
def __next__(self) -> PtyEvent:
|
def __next__(self) -> PtyEvent:
|
||||||
|
while True:
|
||||||
if self._done:
|
if self._done:
|
||||||
raise StopIteration
|
raise StopIteration
|
||||||
try:
|
try:
|
||||||
raw = self._ws.receive_text()
|
msg = self._ws.receive()
|
||||||
except _WS_CLOSED:
|
except _WS_CLOSED:
|
||||||
raise StopIteration
|
raise StopIteration
|
||||||
event = _parse_pty_event(json.loads(raw))
|
if isinstance(msg, BytesMessage):
|
||||||
|
return PtyEvent(type=PtyEventType.output, data=msg.data)
|
||||||
|
if isinstance(msg, TextMessage):
|
||||||
|
event = _parse_control_event(json.loads(msg.data))
|
||||||
|
else:
|
||||||
|
# Ignore other wsproto events (Ping/Pong/etc handled by httpx_ws).
|
||||||
|
continue
|
||||||
if event.type == PtyEventType.started:
|
if event.type == PtyEventType.started:
|
||||||
if event.tag is not None:
|
if event.tag is not None:
|
||||||
self._tag = event.tag
|
self._tag = event.tag
|
||||||
@ -269,10 +273,10 @@ class AsyncPtySession:
|
|||||||
"""Send raw bytes to the PTY stdin.
|
"""Send raw bytes to the PTY stdin.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: Raw bytes to send. Base64-encoded internally.
|
data: Raw bytes to send. Delivered as a binary WebSocket frame
|
||||||
|
verbatim — no JSON wrapping, no base64.
|
||||||
"""
|
"""
|
||||||
encoded = base64.b64encode(data).decode("ascii")
|
await self._ws.send_bytes(data)
|
||||||
await self._ws.send_text(json.dumps({"type": "input", "data": encoded}))
|
|
||||||
|
|
||||||
async def resize(self, cols: int, rows: int) -> None:
|
async def resize(self, cols: int, rows: int) -> None:
|
||||||
"""Resize the PTY terminal.
|
"""Resize the PTY terminal.
|
||||||
@ -298,13 +302,20 @@ class AsyncPtySession:
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
async def __anext__(self) -> PtyEvent:
|
async def __anext__(self) -> PtyEvent:
|
||||||
|
while True:
|
||||||
if self._done:
|
if self._done:
|
||||||
raise StopAsyncIteration
|
raise StopAsyncIteration
|
||||||
try:
|
try:
|
||||||
raw = await self._ws.receive_text()
|
msg = await self._ws.receive()
|
||||||
except _WS_CLOSED:
|
except _WS_CLOSED:
|
||||||
raise StopAsyncIteration
|
raise StopAsyncIteration
|
||||||
event = _parse_pty_event(json.loads(raw))
|
if isinstance(msg, BytesMessage):
|
||||||
|
return PtyEvent(type=PtyEventType.output, data=msg.data)
|
||||||
|
if isinstance(msg, TextMessage):
|
||||||
|
event = _parse_control_event(json.loads(msg.data))
|
||||||
|
else:
|
||||||
|
# Ignore other wsproto events (Ping/Pong/etc handled by httpx_ws).
|
||||||
|
continue
|
||||||
if event.type == PtyEventType.started:
|
if event.type == PtyEventType.started:
|
||||||
if event.tag is not None:
|
if event.tag is not None:
|
||||||
self._tag = event.tag
|
self._tag = event.tag
|
||||||
|
|||||||
@ -13,9 +13,16 @@ from wrenn.exceptions import (
|
|||||||
WrennValidationError,
|
WrennValidationError,
|
||||||
)
|
)
|
||||||
from wrenn.models import (
|
from wrenn.models import (
|
||||||
|
Actor,
|
||||||
Capsule,
|
Capsule,
|
||||||
|
CapsuleMetrics,
|
||||||
|
CapsuleStats,
|
||||||
|
MetricPoint,
|
||||||
|
Resource,
|
||||||
|
SSEEvent,
|
||||||
Status,
|
Status,
|
||||||
Template,
|
Template,
|
||||||
|
UsageResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
BASE = "https://app.wrenn.dev/api"
|
BASE = "https://app.wrenn.dev/api"
|
||||||
@ -103,6 +110,212 @@ 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
|
||||||
|
assert isinstance(m, CapsuleMetrics)
|
||||||
|
assert isinstance(m.points[0], MetricPoint)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_stats_default_omits_range(self, client):
|
||||||
|
route = respx.get(f"{BASE}/v1/capsules/stats").respond(
|
||||||
|
200,
|
||||||
|
json={"range": "1h", "current": {}, "peaks": {}, "series": {}},
|
||||||
|
)
|
||||||
|
stats = client.capsules.stats()
|
||||||
|
assert "range=" not in str(route.calls[0].request.url)
|
||||||
|
assert isinstance(stats, CapsuleStats)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_usage_default_omits_params(self, client):
|
||||||
|
route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []})
|
||||||
|
usage = client.capsules.usage()
|
||||||
|
url = str(route.calls[0].request.url)
|
||||||
|
assert "from=" not in url
|
||||||
|
assert "to=" not in url
|
||||||
|
assert isinstance(usage, UsageResponse)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_metrics_default_omits_range(self, client):
|
||||||
|
route = respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond(
|
||||||
|
200, json={"sandbox_id": "sb-1", "points": []}
|
||||||
|
)
|
||||||
|
client.capsules.metrics("sb-1")
|
||||||
|
assert "range=" not in str(route.calls[0].request.url)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_metrics_not_found(self, client):
|
||||||
|
respx.get(f"{BASE}/v1/capsules/nope/metrics").respond(
|
||||||
|
404,
|
||||||
|
json={"error": {"code": "not_found", "message": "capsule not found"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennNotFoundError):
|
||||||
|
client.capsules.metrics("nope")
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_stats_auth_error(self, client):
|
||||||
|
respx.get(f"{BASE}/v1/capsules/stats").respond(
|
||||||
|
401,
|
||||||
|
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennAuthenticationError):
|
||||||
|
client.capsules.stats()
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_usage_string_dates(self, client):
|
||||||
|
route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []})
|
||||||
|
client.capsules.usage(from_="2026-01-01", to="2026-01-31")
|
||||||
|
url = str(route.calls[0].request.url)
|
||||||
|
assert "from=2026-01-01" in url
|
||||||
|
assert "to=2026-01-31" in url
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_usage_partial_dates(self, client):
|
||||||
|
route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []})
|
||||||
|
client.capsules.usage(from_="2026-01-01")
|
||||||
|
url = str(route.calls[0].request.url)
|
||||||
|
assert "from=2026-01-01" in url
|
||||||
|
assert "to=" not in url
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_stream_multiline_data(self, client):
|
||||||
|
body = (
|
||||||
|
"event: capsule.create\n"
|
||||||
|
'data: {"event":"capsule.create",\n'
|
||||||
|
'data: "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 len(events) == 1
|
||||||
|
assert events[0].resource.id == "sb-1"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_stream_skips_keepalive_only(self, client):
|
||||||
|
body = ": keepalive\n\n: keepalive\n\n"
|
||||||
|
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "text/event-stream"},
|
||||||
|
content=body,
|
||||||
|
)
|
||||||
|
assert list(client.events.stream()) == []
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_stream_ignores_incomplete_trailing_frame(self, client):
|
||||||
|
body = (
|
||||||
|
'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||||
|
"\n"
|
||||||
|
'data: {"event":"capsule.destroy"' # no closing brace, no blank line
|
||||||
|
)
|
||||||
|
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "text/event-stream"},
|
||||||
|
content=body,
|
||||||
|
)
|
||||||
|
events = list(client.events.stream())
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].event.value == "capsule.create"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_stream_full_payload_round_trip(self, client):
|
||||||
|
body = (
|
||||||
|
'data: {"event":"capsule.create",'
|
||||||
|
'"outcome":"success",'
|
||||||
|
'"resource":{"id":"sb-1","type":"capsule"},'
|
||||||
|
'"actor":{"type":"api_key","id":"key-1","name":"ci"},'
|
||||||
|
'"metadata":{"reason":"manual"}}\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 len(events) == 1
|
||||||
|
ev = events[0]
|
||||||
|
assert isinstance(ev, SSEEvent)
|
||||||
|
assert isinstance(ev.resource, Resource)
|
||||||
|
assert isinstance(ev.actor, Actor)
|
||||||
|
assert ev.actor.name == "ci"
|
||||||
|
assert ev.metadata == {"reason": "manual"}
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_stream_raises_on_4xx(self, client):
|
||||||
|
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||||
|
401,
|
||||||
|
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennAuthenticationError):
|
||||||
|
list(client.events.stream())
|
||||||
|
|
||||||
|
|
||||||
class TestSnapshots:
|
class TestSnapshots:
|
||||||
@respx.mock
|
@respx.mock
|
||||||
@ -262,6 +475,156 @@ class TestAsyncClient:
|
|||||||
with pytest.raises(WrennNotFoundError):
|
with pytest.raises(WrennNotFoundError):
|
||||||
await async_client.capsules.get("nope")
|
await async_client.capsules.get("nope")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_stats(self, async_client):
|
||||||
|
async with async_client:
|
||||||
|
route = respx.get(f"{BASE}/v1/capsules/stats").respond(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"range": "24h",
|
||||||
|
"current": {"running_count": 1},
|
||||||
|
"peaks": {"running_count": 5},
|
||||||
|
"series": {"running": [1]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
stats = await async_client.capsules.stats(range="24h")
|
||||||
|
assert "range=24h" in str(route.calls[0].request.url)
|
||||||
|
assert isinstance(stats, CapsuleStats)
|
||||||
|
assert stats.current.running_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_usage(self, async_client):
|
||||||
|
import datetime as _dt
|
||||||
|
|
||||||
|
async with async_client:
|
||||||
|
route = respx.get(f"{BASE}/v1/capsules/usage").respond(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"from": "2026-06-01",
|
||||||
|
"to": "2026-06-22",
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"date": "2026-06-01",
|
||||||
|
"cpu_minutes": 1.5,
|
||||||
|
"ram_mb_minutes": 200.0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
usage = await async_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
|
||||||
|
assert isinstance(usage, UsageResponse)
|
||||||
|
assert usage.points and usage.points[0].cpu_minutes == 1.5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_metrics(self, async_client):
|
||||||
|
async with async_client:
|
||||||
|
respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"sandbox_id": "sb-1",
|
||||||
|
"range": "2h",
|
||||||
|
"points": [
|
||||||
|
{"timestamp_unix": 1, "cpu_pct": 33.0, "mem_bytes": 1024}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
m = await async_client.capsules.metrics("sb-1", range="2h")
|
||||||
|
assert isinstance(m, CapsuleMetrics)
|
||||||
|
assert m.points[0].cpu_pct == 33.0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_metrics_not_found(self, async_client):
|
||||||
|
async with async_client:
|
||||||
|
respx.get(f"{BASE}/v1/capsules/nope/metrics").respond(
|
||||||
|
404,
|
||||||
|
json={"error": {"code": "not_found", "message": "not found"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennNotFoundError):
|
||||||
|
await async_client.capsules.metrics("nope")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_events_stream(self, async_client):
|
||||||
|
async with async_client:
|
||||||
|
body = (
|
||||||
|
": keepalive\n"
|
||||||
|
'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||||
|
"\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 = [ev async for ev in async_client.events.stream()]
|
||||||
|
assert [e.event.value for e in events] == [
|
||||||
|
"capsule.create",
|
||||||
|
"capsule.destroy",
|
||||||
|
]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_events_raises_on_4xx(self, async_client):
|
||||||
|
async with async_client:
|
||||||
|
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||||
|
401,
|
||||||
|
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennAuthenticationError):
|
||||||
|
async for _ in async_client.events.stream():
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_events_multiline_data(self, async_client):
|
||||||
|
async with async_client:
|
||||||
|
body = (
|
||||||
|
'data: {"event":"capsule.create",\n'
|
||||||
|
'data: "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 = [ev async for ev in async_client.events.stream()]
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].resource.id == "sb-1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPackageSurface:
|
||||||
|
def test_version(self):
|
||||||
|
import wrenn
|
||||||
|
|
||||||
|
assert wrenn.__version__ == "0.3.0"
|
||||||
|
|
||||||
|
def test_new_models_exported(self):
|
||||||
|
import wrenn.models as m
|
||||||
|
|
||||||
|
for name in (
|
||||||
|
"Actor",
|
||||||
|
"CapsuleMetrics",
|
||||||
|
"CapsuleStats",
|
||||||
|
"MetricPoint",
|
||||||
|
"Resource",
|
||||||
|
"SSEEvent",
|
||||||
|
"UsageResponse",
|
||||||
|
):
|
||||||
|
assert hasattr(m, name), name
|
||||||
|
assert name in m.__all__
|
||||||
|
|
||||||
|
|
||||||
class TestClientResolution:
|
class TestClientResolution:
|
||||||
def test_default_base_url_strips_app_subdomain(self):
|
def test_default_base_url_strips_app_subdomain(self):
|
||||||
|
|||||||
@ -430,12 +430,10 @@ class TestCodeRunnerMimeTypes:
|
|||||||
def test_requests_status_code(self):
|
def test_requests_status_code(self):
|
||||||
ex = self._run(
|
ex = self._run(
|
||||||
"import requests\n"
|
"import requests\n"
|
||||||
"r = requests.get('https://httpbin.org/status/204', timeout=10)\n"
|
"r = requests.get('http://httpbingo.org/status/418', timeout=10)\n"
|
||||||
"r.status_code\n"
|
"r.status_code\n"
|
||||||
)
|
)
|
||||||
if ex.error is not None:
|
assert ex.text == "418"
|
||||||
pytest.skip(f"network unavailable: {ex.error.name}")
|
|
||||||
assert ex.text == "204"
|
|
||||||
|
|
||||||
|
|
||||||
class TestCodeRunnerIsolation:
|
class TestCodeRunnerIsolation:
|
||||||
|
|||||||
@ -388,6 +388,214 @@ class TestJupyterRequest:
|
|||||||
assert a["header"]["msg_id"] != b["header"]["msg_id"]
|
assert a["header"]["msg_id"] != b["header"]["msg_id"]
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── _protocol direct helpers ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestPickKernelId:
|
||||||
|
def test_returns_first_matching_kernel(self):
|
||||||
|
from wrenn.code_runner._protocol import pick_kernel_id
|
||||||
|
|
||||||
|
kernels = [
|
||||||
|
{"id": "k-1", "name": "python3"},
|
||||||
|
{"id": "k-2", "name": "wrenn"},
|
||||||
|
{"id": "k-3", "name": "wrenn"},
|
||||||
|
]
|
||||||
|
assert pick_kernel_id(kernels, "wrenn") == "k-2"
|
||||||
|
|
||||||
|
def test_returns_none_when_no_match(self):
|
||||||
|
from wrenn.code_runner._protocol import pick_kernel_id
|
||||||
|
|
||||||
|
kernels = [{"id": "k-1", "name": "python3"}]
|
||||||
|
assert pick_kernel_id(kernels, "wrenn") is None
|
||||||
|
|
||||||
|
def test_returns_none_on_empty_list(self):
|
||||||
|
from wrenn.code_runner._protocol import pick_kernel_id
|
||||||
|
|
||||||
|
assert pick_kernel_id([], "wrenn") is None
|
||||||
|
|
||||||
|
def test_ignores_entries_missing_name(self):
|
||||||
|
from wrenn.code_runner._protocol import pick_kernel_id
|
||||||
|
|
||||||
|
kernels = [{"id": "k-1"}, {"id": "k-2", "name": "wrenn"}]
|
||||||
|
assert pick_kernel_id(kernels, "wrenn") == "k-2"
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateLanguage:
|
||||||
|
def test_python_accepted(self):
|
||||||
|
from wrenn.code_runner._protocol import validate_language
|
||||||
|
|
||||||
|
validate_language("python") # no raise
|
||||||
|
|
||||||
|
def test_other_language_raises(self):
|
||||||
|
from wrenn.code_runner._protocol import validate_language
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="not supported"):
|
||||||
|
validate_language("r")
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildWsUrl:
|
||||||
|
def test_uses_proxy_domain_when_given(self):
|
||||||
|
from wrenn.code_runner._protocol import build_ws_url
|
||||||
|
|
||||||
|
url = build_ws_url(
|
||||||
|
base_url="https://app.wrenn.dev/api",
|
||||||
|
capsule_id="sb-1",
|
||||||
|
kernel_id="k-1",
|
||||||
|
proxy_domain="wrenn.dev",
|
||||||
|
)
|
||||||
|
assert url.startswith("wss://")
|
||||||
|
assert "8888-sb-1.wrenn.dev" in url
|
||||||
|
assert url.endswith("/api/kernels/k-1/channels")
|
||||||
|
|
||||||
|
def test_falls_back_to_base_host(self):
|
||||||
|
from wrenn.code_runner._protocol import build_ws_url
|
||||||
|
|
||||||
|
url = build_ws_url(
|
||||||
|
base_url="http://localhost:8080/api",
|
||||||
|
capsule_id="sb-1",
|
||||||
|
kernel_id="k-1",
|
||||||
|
)
|
||||||
|
# localhost stays http→ws (not wss) only if helper preserves scheme
|
||||||
|
assert "8888-sb-1.localhost:8080" in url
|
||||||
|
assert url.endswith("/api/kernels/k-1/channels")
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyKernelMessage:
|
||||||
|
def _make_execution(self):
|
||||||
|
from wrenn.code_runner.models import Execution, Logs
|
||||||
|
|
||||||
|
return Execution(results=[], logs=Logs(stdout=[], stderr=[]), error=None)
|
||||||
|
|
||||||
|
def test_ignores_other_parent(self):
|
||||||
|
from wrenn.code_runner._protocol import apply_kernel_message
|
||||||
|
|
||||||
|
execution = self._make_execution()
|
||||||
|
msg = {
|
||||||
|
"msg_type": "stream",
|
||||||
|
"header": {"msg_type": "stream"},
|
||||||
|
"parent_header": {"msg_id": "other"},
|
||||||
|
"content": {"name": "stdout", "text": "hi"},
|
||||||
|
}
|
||||||
|
emitted: list = []
|
||||||
|
done = apply_kernel_message(
|
||||||
|
msg, "mine", execution, emitted.append, None, None, None
|
||||||
|
)
|
||||||
|
assert done is False
|
||||||
|
assert execution.logs.stdout == []
|
||||||
|
|
||||||
|
def test_stream_stdout_routed(self):
|
||||||
|
from wrenn.code_runner._protocol import apply_kernel_message
|
||||||
|
|
||||||
|
execution = self._make_execution()
|
||||||
|
msg = {
|
||||||
|
"msg_type": "stream",
|
||||||
|
"parent_header": {"msg_id": "mine"},
|
||||||
|
"content": {"name": "stdout", "text": "hi"},
|
||||||
|
}
|
||||||
|
outs: list[str] = []
|
||||||
|
apply_kernel_message(
|
||||||
|
msg, "mine", execution, lambda e: None, None, outs.append, None
|
||||||
|
)
|
||||||
|
assert execution.logs.stdout == ["hi"]
|
||||||
|
assert outs == ["hi"]
|
||||||
|
|
||||||
|
def test_stream_stderr_routed(self):
|
||||||
|
from wrenn.code_runner._protocol import apply_kernel_message
|
||||||
|
|
||||||
|
execution = self._make_execution()
|
||||||
|
msg = {
|
||||||
|
"msg_type": "stream",
|
||||||
|
"parent_header": {"msg_id": "mine"},
|
||||||
|
"content": {"name": "stderr", "text": "boom"},
|
||||||
|
}
|
||||||
|
errs: list[str] = []
|
||||||
|
apply_kernel_message(
|
||||||
|
msg, "mine", execution, lambda e: None, None, None, errs.append
|
||||||
|
)
|
||||||
|
assert execution.logs.stderr == ["boom"]
|
||||||
|
assert errs == ["boom"]
|
||||||
|
|
||||||
|
def test_execute_result_marks_main(self):
|
||||||
|
from wrenn.code_runner._protocol import apply_kernel_message
|
||||||
|
|
||||||
|
execution = self._make_execution()
|
||||||
|
msg = {
|
||||||
|
"msg_type": "execute_result",
|
||||||
|
"parent_header": {"msg_id": "mine"},
|
||||||
|
"content": {
|
||||||
|
"data": {"text/plain": "42"},
|
||||||
|
"execution_count": 7,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
results: list = []
|
||||||
|
apply_kernel_message(
|
||||||
|
msg, "mine", execution, lambda e: None, results.append, None, None
|
||||||
|
)
|
||||||
|
assert execution.execution_count == 7
|
||||||
|
assert execution.results[0].is_main_result is True
|
||||||
|
assert results and results[0].is_main_result is True
|
||||||
|
|
||||||
|
def test_display_data_is_not_main(self):
|
||||||
|
from wrenn.code_runner._protocol import apply_kernel_message
|
||||||
|
|
||||||
|
execution = self._make_execution()
|
||||||
|
msg = {
|
||||||
|
"msg_type": "display_data",
|
||||||
|
"parent_header": {"msg_id": "mine"},
|
||||||
|
"content": {"data": {"text/plain": "x"}},
|
||||||
|
}
|
||||||
|
apply_kernel_message(msg, "mine", execution, lambda e: None, None, None, None)
|
||||||
|
assert execution.results[0].is_main_result is False
|
||||||
|
assert execution.execution_count is None
|
||||||
|
|
||||||
|
def test_error_message_emits_error(self):
|
||||||
|
from wrenn.code_runner._protocol import apply_kernel_message
|
||||||
|
|
||||||
|
execution = self._make_execution()
|
||||||
|
msg = {
|
||||||
|
"msg_type": "error",
|
||||||
|
"parent_header": {"msg_id": "mine"},
|
||||||
|
"content": {
|
||||||
|
"ename": "ValueError",
|
||||||
|
"evalue": "bad",
|
||||||
|
"traceback": ["line1", "line2"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
emitted: list = []
|
||||||
|
apply_kernel_message(msg, "mine", execution, emitted.append, None, None, None)
|
||||||
|
assert len(emitted) == 1
|
||||||
|
assert emitted[0].name == "ValueError"
|
||||||
|
assert emitted[0].traceback == "line1\nline2"
|
||||||
|
|
||||||
|
def test_idle_status_returns_true(self):
|
||||||
|
from wrenn.code_runner._protocol import apply_kernel_message
|
||||||
|
|
||||||
|
execution = self._make_execution()
|
||||||
|
msg = {
|
||||||
|
"msg_type": "status",
|
||||||
|
"parent_header": {"msg_id": "mine"},
|
||||||
|
"content": {"execution_state": "idle"},
|
||||||
|
}
|
||||||
|
done = apply_kernel_message(
|
||||||
|
msg, "mine", execution, lambda e: None, None, None, None
|
||||||
|
)
|
||||||
|
assert done is True
|
||||||
|
|
||||||
|
def test_busy_status_returns_false(self):
|
||||||
|
from wrenn.code_runner._protocol import apply_kernel_message
|
||||||
|
|
||||||
|
execution = self._make_execution()
|
||||||
|
msg = {
|
||||||
|
"msg_type": "status",
|
||||||
|
"parent_header": {"msg_id": "mine"},
|
||||||
|
"content": {"execution_state": "busy"},
|
||||||
|
}
|
||||||
|
done = apply_kernel_message(
|
||||||
|
msg, "mine", execution, lambda e: None, None, None, None
|
||||||
|
)
|
||||||
|
assert done is False
|
||||||
|
|
||||||
|
|
||||||
# ───────────────────────── run_code (WS-mocked) ─────────────────────────
|
# ───────────────────────── run_code (WS-mocked) ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -145,8 +145,8 @@ class TestRunPayload:
|
|||||||
route = respx.post(EXEC_URL).respond(200, json={"stdout": "hi", "exit_code": 0})
|
route = respx.post(EXEC_URL).respond(200, json={"stdout": "hi", "exit_code": 0})
|
||||||
result = _make_commands().run("echo hi")
|
result = _make_commands().run("echo hi")
|
||||||
body = json.loads(route.calls[0].request.content)
|
body = json.loads(route.calls[0].request.content)
|
||||||
assert body["cmd"] == "/bin/sh"
|
assert body["cmd"] == "echo hi"
|
||||||
assert body["args"] == ["-c", "echo hi"]
|
assert "args" not in body
|
||||||
assert body["background"] is False
|
assert body["background"] is False
|
||||||
assert body["timeout_sec"] == 30
|
assert body["timeout_sec"] == 30
|
||||||
assert result.stdout == "hi"
|
assert result.stdout == "hi"
|
||||||
@ -361,12 +361,12 @@ def _patch_async_ws(monkeypatch, ws: _AsyncFakeWS) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class TestStream:
|
class TestStream:
|
||||||
def test_stream_sends_shell_wrapped_start(self, monkeypatch):
|
def test_stream_sends_cmd_verbatim(self, monkeypatch):
|
||||||
ws = _FakeWS([{"type": "exit", "exit_code": 0}])
|
ws = _FakeWS([{"type": "exit", "exit_code": 0}])
|
||||||
_patch_sync_ws(monkeypatch, ws)
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
list(_make_commands().stream("echo hi"))
|
list(_make_commands().stream("echo hi"))
|
||||||
start = json.loads(ws.sent[0])
|
start = json.loads(ws.sent[0])
|
||||||
assert start == {"type": "start", "cmd": "/bin/sh", "args": ["-c", "echo hi"]}
|
assert start == {"type": "start", "cmd": "echo hi"}
|
||||||
|
|
||||||
def test_stream_with_explicit_args(self, monkeypatch):
|
def test_stream_with_explicit_args(self, monkeypatch):
|
||||||
ws = _FakeWS([{"type": "exit", "exit_code": 0}])
|
ws = _FakeWS([{"type": "exit", "exit_code": 0}])
|
||||||
@ -480,7 +480,8 @@ class TestAsyncCommands:
|
|||||||
events = [e async for e in _make_async_commands().stream("echo out")]
|
events = [e async for e in _make_async_commands().stream("echo out")]
|
||||||
assert [e.type for e in events] == ["start", "stdout", "exit"]
|
assert [e.type for e in events] == ["start", "stdout", "exit"]
|
||||||
start = json.loads(ws.sent[0])
|
start = json.loads(ws.sent[0])
|
||||||
assert start["cmd"] == "/bin/sh"
|
assert start["cmd"] == "echo out"
|
||||||
|
assert "args" not in start
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_connect(self, monkeypatch):
|
async def test_async_connect(self, monkeypatch):
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import respx
|
import respx
|
||||||
|
from wsproto.events import BytesMessage, TextMessage
|
||||||
|
|
||||||
from wrenn.capsule import Capsule
|
from wrenn.capsule import Capsule
|
||||||
from wrenn.models import FileEntry
|
from wrenn.models import FileEntry
|
||||||
@ -13,9 +13,18 @@ from wrenn.pty import (
|
|||||||
AsyncPtySession,
|
AsyncPtySession,
|
||||||
PtyEventType,
|
PtyEventType,
|
||||||
PtySession,
|
PtySession,
|
||||||
_parse_pty_event,
|
_parse_control_event,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(payload: dict) -> TextMessage:
|
||||||
|
return TextMessage(data=json.dumps(payload))
|
||||||
|
|
||||||
|
|
||||||
|
def _bytes(data: bytes) -> BytesMessage:
|
||||||
|
return BytesMessage(data=data)
|
||||||
|
|
||||||
|
|
||||||
BASE = "https://app.wrenn.dev/api"
|
BASE = "https://app.wrenn.dev/api"
|
||||||
|
|
||||||
|
|
||||||
@ -74,32 +83,32 @@ class TestFilesList:
|
|||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"name": "main.py",
|
"name": "main.py",
|
||||||
"path": "/home/user/main.py",
|
"path": "/home/wrenn-user/main.py",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"size": 1024,
|
"size": 1024,
|
||||||
"mode": 33188,
|
"mode": 33188,
|
||||||
"permissions": "-rw-r--r--",
|
"permissions": "-rw-r--r--",
|
||||||
"owner": "root",
|
"owner": "wrenn-user",
|
||||||
"group": "root",
|
"group": "wrenn-user",
|
||||||
"modified_at": 1712899200,
|
"modified_at": 1712899200,
|
||||||
"symlink_target": None,
|
"symlink_target": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "config",
|
"name": "config",
|
||||||
"path": "/home/user/config",
|
"path": "/home/wrenn-user/config",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"size": 4096,
|
"size": 4096,
|
||||||
"mode": 16877,
|
"mode": 16877,
|
||||||
"permissions": "drwxr-xr-x",
|
"permissions": "drwxr-xr-x",
|
||||||
"owner": "root",
|
"owner": "wrenn-user",
|
||||||
"group": "root",
|
"group": "wrenn-user",
|
||||||
"modified_at": 1712899100,
|
"modified_at": 1712899100,
|
||||||
"symlink_target": None,
|
"symlink_target": None,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
entries = cap.files.list("/home/user")
|
entries = cap.files.list("/home/wrenn-user")
|
||||||
assert len(entries) == 2
|
assert len(entries) == 2
|
||||||
assert isinstance(entries[0], FileEntry)
|
assert isinstance(entries[0], FileEntry)
|
||||||
assert entries[0].name == "main.py"
|
assert entries[0].name == "main.py"
|
||||||
@ -113,7 +122,7 @@ class TestFilesList:
|
|||||||
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||||
200, json={"entries": []}
|
200, json={"entries": []}
|
||||||
)
|
)
|
||||||
cap.files.list("/home/user", depth=3)
|
cap.files.list("/home/wrenn-user", depth=3)
|
||||||
body = json.loads(route.calls[0].request.content)
|
body = json.loads(route.calls[0].request.content)
|
||||||
assert body["depth"] == 3
|
assert body["depth"] == 3
|
||||||
|
|
||||||
@ -136,19 +145,19 @@ class TestFilesMakeDir:
|
|||||||
json={
|
json={
|
||||||
"entry": {
|
"entry": {
|
||||||
"name": "data",
|
"name": "data",
|
||||||
"path": "/home/user/data",
|
"path": "/home/wrenn-user/data",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"size": 4096,
|
"size": 4096,
|
||||||
"mode": 16877,
|
"mode": 16877,
|
||||||
"permissions": "drwxr-xr-x",
|
"permissions": "drwxr-xr-x",
|
||||||
"owner": "root",
|
"owner": "wrenn-user",
|
||||||
"group": "root",
|
"group": "wrenn-user",
|
||||||
"modified_at": 1712899200,
|
"modified_at": 1712899200,
|
||||||
"symlink_target": None,
|
"symlink_target": None,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
entry = cap.files.make_dir("/home/user/data")
|
entry = cap.files.make_dir("/home/wrenn-user/data")
|
||||||
assert isinstance(entry, FileEntry)
|
assert isinstance(entry, FileEntry)
|
||||||
assert entry.name == "data"
|
assert entry.name == "data"
|
||||||
assert entry.type == "directory"
|
assert entry.type == "directory"
|
||||||
@ -166,20 +175,20 @@ class TestFilesMakeDir:
|
|||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"name": "data",
|
"name": "data",
|
||||||
"path": "/home/user/data",
|
"path": "/home/wrenn-user/data",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"size": 4096,
|
"size": 4096,
|
||||||
"mode": 16877,
|
"mode": 16877,
|
||||||
"permissions": "drwxr-xr-x",
|
"permissions": "drwxr-xr-x",
|
||||||
"owner": "root",
|
"owner": "wrenn-user",
|
||||||
"group": "root",
|
"group": "wrenn-user",
|
||||||
"modified_at": 1712899200,
|
"modified_at": 1712899200,
|
||||||
"symlink_target": None,
|
"symlink_target": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
entry = cap.files.make_dir("/home/user/data")
|
entry = cap.files.make_dir("/home/wrenn-user/data")
|
||||||
assert entry.name == "data"
|
assert entry.name == "data"
|
||||||
|
|
||||||
|
|
||||||
@ -188,7 +197,7 @@ class TestFilesRemove:
|
|||||||
def test_remove_succeeds(self):
|
def test_remove_succeeds(self):
|
||||||
cap = _make_capsule()
|
cap = _make_capsule()
|
||||||
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204)
|
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204)
|
||||||
cap.files.remove("/home/user/old_data")
|
cap.files.remove("/home/wrenn-user/old_data")
|
||||||
assert route.called
|
assert route.called
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
@ -226,50 +235,37 @@ class TestFilesExists:
|
|||||||
class TestPtyEventParsing:
|
class TestPtyEventParsing:
|
||||||
def test_started_event(self):
|
def test_started_event(self):
|
||||||
raw = {"type": "started", "tag": "pty-a1b2c3d4", "pid": 42}
|
raw = {"type": "started", "tag": "pty-a1b2c3d4", "pid": 42}
|
||||||
event = _parse_pty_event(raw)
|
event = _parse_control_event(raw)
|
||||||
assert event.type == PtyEventType.started
|
assert event.type == PtyEventType.started
|
||||||
assert event.pid == 42
|
assert event.pid == 42
|
||||||
assert event.tag == "pty-a1b2c3d4"
|
assert event.tag == "pty-a1b2c3d4"
|
||||||
|
|
||||||
def test_output_event_base64(self):
|
|
||||||
encoded = base64.b64encode(b"ls -la\n").decode()
|
|
||||||
raw = {"type": "output", "data": encoded}
|
|
||||||
event = _parse_pty_event(raw)
|
|
||||||
assert event.type == PtyEventType.output
|
|
||||||
assert event.data == b"ls -la\n"
|
|
||||||
|
|
||||||
def test_output_event_empty(self):
|
|
||||||
raw = {"type": "output", "data": ""}
|
|
||||||
event = _parse_pty_event(raw)
|
|
||||||
assert event.data == b""
|
|
||||||
|
|
||||||
def test_exit_event(self):
|
def test_exit_event(self):
|
||||||
raw = {"type": "exit", "exit_code": 0}
|
raw = {"type": "exit", "exit_code": 0}
|
||||||
event = _parse_pty_event(raw)
|
event = _parse_control_event(raw)
|
||||||
assert event.type == PtyEventType.exit
|
assert event.type == PtyEventType.exit
|
||||||
assert event.exit_code == 0
|
assert event.exit_code == 0
|
||||||
|
|
||||||
def test_error_event(self):
|
def test_error_event(self):
|
||||||
raw = {"type": "error", "data": "process not found", "fatal": True}
|
raw = {"type": "error", "data": "process not found", "fatal": True}
|
||||||
event = _parse_pty_event(raw)
|
event = _parse_control_event(raw)
|
||||||
assert event.type == PtyEventType.error
|
assert event.type == PtyEventType.error
|
||||||
assert event.data == "process not found"
|
assert event.data == "process not found"
|
||||||
assert event.fatal is True
|
assert event.fatal is True
|
||||||
|
|
||||||
def test_ping_event(self):
|
def test_ping_event(self):
|
||||||
raw = {"type": "ping"}
|
raw = {"type": "ping"}
|
||||||
event = _parse_pty_event(raw)
|
event = _parse_control_event(raw)
|
||||||
assert event.type == PtyEventType.ping
|
assert event.type == PtyEventType.ping
|
||||||
|
|
||||||
|
|
||||||
class TestPtySessionWrite:
|
class TestPtySessionWrite:
|
||||||
def test_write_sends_base64_input(self):
|
def test_write_sends_binary_frame(self):
|
||||||
ws = MagicMock()
|
ws = MagicMock()
|
||||||
session = PtySession(ws, "cl-abc")
|
session = PtySession(ws, "cl-abc")
|
||||||
session.write(b"ls -la\n")
|
session.write(b"ls -la\n")
|
||||||
sent = json.loads(ws.send_text.call_args[0][0])
|
ws.send_bytes.assert_called_once_with(b"ls -la\n")
|
||||||
assert sent["type"] == "input"
|
ws.send_text.assert_not_called()
|
||||||
assert base64.b64decode(sent["data"]) == b"ls -la\n"
|
|
||||||
|
|
||||||
|
|
||||||
class TestPtySessionResize:
|
class TestPtySessionResize:
|
||||||
@ -303,12 +299,11 @@ class TestPtySessionKill:
|
|||||||
class TestPtySessionIteration:
|
class TestPtySessionIteration:
|
||||||
def test_iter_yields_events_until_exit(self):
|
def test_iter_yields_events_until_exit(self):
|
||||||
ws = MagicMock()
|
ws = MagicMock()
|
||||||
messages = [
|
ws.receive.side_effect = [
|
||||||
json.dumps({"type": "started", "tag": "pty-abc12345", "pid": 1}),
|
_text({"type": "started", "tag": "pty-abc12345", "pid": 1}),
|
||||||
json.dumps({"type": "output", "data": base64.b64encode(b"hello").decode()}),
|
_bytes(b"hello"),
|
||||||
json.dumps({"type": "exit", "exit_code": 0}),
|
_text({"type": "exit", "exit_code": 0}),
|
||||||
]
|
]
|
||||||
ws.receive_text.side_effect = messages
|
|
||||||
session = PtySession(ws, "cl-abc")
|
session = PtySession(ws, "cl-abc")
|
||||||
events = list(session)
|
events = list(session)
|
||||||
assert len(events) == 3
|
assert len(events) == 3
|
||||||
@ -320,12 +315,22 @@ class TestPtySessionIteration:
|
|||||||
assert events[2].type == PtyEventType.exit
|
assert events[2].type == PtyEventType.exit
|
||||||
assert events[2].exit_code == 0
|
assert events[2].exit_code == 0
|
||||||
|
|
||||||
|
def test_iter_yields_empty_binary_frame(self):
|
||||||
|
ws = MagicMock()
|
||||||
|
ws.receive.side_effect = [
|
||||||
|
_bytes(b""),
|
||||||
|
_text({"type": "exit", "exit_code": 0}),
|
||||||
|
]
|
||||||
|
session = PtySession(ws, "cl-abc")
|
||||||
|
events = list(session)
|
||||||
|
assert events[0].type == PtyEventType.output
|
||||||
|
assert events[0].data == b""
|
||||||
|
|
||||||
def test_iter_stops_on_fatal_error(self):
|
def test_iter_stops_on_fatal_error(self):
|
||||||
ws = MagicMock()
|
ws = MagicMock()
|
||||||
messages = [
|
ws.receive.side_effect = [
|
||||||
json.dumps({"type": "error", "data": "fatal", "fatal": True}),
|
_text({"type": "error", "data": "fatal", "fatal": True}),
|
||||||
]
|
]
|
||||||
ws.receive_text.side_effect = messages
|
|
||||||
session = PtySession(ws, "cl-abc")
|
session = PtySession(ws, "cl-abc")
|
||||||
events = list(session)
|
events = list(session)
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
@ -335,7 +340,7 @@ class TestPtySessionIteration:
|
|||||||
import httpx_ws
|
import httpx_ws
|
||||||
|
|
||||||
ws = MagicMock()
|
ws = MagicMock()
|
||||||
ws.receive_text.side_effect = httpx_ws.WebSocketDisconnect()
|
ws.receive.side_effect = httpx_ws.WebSocketDisconnect()
|
||||||
session = PtySession(ws, "cl-abc")
|
session = PtySession(ws, "cl-abc")
|
||||||
events = list(session)
|
events = list(session)
|
||||||
assert events == []
|
assert events == []
|
||||||
@ -344,9 +349,9 @@ class TestPtySessionIteration:
|
|||||||
class TestPtySessionPong:
|
class TestPtySessionPong:
|
||||||
def test_ping_triggers_pong(self):
|
def test_ping_triggers_pong(self):
|
||||||
ws = MagicMock()
|
ws = MagicMock()
|
||||||
ws.receive_text.side_effect = [
|
ws.receive.side_effect = [
|
||||||
json.dumps({"type": "ping"}),
|
_text({"type": "ping"}),
|
||||||
json.dumps({"type": "exit", "exit_code": 0}),
|
_text({"type": "exit", "exit_code": 0}),
|
||||||
]
|
]
|
||||||
session = PtySession(ws, "cl-abc")
|
session = PtySession(ws, "cl-abc")
|
||||||
events = list(session)
|
events = list(session)
|
||||||
@ -356,9 +361,9 @@ class TestPtySessionPong:
|
|||||||
|
|
||||||
def test_no_pong_without_ping(self):
|
def test_no_pong_without_ping(self):
|
||||||
ws = MagicMock()
|
ws = MagicMock()
|
||||||
ws.receive_text.side_effect = [
|
ws.receive.side_effect = [
|
||||||
json.dumps({"type": "output", "data": ""}),
|
_bytes(b""),
|
||||||
json.dumps({"type": "exit", "exit_code": 0}),
|
_text({"type": "exit", "exit_code": 0}),
|
||||||
]
|
]
|
||||||
session = PtySession(ws, "cl-abc")
|
session = PtySession(ws, "cl-abc")
|
||||||
list(session)
|
list(session)
|
||||||
@ -411,7 +416,7 @@ class TestPtySessionSendStart:
|
|||||||
cols=120,
|
cols=120,
|
||||||
rows=40,
|
rows=40,
|
||||||
envs={"TERM": "xterm-256color"},
|
envs={"TERM": "xterm-256color"},
|
||||||
cwd="/home/user",
|
cwd="/home/wrenn-user",
|
||||||
)
|
)
|
||||||
sent = json.loads(ws.send_text.call_args[0][0])
|
sent = json.loads(ws.send_text.call_args[0][0])
|
||||||
assert sent["cmd"] == "/bin/zsh"
|
assert sent["cmd"] == "/bin/zsh"
|
||||||
@ -431,13 +436,12 @@ class TestPtySessionSendConnect:
|
|||||||
|
|
||||||
class TestAsyncPtySession:
|
class TestAsyncPtySession:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_write_sends_base64(self):
|
async def test_async_write_sends_binary_frame(self):
|
||||||
ws = AsyncMock()
|
ws = AsyncMock()
|
||||||
session = AsyncPtySession(ws, "cl-abc")
|
session = AsyncPtySession(ws, "cl-abc")
|
||||||
await session.write(b"hello")
|
await session.write(b"hello")
|
||||||
sent = json.loads(ws.send_text.call_args[0][0])
|
ws.send_bytes.assert_awaited_once_with(b"hello")
|
||||||
assert sent["type"] == "input"
|
ws.send_text.assert_not_called()
|
||||||
assert base64.b64decode(sent["data"]) == b"hello"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_resize(self):
|
async def test_async_resize(self):
|
||||||
@ -486,9 +490,9 @@ class TestAsyncPtySession:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_ping_triggers_pong(self):
|
async def test_async_ping_triggers_pong(self):
|
||||||
ws = AsyncMock()
|
ws = AsyncMock()
|
||||||
ws.receive_text.side_effect = [
|
ws.receive.side_effect = [
|
||||||
json.dumps({"type": "ping"}),
|
_text({"type": "ping"}),
|
||||||
json.dumps({"type": "exit", "exit_code": 0}),
|
_text({"type": "exit", "exit_code": 0}),
|
||||||
]
|
]
|
||||||
session = AsyncPtySession(ws, "cl-abc")
|
session = AsyncPtySession(ws, "cl-abc")
|
||||||
events = [e async for e in session]
|
events = [e async for e in session]
|
||||||
@ -508,12 +512,11 @@ class TestAsyncPtySession:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_iteration(self):
|
async def test_async_iteration(self):
|
||||||
ws = AsyncMock()
|
ws = AsyncMock()
|
||||||
messages = [
|
ws.receive.side_effect = [
|
||||||
json.dumps({"type": "started", "tag": "pty-xyz", "pid": 5}),
|
_text({"type": "started", "tag": "pty-xyz", "pid": 5}),
|
||||||
json.dumps({"type": "output", "data": base64.b64encode(b"hi").decode()}),
|
_bytes(b"hi"),
|
||||||
json.dumps({"type": "exit", "exit_code": 0}),
|
_text({"type": "exit", "exit_code": 0}),
|
||||||
]
|
]
|
||||||
ws.receive_text.side_effect = messages
|
|
||||||
session = AsyncPtySession(ws, "cl-abc")
|
session = AsyncPtySession(ws, "cl-abc")
|
||||||
events = []
|
events = []
|
||||||
async for event in session:
|
async for event in session:
|
||||||
@ -522,6 +525,8 @@ class TestAsyncPtySession:
|
|||||||
assert events[0].type == PtyEventType.started
|
assert events[0].type == PtyEventType.started
|
||||||
assert session.tag == "pty-xyz"
|
assert session.tag == "pty-xyz"
|
||||||
assert session.pid == 5
|
assert session.pid == 5
|
||||||
|
assert events[1].type == PtyEventType.output
|
||||||
|
assert events[1].data == b"hi"
|
||||||
assert events[2].type == PtyEventType.exit
|
assert events[2].type == PtyEventType.exit
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -988,14 +988,14 @@ class TestAsyncGit:
|
|||||||
assert len(branches) == 2
|
assert len(branches) == 2
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════<EFBFBD><EFBFBD>═════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════
|
||||||
# Command payload tests — verify /bin/sh -c wrapping
|
# Command payload tests — verify cmd passed verbatim, no sh -c wrap
|
||||||
# ════════════════════════════<EFBFBD><EFBFBD><EFBFBD>══════════════════════<EFBFBD><EFBFBD><EFBFBD>══════════════
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
class TestCommandPayloadWrapping:
|
class TestCommandPayloadWrapping:
|
||||||
"""Verify that Commands.run sends cmd=/bin/sh args=['-c', cmd_string]
|
"""Verify that Commands.run sends the full command string as ``cmd``
|
||||||
so the server-side wrapper expands "${@}" into proper argv."""
|
with no auto ``/bin/sh -c`` wrapping or args splitting."""
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_simple_command(self):
|
def test_simple_command(self):
|
||||||
@ -1005,15 +1005,13 @@ class TestCommandPayloadWrapping:
|
|||||||
git = _make_git()
|
git = _make_git()
|
||||||
git.init("/repo")
|
git.init("/repo")
|
||||||
body = json.loads(route.calls[0].request.content)
|
body = json.loads(route.calls[0].request.content)
|
||||||
assert body["cmd"] == "/bin/sh"
|
assert "args" not in body
|
||||||
assert body["args"] == ["-c", git_cmd_from_body(body)]
|
assert "git" in body["cmd"]
|
||||||
# args[1] should contain the actual git command
|
assert "init" in body["cmd"]
|
||||||
assert body["args"][0] == "-c"
|
|
||||||
assert "git" in body["args"][1]
|
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_command_with_pipes(self):
|
def test_command_with_pipes(self):
|
||||||
"""Pipes and redirects work because /bin/sh interprets them."""
|
"""Shell metacharacters are forwarded verbatim — caller owns shell semantics."""
|
||||||
from wrenn.client import WrennClient
|
from wrenn.client import WrennClient
|
||||||
from wrenn.commands import Commands
|
from wrenn.commands import Commands
|
||||||
|
|
||||||
@ -1023,8 +1021,8 @@ class TestCommandPayloadWrapping:
|
|||||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response(stdout="3\n"))
|
route = respx.post(EXEC_URL).respond(200, json=_exec_response(stdout="3\n"))
|
||||||
commands.run("cat /etc/passwd | wc -l")
|
commands.run("cat /etc/passwd | wc -l")
|
||||||
body = json.loads(route.calls[0].request.content)
|
body = json.loads(route.calls[0].request.content)
|
||||||
assert body["cmd"] == "/bin/sh"
|
assert body["cmd"] == "cat /etc/passwd | wc -l"
|
||||||
assert body["args"] == ["-c", "cat /etc/passwd | wc -l"]
|
assert "args" not in body
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_command_with_semicolons(self):
|
def test_command_with_semicolons(self):
|
||||||
@ -1037,8 +1035,8 @@ class TestCommandPayloadWrapping:
|
|||||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
||||||
commands.run("cd /tmp; ls -la && echo done")
|
commands.run("cd /tmp; ls -la && echo done")
|
||||||
body = json.loads(route.calls[0].request.content)
|
body = json.loads(route.calls[0].request.content)
|
||||||
assert body["cmd"] == "/bin/sh"
|
assert body["cmd"] == "cd /tmp; ls -la && echo done"
|
||||||
assert body["args"] == ["-c", "cd /tmp; ls -la && echo done"]
|
assert "args" not in body
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_command_with_env_vars(self):
|
def test_command_with_env_vars(self):
|
||||||
@ -1051,8 +1049,8 @@ class TestCommandPayloadWrapping:
|
|||||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
||||||
commands.run("FOO=bar echo $FOO")
|
commands.run("FOO=bar echo $FOO")
|
||||||
body = json.loads(route.calls[0].request.content)
|
body = json.loads(route.calls[0].request.content)
|
||||||
assert body["cmd"] == "/bin/sh"
|
assert body["cmd"] == "FOO=bar echo $FOO"
|
||||||
assert body["args"] == ["-c", "FOO=bar echo $FOO"]
|
assert "args" not in body
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_command_with_subshell(self):
|
def test_command_with_subshell(self):
|
||||||
@ -1065,8 +1063,8 @@ class TestCommandPayloadWrapping:
|
|||||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
||||||
commands.run("echo $(date +%s)")
|
commands.run("echo $(date +%s)")
|
||||||
body = json.loads(route.calls[0].request.content)
|
body = json.loads(route.calls[0].request.content)
|
||||||
assert body["cmd"] == "/bin/sh"
|
assert body["cmd"] == "echo $(date +%s)"
|
||||||
assert body["args"] == ["-c", "echo $(date +%s)"]
|
assert "args" not in body
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_command_with_quotes_and_spaces(self):
|
def test_command_with_quotes_and_spaces(self):
|
||||||
@ -1079,10 +1077,8 @@ class TestCommandPayloadWrapping:
|
|||||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
||||||
commands.run("""echo "hello 'world'" | grep -o "'[^']*'" """)
|
commands.run("""echo "hello 'world'" | grep -o "'[^']*'" """)
|
||||||
body = json.loads(route.calls[0].request.content)
|
body = json.loads(route.calls[0].request.content)
|
||||||
assert body["cmd"] == "/bin/sh"
|
assert "args" not in body
|
||||||
assert body["args"][0] == "-c"
|
assert "hello 'world'" in body["cmd"]
|
||||||
# The command string is passed verbatim — shell interprets it
|
|
||||||
assert "hello 'world'" in body["args"][1]
|
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_heredoc_style_command(self):
|
def test_heredoc_style_command(self):
|
||||||
@ -1095,20 +1091,18 @@ class TestCommandPayloadWrapping:
|
|||||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
||||||
commands.run("python3 -c 'import sys; print(sys.version)'")
|
commands.run("python3 -c 'import sys; print(sys.version)'")
|
||||||
body = json.loads(route.calls[0].request.content)
|
body = json.loads(route.calls[0].request.content)
|
||||||
assert body["cmd"] == "/bin/sh"
|
assert body["cmd"] == "python3 -c 'import sys; print(sys.version)'"
|
||||||
assert body["args"] == ["-c", "python3 -c 'import sys; print(sys.version)'"]
|
assert "args" not in body
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_git_shlex_joined_command(self):
|
def test_git_shlex_joined_command(self):
|
||||||
"""Git module uses shlex.join — verify it passes through correctly."""
|
"""Git module uses shlex.join — verify it passes through as cmd."""
|
||||||
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
|
||||||
git = _make_git()
|
git = _make_git()
|
||||||
git.clone("https://github.com/user/repo.git", "/tmp/repo", depth=1)
|
git.clone("https://github.com/user/repo.git", "/tmp/repo", depth=1)
|
||||||
body = json.loads(route.calls[0].request.content)
|
body = json.loads(route.calls[0].request.content)
|
||||||
assert body["cmd"] == "/bin/sh"
|
assert "args" not in body
|
||||||
assert body["args"][0] == "-c"
|
shell_cmd = body["cmd"]
|
||||||
# shlex.join produces: git clone --depth 1 https://... /tmp/repo
|
|
||||||
shell_cmd = body["args"][1]
|
|
||||||
assert "git" in shell_cmd
|
assert "git" in shell_cmd
|
||||||
assert "clone" in shell_cmd
|
assert "clone" in shell_cmd
|
||||||
assert "--depth" in shell_cmd
|
assert "--depth" in shell_cmd
|
||||||
@ -1125,13 +1119,12 @@ class TestCommandPayloadWrapping:
|
|||||||
route = respx.post(EXEC_URL).respond(200, json={"pid": 42, "tag": "bg-1"})
|
route = respx.post(EXEC_URL).respond(200, json={"pid": 42, "tag": "bg-1"})
|
||||||
commands.run("tail -f /var/log/syslog", background=True)
|
commands.run("tail -f /var/log/syslog", background=True)
|
||||||
body = json.loads(route.calls[0].request.content)
|
body = json.loads(route.calls[0].request.content)
|
||||||
assert body["cmd"] == "/bin/sh"
|
assert body["cmd"] == "tail -f /var/log/syslog"
|
||||||
assert body["args"] == ["-c", "tail -f /var/log/syslog"]
|
assert "args" not in body
|
||||||
assert body["background"] is True
|
assert body["background"] is True
|
||||||
|
|
||||||
|
|
||||||
def git_cmd_from_body(body: dict) -> str:
|
def git_cmd_from_body(body: dict) -> str:
|
||||||
"""Extract the shell command string from a wrapped payload."""
|
"""Extract the command string from the payload."""
|
||||||
assert body["cmd"] == "/bin/sh"
|
assert "args" not in body
|
||||||
assert body["args"][0] == "-c"
|
return body["cmd"]
|
||||||
return body["args"][1]
|
|
||||||
|
|||||||
@ -7,8 +7,18 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from wrenn import Capsule, CommandResult
|
from wrenn import Capsule, CommandResult
|
||||||
|
from wrenn.client import WrennClient
|
||||||
from wrenn.commands import CommandHandle, ProcessInfo
|
from wrenn.commands import CommandHandle, ProcessInfo
|
||||||
from wrenn.models import Capsule as CapsuleModel, FileEntry, Status
|
from wrenn.exceptions import WrennNotFoundError
|
||||||
|
from wrenn.models import (
|
||||||
|
Capsule as CapsuleModel,
|
||||||
|
CapsuleMetrics,
|
||||||
|
CapsuleStats,
|
||||||
|
FileEntry,
|
||||||
|
SSEEvent,
|
||||||
|
Status,
|
||||||
|
UsageResponse,
|
||||||
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
@ -323,7 +333,7 @@ class TestFiles:
|
|||||||
class TestGit:
|
class TestGit:
|
||||||
"""Shared capsule for git operation tests.
|
"""Shared capsule for git operation tests.
|
||||||
|
|
||||||
Initializes a repo at /root (default cwd) since the exec API
|
Initializes a repo at /home/wrenn-user (default cwd) since the exec API
|
||||||
does not support the cwd parameter.
|
does not support the cwd parameter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -344,14 +354,14 @@ class TestGit:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def test_init_created_repo(self):
|
def test_init_created_repo(self):
|
||||||
assert self.capsule.files.exists("/root/.git")
|
assert self.capsule.files.exists("/home/wrenn-user/.git")
|
||||||
|
|
||||||
def test_status_clean(self):
|
def test_status_clean(self):
|
||||||
status = self.capsule.git.status()
|
status = self.capsule.git.status()
|
||||||
assert status.branch == "main"
|
assert status.branch == "main"
|
||||||
|
|
||||||
def test_add_and_commit(self):
|
def test_add_and_commit(self):
|
||||||
self.capsule.files.write("/root/hello.txt", "hello git")
|
self.capsule.files.write("/home/wrenn-user/hello.txt", "hello git")
|
||||||
self.capsule.git.add(all=True)
|
self.capsule.git.add(all=True)
|
||||||
result = self.capsule.git.commit("initial commit")
|
result = self.capsule.git.commit("initial commit")
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@ -361,14 +371,14 @@ class TestGit:
|
|||||||
assert status.is_clean
|
assert status.is_clean
|
||||||
|
|
||||||
def test_status_with_changes(self):
|
def test_status_with_changes(self):
|
||||||
self.capsule.files.write("/root/dirty.txt", "uncommitted")
|
self.capsule.files.write("/home/wrenn-user/dirty.txt", "uncommitted")
|
||||||
try:
|
try:
|
||||||
status = self.capsule.git.status()
|
status = self.capsule.git.status()
|
||||||
assert not status.is_clean
|
assert not status.is_clean
|
||||||
paths = [f.path for f in status.files]
|
paths = [f.path for f in status.files]
|
||||||
assert "dirty.txt" in paths
|
assert "dirty.txt" in paths
|
||||||
finally:
|
finally:
|
||||||
self.capsule.files.remove("/root/dirty.txt")
|
self.capsule.files.remove("/home/wrenn-user/dirty.txt")
|
||||||
|
|
||||||
def test_branches(self):
|
def test_branches(self):
|
||||||
branches = self.capsule.git.branches()
|
branches = self.capsule.git.branches()
|
||||||
@ -406,3 +416,124 @@ class TestGit:
|
|||||||
def test_get_config_missing_returns_none(self):
|
def test_get_config_missing_returns_none(self):
|
||||||
value = self.capsule.git.get_config("nonexistent.key")
|
value = self.capsule.git.get_config("nonexistent.key")
|
||||||
assert value is None
|
assert value is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatsUsageMetrics:
|
||||||
|
"""Account-scoped + per-capsule observability endpoints."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
_ensure_env()
|
||||||
|
|
||||||
|
def test_stats_default(self):
|
||||||
|
with WrennClient() as client:
|
||||||
|
stats = client.capsules.stats()
|
||||||
|
assert isinstance(stats, CapsuleStats)
|
||||||
|
# current.running_count is always present even when zero
|
||||||
|
assert stats.current is not None
|
||||||
|
assert stats.peaks is not None
|
||||||
|
|
||||||
|
def test_stats_range(self):
|
||||||
|
with WrennClient() as client:
|
||||||
|
stats = client.capsules.stats(range="24h")
|
||||||
|
assert isinstance(stats, CapsuleStats)
|
||||||
|
if stats.range is not None:
|
||||||
|
assert stats.range.value == "24h"
|
||||||
|
|
||||||
|
def test_usage_no_args(self):
|
||||||
|
with WrennClient() as client:
|
||||||
|
usage = client.capsules.usage()
|
||||||
|
assert isinstance(usage, UsageResponse)
|
||||||
|
assert usage.points is not None
|
||||||
|
|
||||||
|
def test_usage_date_range(self):
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
with WrennClient() as client:
|
||||||
|
usage = client.capsules.usage(from_=today - timedelta(days=7), to=today)
|
||||||
|
assert isinstance(usage, UsageResponse)
|
||||||
|
|
||||||
|
def test_metrics_running_capsule(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
with WrennClient() as client:
|
||||||
|
m = client.capsules.metrics(capsule.capsule_id)
|
||||||
|
assert isinstance(m, CapsuleMetrics)
|
||||||
|
assert m.sandbox_id == capsule.capsule_id
|
||||||
|
assert m.points is not None
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_metrics_range_2h(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
with WrennClient() as client:
|
||||||
|
m = client.capsules.metrics(capsule.capsule_id, range="2h")
|
||||||
|
assert isinstance(m, CapsuleMetrics)
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_metrics_destroyed_capsule_raises(self):
|
||||||
|
# Create, destroy, then ask for metrics — guaranteed valid-shape ID
|
||||||
|
# that no longer exists. Server returns 404 for the missing capsule
|
||||||
|
# (avoids the 400 validation path for malformed IDs).
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
capsule_id = capsule.capsule_id
|
||||||
|
capsule.destroy(wait=True)
|
||||||
|
|
||||||
|
with WrennClient() as client:
|
||||||
|
with pytest.raises(WrennNotFoundError):
|
||||||
|
client.capsules.metrics(capsule_id)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventsStream:
|
||||||
|
"""SSE lifecycle stream — drives a capsule create and watches for it."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
_ensure_env()
|
||||||
|
|
||||||
|
def test_stream_receives_capsule_create_event(self):
|
||||||
|
import threading
|
||||||
|
|
||||||
|
seen: list[SSEEvent] = []
|
||||||
|
ready = threading.Event()
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
def _reader() -> None:
|
||||||
|
with WrennClient() as client:
|
||||||
|
stream = client.events.stream()
|
||||||
|
ready.set()
|
||||||
|
for ev in stream:
|
||||||
|
seen.append(ev)
|
||||||
|
if stop.is_set() or len(seen) > 20:
|
||||||
|
return
|
||||||
|
|
||||||
|
t = threading.Thread(target=_reader, daemon=True)
|
||||||
|
t.start()
|
||||||
|
assert ready.wait(timeout=5)
|
||||||
|
|
||||||
|
# Give the server a beat to fully open the SSE stream before
|
||||||
|
# the action that should appear on it.
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
capsule = Capsule()
|
||||||
|
capsule_id = capsule.capsule_id
|
||||||
|
try:
|
||||||
|
capsule.wait_ready(timeout=60)
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
# Wait for either our capsule's event or a timeout.
|
||||||
|
deadline = time.monotonic() + 15
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if any(
|
||||||
|
ev.resource is not None and ev.resource.id == capsule_id for ev in seen
|
||||||
|
):
|
||||||
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
stop.set()
|
||||||
|
assert any(isinstance(ev, SSEEvent) for ev in seen)
|
||||||
|
# Best-effort: assert we saw a capsule.* event for our capsule.
|
||||||
|
matching = [ev for ev in seen if ev.resource and ev.resource.id == capsule_id]
|
||||||
|
assert matching, f"no events for {capsule_id} in {len(seen)} received"
|
||||||
|
|||||||
@ -75,7 +75,7 @@ class TestCommandEnvironment:
|
|||||||
|
|
||||||
def test_default_cwd_is_home(self):
|
def test_default_cwd_is_home(self):
|
||||||
result = self.capsule.commands.run("pwd")
|
result = self.capsule.commands.run("pwd")
|
||||||
assert result.stdout.strip() == "/root"
|
assert result.stdout.strip() == "/home/wrenn-user"
|
||||||
|
|
||||||
def test_cwd_resolves_relative_paths(self):
|
def test_cwd_resolves_relative_paths(self):
|
||||||
self.capsule.files.make_dir("/tmp/cwd_probe/sub")
|
self.capsule.files.make_dir("/tmp/cwd_probe/sub")
|
||||||
@ -90,7 +90,7 @@ class TestCommandEnvironment:
|
|||||||
# Each run is a fresh process — `cd` in one does not affect the next.
|
# Each run is a fresh process — `cd` in one does not affect the next.
|
||||||
self.capsule.commands.run("cd /tmp")
|
self.capsule.commands.run("cd /tmp")
|
||||||
result = self.capsule.commands.run("pwd")
|
result = self.capsule.commands.run("pwd")
|
||||||
assert result.stdout.strip() == "/root"
|
assert result.stdout.strip() == "/home/wrenn-user"
|
||||||
|
|
||||||
def test_single_env_var(self):
|
def test_single_env_var(self):
|
||||||
result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"})
|
result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"})
|
||||||
@ -115,9 +115,29 @@ class TestCommandEnvironment:
|
|||||||
def test_base_environment_present(self):
|
def test_base_environment_present(self):
|
||||||
result = self.capsule.commands.run("echo $HOME; echo $PATH")
|
result = self.capsule.commands.run("echo $HOME; echo $PATH")
|
||||||
lines = result.stdout.strip().splitlines()
|
lines = result.stdout.strip().splitlines()
|
||||||
assert lines[0] == "/root"
|
assert lines[0] == "/home/wrenn-user"
|
||||||
assert "/usr/bin" in lines[1]
|
assert "/usr/bin" in lines[1]
|
||||||
|
|
||||||
|
def test_sudo_available(self):
|
||||||
|
result = self.capsule.commands.run("which sudo")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_sudo_runs_without_password(self):
|
||||||
|
result = self.capsule.commands.run("sudo whoami")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "root"
|
||||||
|
|
||||||
|
def test_sudo_can_write_to_protected_path(self):
|
||||||
|
result = self.capsule.commands.run(
|
||||||
|
"sudo touch /opt/sudo-test-marker && cat /opt/sudo-test-marker"
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_sudo_can_read_root_owned_file(self):
|
||||||
|
result = self.capsule.commands.run("sudo cat /etc/shadow | head -1")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "root" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════
|
||||||
# Long-running commands
|
# Long-running commands
|
||||||
@ -143,7 +163,7 @@ class TestLongRunningCommands:
|
|||||||
|
|
||||||
def test_apt_get_install(self):
|
def test_apt_get_install(self):
|
||||||
result = self.capsule.commands.run(
|
result = self.capsule.commands.run(
|
||||||
"apt-get update -qq && apt-get install -y -qq cowsay", timeout=300
|
"sudo apt-get update -qq && sudo apt-get install -y -qq cowsay", timeout=300
|
||||||
)
|
)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
@ -388,7 +408,9 @@ class TestGitClone:
|
|||||||
def setup_class(cls):
|
def setup_class(cls):
|
||||||
_ensure_env()
|
_ensure_env()
|
||||||
cls.capsule = Capsule(wait=True)
|
cls.capsule = Capsule(wait=True)
|
||||||
cls.capsule.git.clone(WRENN_REPO, "/root/wrenn", depth=1, timeout=300)
|
cls.capsule.git.clone(
|
||||||
|
WRENN_REPO, "/home/wrenn-user/wrenn", depth=1, timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def teardown_class(cls):
|
def teardown_class(cls):
|
||||||
@ -398,66 +420,74 @@ class TestGitClone:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def test_clone_created_repo(self):
|
def test_clone_created_repo(self):
|
||||||
assert self.capsule.files.exists("/root/wrenn/.git")
|
assert self.capsule.files.exists("/home/wrenn-user/wrenn/.git")
|
||||||
|
|
||||||
def test_clone_checked_out_files(self):
|
def test_clone_checked_out_files(self):
|
||||||
entries = self.capsule.files.list("/root/wrenn")
|
entries = self.capsule.files.list("/home/wrenn-user/wrenn")
|
||||||
names = [e.name for e in entries]
|
names = [e.name for e in entries]
|
||||||
assert "README.md" in names
|
assert "README.md" in names
|
||||||
|
|
||||||
def test_status_of_clone_is_clean(self):
|
def test_status_of_clone_is_clean(self):
|
||||||
status = self.capsule.git.status(cwd="/root/wrenn")
|
status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
|
||||||
assert status.branch == "main"
|
assert status.branch == "main"
|
||||||
assert status.is_clean
|
assert status.is_clean
|
||||||
|
|
||||||
def test_branches_lists_main(self):
|
def test_branches_lists_main(self):
|
||||||
branches = self.capsule.git.branches(cwd="/root/wrenn")
|
branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn")
|
||||||
names = [b.name for b in branches]
|
names = [b.name for b in branches]
|
||||||
assert "main" in names
|
assert "main" in names
|
||||||
assert any(b.is_current for b in branches)
|
assert any(b.is_current for b in branches)
|
||||||
|
|
||||||
def test_remote_get_origin(self):
|
def test_remote_get_origin(self):
|
||||||
url = self.capsule.git.remote_get("origin", cwd="/root/wrenn")
|
url = self.capsule.git.remote_get("origin", cwd="/home/wrenn-user/wrenn")
|
||||||
assert url is not None
|
assert url is not None
|
||||||
assert "wrennhq/wrenn" in url
|
assert "wrennhq/wrenn" in url
|
||||||
|
|
||||||
def test_git_log_has_commit(self):
|
def test_git_log_has_commit(self):
|
||||||
result = self.capsule.commands.run("git log --oneline -1", cwd="/root/wrenn")
|
result = self.capsule.commands.run(
|
||||||
|
"git log --oneline -1", cwd="/home/wrenn-user/wrenn"
|
||||||
|
)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert result.stdout.strip()
|
assert result.stdout.strip()
|
||||||
|
|
||||||
def test_modify_add_commit(self):
|
def test_modify_add_commit(self):
|
||||||
marker = uuid.uuid4().hex
|
marker = uuid.uuid4().hex
|
||||||
self.capsule.git.configure_user(
|
self.capsule.git.configure_user(
|
||||||
"CI Bot", "ci@example.com", cwd="/root/wrenn", scope="local"
|
"CI Bot", "ci@example.com", cwd="/home/wrenn-user/wrenn", scope="local"
|
||||||
)
|
)
|
||||||
self.capsule.files.write(f"/root/wrenn/sdk_probe_{marker}.txt", marker)
|
self.capsule.files.write(
|
||||||
self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/root/wrenn")
|
f"/home/wrenn-user/wrenn/sdk_probe_{marker}.txt", marker
|
||||||
|
)
|
||||||
|
self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/home/wrenn-user/wrenn")
|
||||||
|
|
||||||
staged = self.capsule.git.status(cwd="/root/wrenn")
|
staged = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
|
||||||
assert staged.has_staged
|
assert staged.has_staged
|
||||||
|
|
||||||
result = self.capsule.git.commit("probe commit", cwd="/root/wrenn")
|
result = self.capsule.git.commit("probe commit", cwd="/home/wrenn-user/wrenn")
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
after = self.capsule.git.status(cwd="/root/wrenn")
|
after = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
|
||||||
assert after.is_clean
|
assert after.is_clean
|
||||||
assert after.ahead >= 1
|
assert after.ahead >= 1
|
||||||
|
|
||||||
def test_create_and_checkout_branch_in_clone(self):
|
def test_create_and_checkout_branch_in_clone(self):
|
||||||
self.capsule.git.create_branch("sdk-feature", cwd="/root/wrenn")
|
self.capsule.git.create_branch("sdk-feature", cwd="/home/wrenn-user/wrenn")
|
||||||
branches = self.capsule.git.branches(cwd="/root/wrenn")
|
branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn")
|
||||||
current = [b for b in branches if b.is_current]
|
current = [b for b in branches if b.is_current]
|
||||||
assert current and current[0].name == "sdk-feature"
|
assert current and current[0].name == "sdk-feature"
|
||||||
self.capsule.git.checkout_branch("main", cwd="/root/wrenn")
|
self.capsule.git.checkout_branch("main", cwd="/home/wrenn-user/wrenn")
|
||||||
|
|
||||||
def test_diff_via_commands(self):
|
def test_diff_via_commands(self):
|
||||||
self.capsule.files.write("/root/wrenn/README.md", "overwritten\n")
|
self.capsule.files.write("/home/wrenn-user/wrenn/README.md", "overwritten\n")
|
||||||
try:
|
try:
|
||||||
result = self.capsule.commands.run("git diff --stat", cwd="/root/wrenn")
|
result = self.capsule.commands.run(
|
||||||
|
"git diff --stat", cwd="/home/wrenn-user/wrenn"
|
||||||
|
)
|
||||||
assert "README.md" in result.stdout
|
assert "README.md" in result.stdout
|
||||||
finally:
|
finally:
|
||||||
self.capsule.git.restore(["README.md"], worktree=True, cwd="/root/wrenn")
|
self.capsule.git.restore(
|
||||||
|
["README.md"], worktree=True, cwd="/home/wrenn-user/wrenn"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestGitErrors:
|
class TestGitErrors:
|
||||||
@ -481,7 +511,7 @@ class TestGitErrors:
|
|||||||
with pytest.raises(GitError):
|
with pytest.raises(GitError):
|
||||||
self.capsule.git.clone(
|
self.capsule.git.clone(
|
||||||
"https://github.com/wrennhq/this-repo-does-not-exist-xyz",
|
"https://github.com/wrennhq/this-repo-does-not-exist-xyz",
|
||||||
"/root/missing",
|
"/home/wrenn-user/missing",
|
||||||
timeout=120,
|
timeout=120,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -493,7 +523,11 @@ class TestGitErrors:
|
|||||||
|
|
||||||
def test_clone_with_branch(self):
|
def test_clone_with_branch(self):
|
||||||
self.capsule.git.clone(
|
self.capsule.git.clone(
|
||||||
WRENN_REPO, "/root/wrenn-main", branch="main", depth=1, timeout=300
|
WRENN_REPO,
|
||||||
|
"/home/wrenn-user/wrenn-main",
|
||||||
|
branch="main",
|
||||||
|
depth=1,
|
||||||
|
timeout=300,
|
||||||
)
|
)
|
||||||
status = self.capsule.git.status(cwd="/root/wrenn-main")
|
status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn-main")
|
||||||
assert status.branch == "main"
|
assert status.branch == "main"
|
||||||
|
|||||||
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.1.4"
|
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