Compare commits
18 Commits
feat/modul
...
fce514c49c
| Author | SHA1 | Date | |
|---|---|---|---|
| fce514c49c | |||
| 87cc16e9e2 | |||
| 08f6a1ab84 | |||
| 51c6987515 | |||
| 800a8566db | |||
| e057ec2407 | |||
| e5e4e1a85b | |||
| 6112c71abc | |||
| a42f0b2e71 | |||
| d9c028564e | |||
| 06b4a8cbcb | |||
| 04e5dc652f | |||
| 4a7db8e204 | |||
| a76be96682 | |||
| be573d07a3 | |||
| dc66ac24d5 | |||
| b5e2b12ef1 | |||
| 213af4aee7 |
6
.gitignore
vendored
6
.gitignore
vendored
@ -176,5 +176,9 @@ cython_debug/
|
|||||||
|
|
||||||
CODE_EXECUTION.md
|
CODE_EXECUTION.md
|
||||||
|
|
||||||
.claude/
|
|
||||||
.opencode/
|
.opencode/
|
||||||
|
# AI
|
||||||
|
.code-review-graph/
|
||||||
|
.claude
|
||||||
|
.mcp.json
|
||||||
|
AGENTS.md
|
||||||
|
|||||||
25
.pre-commit-config.yaml
Normal file
25
.pre-commit-config.yaml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.15.10
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
- id: ruff-format
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.20.0
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
additional_dependencies:
|
||||||
|
- pydantic>=2.12.5
|
||||||
|
- httpx>=0.28.1
|
||||||
|
- httpx-ws>=0.9.0
|
||||||
|
- email-validator>=2.3.0
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: unit-tests
|
||||||
|
name: unit tests
|
||||||
|
entry: uv run pytest -m "not integration" -x -q
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
@ -1,5 +1,5 @@
|
|||||||
when:
|
when:
|
||||||
event: push
|
event: pull_request
|
||||||
branch:
|
branch:
|
||||||
- main
|
- main
|
||||||
- dev
|
- dev
|
||||||
|
|||||||
56
AGENTS.md
56
AGENTS.md
@ -1,56 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
## Project
|
|
||||||
|
|
||||||
Wrenn Python SDK — a client library for the Wrenn microVM platform. e2b drop-in replacement.
|
|
||||||
Package name: `wrenn`. Python 3.13+, managed with [uv](https://docs.astral.sh/uv/).
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv sync # install deps
|
|
||||||
make lint # ruff check + format check (no auto-fix)
|
|
||||||
make test # unit tests only (tests/test_client.py)
|
|
||||||
make test-integration # all tests including integration (needs live server)
|
|
||||||
make generate # regenerate models from OpenAPI spec (fetches from remote)
|
|
||||||
make check # lint + unit test
|
|
||||||
```
|
|
||||||
|
|
||||||
- `make test` only runs `tests/test_client.py`, not all unit tests. To run a specific test file: `uv run pytest tests/test_capsule_features.py -v`
|
|
||||||
- No typecheck step in Makefile or CI. `mypy` is a dev dependency but not wired up — do not assume it runs.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- `src/wrenn/` — the library package
|
|
||||||
- `capsule.py` / `async_capsule.py` — high-level `Capsule` / `AsyncCapsule` (main user-facing classes)
|
|
||||||
- `client.py` — low-level `WrennClient` / `AsyncWrennClient`
|
|
||||||
- `commands.py` — command execution and streaming
|
|
||||||
- `files.py` — filesystem operations
|
|
||||||
- `pty.py` — interactive terminal (PTY) over WebSocket
|
|
||||||
- `exceptions.py` — typed error hierarchy (`WrennError` base)
|
|
||||||
- `models/_generated.py` — **auto-generated** from OpenAPI spec via `datamodel-codegen` (never edit directly; run `make generate`)
|
|
||||||
- `sandbox.py` — deprecated `Sandbox` alias for `Capsule`
|
|
||||||
- `code_interpreter/` — specialized capsule for stateful Jupyter kernel execution
|
|
||||||
- `tests/` — unit tests use `respx` to mock `httpx`; integration tests are in `tests/integration/`
|
|
||||||
- `api/openapi.yaml` — downloaded OpenAPI spec used for code generation
|
|
||||||
|
|
||||||
## Key Conventions
|
|
||||||
|
|
||||||
- Generated code lives in `src/wrenn/models/_generated.py`. Never edit it. Run `make generate` to update.
|
|
||||||
- `Sandbox` is a deprecated alias for `Capsule`. New code should use `Capsule` / `AsyncCapsule`.
|
|
||||||
- Dual sync/async API: every major class has an `Async` counterpart.
|
|
||||||
- Uses `httpx` for HTTP, `httpx-ws` for WebSockets, `pydantic` for models.
|
|
||||||
- `__init__.py` uses `__getattr__` for lazy deprecated aliases (`Sandbox`, `WrennHostHasSandboxesError`).
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- Unit tests mock HTTP via `respx` (httpx mocking library).
|
|
||||||
- Integration tests require env vars: `WRENN_API_KEY` (or `WRENN_TOKEN`), optionally `WRENN_BASE_URL`.
|
|
||||||
- Integration test fixtures in `tests/integration/conftest.py` create real capsules and clean them up.
|
|
||||||
- `pytest` marker: `@pytest.mark.integration` for tests needing a live server.
|
|
||||||
|
|
||||||
## CI
|
|
||||||
|
|
||||||
Woodpecker CI (`.woodpecker/check.yml`) runs on push to `main` and `dev`:
|
|
||||||
1. `make lint`
|
|
||||||
2. `make test` (unit tests only — integration tests are not in CI)
|
|
||||||
171
CLAUDE.md
Normal file
171
CLAUDE.md
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
## Design Context
|
||||||
|
|
||||||
|
### Users
|
||||||
|
Developers across the full spectrum — solo engineers building side projects, startup teams integrating sandboxed execution into products, and platform/infra engineers at larger organizations running production workloads on Firecracker microVMs. They arrive with context: they know what a process is, what a rootfs is, what a TTY means. The interface must feel at home for all three: approachable enough not to intimidate a hacker, precise enough to earn the trust of a production ops team. Never condescend, never oversimplify. Trust the user to understand what they're looking at.
|
||||||
|
|
||||||
|
**Primary job to be done:** Understand what's running, act on it confidently, and get back to code.
|
||||||
|
|
||||||
|
### Brand Personality
|
||||||
|
**Precise. Warm. Uncompromising.**
|
||||||
|
|
||||||
|
Wrenn is an engineer's favorite tool — built with visible care, not assembled from defaults. It runs real infrastructure (Firecracker microVMs), so the UI should reflect that seriousness without becoming cold or corporate. The warmth comes from the typography and color palette; the precision comes from hierarchy, density, and data fidelity.
|
||||||
|
|
||||||
|
Emotional goal: **in control.** Users leave a session with full confidence in what's running, what happened, and what comes next. Nothing is hidden, nothing is ambiguous.
|
||||||
|
|
||||||
|
### Aesthetic Direction
|
||||||
|
**Dark-only (permanently), industrial-warm, data-forward.**
|
||||||
|
|
||||||
|
No light mode planned. All design decisions should optimize for dark. The near-black-green background palette (`#0a0c0b` through `#2a302d`) reads as "black with intention" — not pitch black (cold) and not charcoal (dated). The sage green accent (`#5e8c58`) is muted and organic, a meaningful departure from the startup-green neon that saturates the developer tool space.
|
||||||
|
|
||||||
|
**Anti-references:**
|
||||||
|
- **Supabase**: avoid the friendly, approachable startup-green energy — too generic, too eager to please
|
||||||
|
- **AWS / GCP consoles**: avoid utility-first density without craft — functional but joyless, visually dated
|
||||||
|
|
||||||
|
**References that capture the right spirit:**
|
||||||
|
- The precision of a well-calibrated instrument
|
||||||
|
- Editorial typography from technical publications
|
||||||
|
- The quiet confidence of tools that don't need to explain themselves
|
||||||
|
|
||||||
|
### Type System
|
||||||
|
Four fonts with strict roles — this is the design system's strongest personality trait and must be respected:
|
||||||
|
|
||||||
|
| Font | CSS Class | Role | When to use |
|
||||||
|
|------|-----------|------|-------------|
|
||||||
|
| **Manrope** (variable, sans) | `font-sans` | UI workhorse | All body copy, nav, labels, buttons, form text |
|
||||||
|
| **Instrument Serif** | `font-serif` | Display / editorial | Page titles (h1), dialog headings, metric values, hero moments |
|
||||||
|
| **JetBrains Mono** (variable) | `font-mono` | Data / code | IDs, timestamps, key prefixes, file paths, terminal output, metrics |
|
||||||
|
| **Alice** | brand wordmark only | Brand wordmark | "Wrenn" in sidebar and login only — nowhere else |
|
||||||
|
|
||||||
|
Instrument Serif at scale creates the signature editorial moments. Mono provides the precision signal for technical data. Never swap these roles.
|
||||||
|
|
||||||
|
**Tracking overrides (app.css):**
|
||||||
|
- `.font-serif` — `letter-spacing: 0.015em` (positive tracking; Instrument Serif reads less condensed at display sizes)
|
||||||
|
- `.font-mono` — `font-variant-numeric: tabular-nums` (numbers align in tables and metric displays)
|
||||||
|
|
||||||
|
**Type scale (root: 87.5% = 14px base):**
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--text-display` | 2.571rem (~36px) | Auth section headings |
|
||||||
|
| `--text-page` | 2rem (~28px) | Page h1 titles |
|
||||||
|
| `--text-heading` | 1.429rem (~20px) | Dialog headings, empty states |
|
||||||
|
| `--text-body` | 1rem (~14px) | Primary body, buttons, inputs |
|
||||||
|
| `--text-ui` | 0.929rem (~13px) | Nav labels, table cells |
|
||||||
|
| `--text-meta` | 0.857rem (~12px) | Key prefixes, minor info |
|
||||||
|
| `--text-label` | 0.786rem (~11px) | Uppercase section labels |
|
||||||
|
| `--text-badge` | 0.714rem (~10px) | Live badges, tiny indicators |
|
||||||
|
|
||||||
|
### Color System
|
||||||
|
|
||||||
|
All values are CSS custom properties in `frontend/src/app.css`.
|
||||||
|
|
||||||
|
**Backgrounds (6-step near-black-green scale):**
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-bg-0` | `#0a0c0b` | Page base, sidebar deepest layer |
|
||||||
|
| `--color-bg-1` | `#0f1211` | Sidebar surface |
|
||||||
|
| `--color-bg-2` | `#141817` | Card backgrounds |
|
||||||
|
| `--color-bg-3` | `#1a1e1c` | Table headers, elevated surfaces |
|
||||||
|
| `--color-bg-4` | `#212624` | Hover states, inputs |
|
||||||
|
| `--color-bg-5` | `#2a302d` | Highlighted items, selected rows |
|
||||||
|
|
||||||
|
**Text (5-level hierarchy):**
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-text-bright` | `#eae7e2` | H1s, dialog headings |
|
||||||
|
| `--color-text-primary` | `#d0cdc6` | Body copy, primary labels |
|
||||||
|
| `--color-text-secondary` | `#9b9790` | Secondary labels, descriptions |
|
||||||
|
| `--color-text-tertiary` | `#6b6862` | Hints, placeholders |
|
||||||
|
| `--color-text-muted` | `#454340` | Dividers as text, ultra-subtle |
|
||||||
|
|
||||||
|
**Accent (sage green — use sparingly, must feel earned):**
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-accent` | `#5e8c58` | Primary CTA, live indicators, focus rings, active nav |
|
||||||
|
| `--color-accent-mid` | `#89a785` | Hover accent text |
|
||||||
|
| `--color-accent-bright` | `#a4c89f` | Accent on dark backgrounds |
|
||||||
|
| `--color-accent-glow` | `rgba(94,140,88,0.07)` | Subtle tinted backgrounds |
|
||||||
|
| `--color-accent-glow-mid` | `rgba(94,140,88,0.14)` | Hover tint on accent items |
|
||||||
|
|
||||||
|
**Status semantics:**
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-amber` | `#d4a73c` | Warning, paused state |
|
||||||
|
| `--color-red` | `#cf8172` | Error, destructive actions |
|
||||||
|
| `--color-blue` | `#5a9fd4` | Info, neutral system states |
|
||||||
|
|
||||||
|
**Borders:** `--color-border` (`#1f2321`) default; `--color-border-mid` (`#2a2f2c`) for inputs/hover.
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
|
||||||
|
**Buttons:**
|
||||||
|
- Primary: solid sage green (`--color-accent`), hover brightness boost + micro-lift (`-translate-y-px`)
|
||||||
|
- Secondary: bordered (`--color-border-mid`), text transitions to accent on hover
|
||||||
|
- Danger: red text + subtle red background on hover
|
||||||
|
- All: `transition-all duration-150`
|
||||||
|
|
||||||
|
**Inputs:**
|
||||||
|
- Border `--color-border`, background `--color-bg-2`; focus transitions border and icon to accent
|
||||||
|
- Group focus pattern: `group` wrapper + `group-focus-within:text-[var(--color-accent)]` on icon
|
||||||
|
|
||||||
|
**Tables / data lists:**
|
||||||
|
- Grid layout; header `bg-3` + uppercase `--text-label`; row hover `hover:bg-[var(--color-bg-3)]`
|
||||||
|
- Status stripe: left border color matches sandbox state
|
||||||
|
|
||||||
|
**Status indicators:** Running = animated ping + sage green dot; Paused = amber dot; Stopped = muted gray. Color is never the sole differentiator.
|
||||||
|
|
||||||
|
**Modals & dialogs:** Border + shadow only — no accent gradient bars/strips. `fadeUp` 0.35s entrance.
|
||||||
|
|
||||||
|
**Empty states:** Large icon with glow, Instrument Serif heading, secondary body text, CTA below, `iconFloat` 4s animation.
|
||||||
|
|
||||||
|
**Animations (always respect `prefers-reduced-motion`):** `fadeUp` (entrance), `status-ping` (live indicator), `iconFloat` (empty states), `spin-once` (refresh), staggered `animation-delay` on lists.
|
||||||
|
|
||||||
|
### Design Principles
|
||||||
|
|
||||||
|
1. **Precision over friendliness.** Every element earns its place. Wrenn doesn't need to tell you it's developer-friendly — that should be self-evident from the quality of the information architecture.
|
||||||
|
|
||||||
|
2. **Density with breathing room.** Data-forward doesn't mean cramped. Strategic whitespace creates calm hierarchy within dense contexts. Sections breathe; rows don't waste space.
|
||||||
|
|
||||||
|
3. **Industrial warmth.** The serif + mono + warm-black combination prevents sterility. This is a forge, not a gallery. The warmth is in the details, not the primary colors.
|
||||||
|
|
||||||
|
4. **Legible at speed.** Users scan dashboards in seconds. Strong typographic contrast (serif h1, mono IDs, sans body), consistent patterns, and predictable placement let users orientate instantly without reading everything.
|
||||||
|
|
||||||
|
5. **Craft signals trust.** For infrastructure that runs production code, the quality of the UI is a proxy for the quality of the product. Pixel-level decisions matter. Polish is not decoration — it's a trust signal.
|
||||||
|
|
||||||
|
<!-- code-review-graph MCP tools -->
|
||||||
|
## MCP Tools: code-review-graph
|
||||||
|
|
||||||
|
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||||
|
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||||
|
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||||
|
you structural context (callers, dependents, test coverage) that file
|
||||||
|
scanning cannot.
|
||||||
|
|
||||||
|
### When to use graph tools FIRST
|
||||||
|
|
||||||
|
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||||
|
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||||
|
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||||
|
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||||
|
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||||
|
|
||||||
|
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||||
|
|
||||||
|
### Key Tools
|
||||||
|
|
||||||
|
| Tool | Use when |
|
||||||
|
|------|----------|
|
||||||
|
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||||
|
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||||
|
| `get_impact_radius` | Understanding blast radius of a change |
|
||||||
|
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||||
|
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||||
|
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||||
|
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||||
|
| `refactor_tool` | Planning renames, finding dead code |
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
1. The graph auto-updates on file changes (via hooks).
|
||||||
|
2. Use `detect_changes` for code review.
|
||||||
|
3. Use `get_affected_flows` to understand impact.
|
||||||
|
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||||
1378
api/openapi.yaml
1378
api/openapi.yaml
File diff suppressed because it is too large
Load Diff
@ -1964,15 +1964,17 @@ inactivity TTL is set.
|
|||||||
#### wait\_ready
|
#### wait\_ready
|
||||||
|
|
||||||
```python
|
```python
|
||||||
async def wait_ready(timeout: float = 30, interval: float = 0.5) -> None
|
async def wait_ready(timeout: float = 30) -> None
|
||||||
```
|
```
|
||||||
|
|
||||||
Await until the capsule status is ``running``.
|
Await until the capsule status is ``running``.
|
||||||
|
|
||||||
|
Polling interval adapts to the current transient status:
|
||||||
|
0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
|
||||||
|
|
||||||
**Arguments**:
|
**Arguments**:
|
||||||
|
|
||||||
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
|
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
|
||||||
- `interval` _float_ - Polling interval in seconds. Defaults to ``0.5``.
|
|
||||||
|
|
||||||
|
|
||||||
**Raises**:
|
**Raises**:
|
||||||
@ -2534,15 +2536,17 @@ inactivity TTL is set.
|
|||||||
#### wait\_ready
|
#### wait\_ready
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def wait_ready(timeout: float = 30, interval: float = 0.5) -> None
|
def wait_ready(timeout: float = 30) -> None
|
||||||
```
|
```
|
||||||
|
|
||||||
Block until the capsule status is ``running``.
|
Block until the capsule status is ``running``.
|
||||||
|
|
||||||
|
Polling interval adapts to the current transient status:
|
||||||
|
0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
|
||||||
|
|
||||||
**Arguments**:
|
**Arguments**:
|
||||||
|
|
||||||
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
|
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
|
||||||
- `interval` _float_ - Polling interval in seconds. Defaults to ``0.5``.
|
|
||||||
|
|
||||||
|
|
||||||
**Raises**:
|
**Raises**:
|
||||||
@ -2700,17 +2704,6 @@ Create a snapshot template from this capsule's current state.
|
|||||||
|
|
||||||
# wrenn.\_config
|
# wrenn.\_config
|
||||||
|
|
||||||
<a id="wrenn._config.ConnectionConfig"></a>
|
|
||||||
|
|
||||||
## ConnectionConfig Objects
|
|
||||||
|
|
||||||
```python
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ConnectionConfig()
|
|
||||||
```
|
|
||||||
|
|
||||||
Resolved credentials and base URL for Wrenn API calls.
|
|
||||||
|
|
||||||
<a id="wrenn._git._auth"></a>
|
<a id="wrenn._git._auth"></a>
|
||||||
|
|
||||||
# wrenn.\_git.\_auth
|
# wrenn.\_git.\_auth
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "wrenn"
|
name = "wrenn"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
description = "Python SDK for Wrenn"
|
description = "Python SDK for Wrenn"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@ -36,6 +36,7 @@ build-backend = "hatchling.build"
|
|||||||
dev = [
|
dev = [
|
||||||
"datamodel-code-generator[ruff]>=0.56.0",
|
"datamodel-code-generator[ruff]>=0.56.0",
|
||||||
"mypy>=1.20.0",
|
"mypy>=1.20.0",
|
||||||
|
"pre-commit>=4.6.0",
|
||||||
"pydoc-markdown>=4.8.2",
|
"pydoc-markdown>=4.8.2",
|
||||||
"pytest>=9.0.3",
|
"pytest>=9.0.3",
|
||||||
"pytest-asyncio>=1.3.0",
|
"pytest-asyncio>=1.3.0",
|
||||||
|
|||||||
@ -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.0"
|
__version__ = "0.1.4"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"__version__",
|
"__version__",
|
||||||
|
|||||||
@ -1,33 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
DEFAULT_BASE_URL = "https://app.wrenn.dev/api"
|
DEFAULT_BASE_URL = "https://app.wrenn.dev/api"
|
||||||
ENV_API_KEY = "WRENN_API_KEY"
|
ENV_API_KEY = "WRENN_API_KEY"
|
||||||
ENV_BASE_URL = "WRENN_BASE_URL"
|
ENV_BASE_URL = "WRENN_BASE_URL"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ConnectionConfig:
|
|
||||||
"""Resolved credentials and base URL for Wrenn API calls."""
|
|
||||||
|
|
||||||
api_key: str
|
|
||||||
base_url: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_env(
|
|
||||||
cls,
|
|
||||||
api_key: str | None = None,
|
|
||||||
base_url: str | None = None,
|
|
||||||
) -> ConnectionConfig:
|
|
||||||
resolved_key = api_key or os.environ.get(ENV_API_KEY)
|
|
||||||
if not resolved_key:
|
|
||||||
raise ValueError(
|
|
||||||
f"No API key provided. Pass api_key= or set the {ENV_API_KEY} environment variable."
|
|
||||||
)
|
|
||||||
resolved_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL)
|
|
||||||
return cls(api_key=resolved_key, base_url=resolved_url)
|
|
||||||
|
|
||||||
def auth_headers(self) -> dict[str, str]:
|
|
||||||
return {"X-API-Key": self.api_key}
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import builtins
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@ -8,15 +10,54 @@ from contextlib import asynccontextmanager
|
|||||||
import httpx_ws
|
import httpx_ws
|
||||||
|
|
||||||
from wrenn._git import AsyncGit
|
from wrenn._git import AsyncGit
|
||||||
from wrenn.capsule import _DualMethod, _build_proxy_url
|
from wrenn.capsule import (
|
||||||
|
_DEFAULT_WAIT_TIMEOUT,
|
||||||
|
_DESTROY_INTERVAL,
|
||||||
|
_FAIL_STATUSES,
|
||||||
|
_PAUSE_INTERVAL,
|
||||||
|
_RESUME_INTERVAL,
|
||||||
|
_START_INTERVAL,
|
||||||
|
_DualMethod,
|
||||||
|
_build_proxy_url,
|
||||||
|
)
|
||||||
from wrenn.client import AsyncWrennClient
|
from wrenn.client import AsyncWrennClient
|
||||||
from wrenn.commands import AsyncCommands
|
from wrenn.commands import AsyncCommands
|
||||||
|
from wrenn.exceptions import WrennNotFoundError
|
||||||
from wrenn.files import AsyncFiles
|
from wrenn.files import AsyncFiles
|
||||||
from wrenn.models import Capsule as CapsuleModel
|
from wrenn.models import Capsule as CapsuleModel
|
||||||
from wrenn.models import Status, Template
|
from wrenn.models import Status, Template
|
||||||
from wrenn.pty import AsyncPtySession
|
from wrenn.pty import AsyncPtySession
|
||||||
|
|
||||||
|
|
||||||
|
async def _apoll_until(
|
||||||
|
fetch,
|
||||||
|
targets: set[Status],
|
||||||
|
interval: float,
|
||||||
|
timeout: float = _DEFAULT_WAIT_TIMEOUT,
|
||||||
|
fail_on: set[Status] | None = None,
|
||||||
|
) -> CapsuleModel:
|
||||||
|
fail = fail_on if fail_on is not None else _FAIL_STATUSES
|
||||||
|
treat_missing_as_target = Status.missing in targets
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
last: CapsuleModel | None = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
last = await fetch()
|
||||||
|
except WrennNotFoundError:
|
||||||
|
if treat_missing_as_target:
|
||||||
|
return CapsuleModel(status=Status.missing)
|
||||||
|
raise
|
||||||
|
if last.status in targets:
|
||||||
|
return last
|
||||||
|
if last.status is not None and last.status in fail:
|
||||||
|
raise RuntimeError(f"Capsule entered {last.status} state while waiting")
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Capsule did not reach {targets} within {timeout}s "
|
||||||
|
f"(last status: {last.status if last else 'unknown'})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AsyncCapsule:
|
class AsyncCapsule:
|
||||||
"""Async Wrenn capsule with e2b-compatible interface.
|
"""Async Wrenn capsule with e2b-compatible interface.
|
||||||
|
|
||||||
@ -102,6 +143,7 @@ class AsyncCapsule:
|
|||||||
memory_mb=memory_mb,
|
memory_mb=memory_mb,
|
||||||
timeout_sec=timeout,
|
timeout_sec=timeout,
|
||||||
)
|
)
|
||||||
|
assert info.id is not None
|
||||||
capsule = cls(
|
capsule = cls(
|
||||||
_capsule_id=info.id,
|
_capsule_id=info.id,
|
||||||
_client=client,
|
_client=client,
|
||||||
@ -136,15 +178,21 @@ class AsyncCapsule:
|
|||||||
client = AsyncWrennClient(api_key=api_key, base_url=base_url)
|
client = AsyncWrennClient(api_key=api_key, base_url=base_url)
|
||||||
info = await client.capsules.get(capsule_id)
|
info = await client.capsules.get(capsule_id)
|
||||||
|
|
||||||
if info.status == Status.paused:
|
capsule = cls(
|
||||||
info = await client.capsules.resume(capsule_id)
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
_capsule_id=capsule_id,
|
_capsule_id=capsule_id,
|
||||||
_client=client,
|
_client=client,
|
||||||
_info=info,
|
_info=info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if info.status == Status.pausing:
|
||||||
|
info = await capsule._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
|
||||||
|
if info.status == Status.paused:
|
||||||
|
await client.capsules.resume(capsule_id)
|
||||||
|
if info.status != Status.running:
|
||||||
|
await capsule.wait_ready()
|
||||||
|
|
||||||
|
return capsule
|
||||||
|
|
||||||
# ── Dual instance/static lifecycle ──────────────────────────
|
# ── Dual instance/static lifecycle ──────────────────────────
|
||||||
|
|
||||||
destroy = _DualMethod("_instance_destroy", "_static_destroy")
|
destroy = _DualMethod("_instance_destroy", "_static_destroy")
|
||||||
@ -152,22 +200,35 @@ class AsyncCapsule:
|
|||||||
resume = _DualMethod("_instance_resume", "_static_resume")
|
resume = _DualMethod("_instance_resume", "_static_resume")
|
||||||
get_info = _DualMethod("_instance_get_info", "_static_get_info")
|
get_info = _DualMethod("_instance_get_info", "_static_get_info")
|
||||||
|
|
||||||
async def _instance_destroy(self) -> None:
|
async def _instance_destroy(self, wait: bool = False) -> None:
|
||||||
await self._client.capsules.destroy(self._id)
|
await self._client.capsules.destroy(self._id)
|
||||||
|
if wait:
|
||||||
|
await self._wait_for_status(
|
||||||
|
{Status.stopped, Status.missing}, _DESTROY_INTERVAL
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _static_destroy(
|
async def _static_destroy(
|
||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
await client.capsules.destroy(capsule_id)
|
await client.capsules.destroy(capsule_id)
|
||||||
|
if wait:
|
||||||
|
await _apoll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.stopped, Status.missing},
|
||||||
|
_DESTROY_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
async def _instance_pause(self) -> CapsuleModel:
|
async def _instance_pause(self, wait: bool = False) -> CapsuleModel:
|
||||||
self._info = await self._client.capsules.pause(self._id)
|
self._info = await self._client.capsules.pause(self._id)
|
||||||
|
if wait:
|
||||||
|
self._info = await self._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
|
||||||
return self._info
|
return self._info
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -175,14 +236,24 @@ class AsyncCapsule:
|
|||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> CapsuleModel:
|
) -> CapsuleModel:
|
||||||
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
return await client.capsules.pause(capsule_id)
|
info = await client.capsules.pause(capsule_id)
|
||||||
|
if wait:
|
||||||
|
info = await _apoll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.paused},
|
||||||
|
_PAUSE_INTERVAL,
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
async def _instance_resume(self) -> CapsuleModel:
|
async def _instance_resume(self, wait: bool = False) -> CapsuleModel:
|
||||||
self._info = await self._client.capsules.resume(self._id)
|
self._info = await self._client.capsules.resume(self._id)
|
||||||
|
if wait:
|
||||||
|
self._info = await self._wait_for_status({Status.running}, _RESUME_INTERVAL)
|
||||||
return self._info
|
return self._info
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -190,11 +261,19 @@ class AsyncCapsule:
|
|||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> CapsuleModel:
|
) -> CapsuleModel:
|
||||||
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
return await client.capsules.resume(capsule_id)
|
info = await client.capsules.resume(capsule_id)
|
||||||
|
if wait:
|
||||||
|
info = await _apoll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.running},
|
||||||
|
_RESUME_INTERVAL,
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
async def _instance_get_info(self) -> CapsuleModel:
|
async def _instance_get_info(self) -> CapsuleModel:
|
||||||
self._info = await self._client.capsules.get(self._id)
|
self._info = await self._client.capsules.get(self._id)
|
||||||
@ -221,29 +300,30 @@ class AsyncCapsule:
|
|||||||
"""
|
"""
|
||||||
await self._client.capsules.ping(self._id)
|
await self._client.capsules.ping(self._id)
|
||||||
|
|
||||||
async def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None:
|
async def _wait_for_status(
|
||||||
"""Await until the capsule status is ``running``.
|
self,
|
||||||
|
targets: set[Status],
|
||||||
|
interval: float,
|
||||||
|
timeout: float = _DEFAULT_WAIT_TIMEOUT,
|
||||||
|
) -> CapsuleModel:
|
||||||
|
info = await _apoll_until(
|
||||||
|
lambda: self._client.capsules.get(self._id),
|
||||||
|
targets,
|
||||||
|
interval,
|
||||||
|
timeout,
|
||||||
|
fail_on={Status.error, Status.stopped, Status.missing} - targets,
|
||||||
|
)
|
||||||
|
self._info = info
|
||||||
|
return info
|
||||||
|
|
||||||
Args:
|
async def wait_ready(self, timeout: float = _DEFAULT_WAIT_TIMEOUT) -> None:
|
||||||
timeout (float): Maximum seconds to wait. Defaults to ``30``.
|
"""Await until capsule status is ``running``.
|
||||||
interval (float): Polling interval in seconds. Defaults to ``0.5``.
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TimeoutError: If the capsule does not reach ``running`` state
|
TimeoutError: If capsule does not reach ``running`` within ``timeout``.
|
||||||
within ``timeout`` seconds.
|
RuntimeError: If capsule enters error/stopped/missing while waiting.
|
||||||
RuntimeError: If the capsule enters an error, stopped, or paused
|
|
||||||
state while waiting.
|
|
||||||
"""
|
"""
|
||||||
deadline = time.monotonic() + timeout
|
await self._wait_for_status({Status.running}, _START_INTERVAL, timeout)
|
||||||
while time.monotonic() < deadline:
|
|
||||||
info = await self._client.capsules.get(self._id)
|
|
||||||
if info.status == Status.running:
|
|
||||||
self._info = info
|
|
||||||
return
|
|
||||||
if info.status in (Status.error, Status.stopped, Status.paused):
|
|
||||||
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
|
||||||
|
|
||||||
async def is_running(self) -> bool:
|
async def is_running(self) -> bool:
|
||||||
"""Check whether the capsule is currently running.
|
"""Check whether the capsule is currently running.
|
||||||
@ -284,7 +364,7 @@ class AsyncCapsule:
|
|||||||
async def pty(
|
async def pty(
|
||||||
self,
|
self,
|
||||||
cmd: str = "/bin/bash",
|
cmd: str = "/bin/bash",
|
||||||
args: list[str] | None = None,
|
args: builtins.list[str] | None = None,
|
||||||
cols: int = 80,
|
cols: int = 80,
|
||||||
rows: int = 24,
|
rows: int = 24,
|
||||||
envs: dict[str, str] | None = None,
|
envs: dict[str, str] | None = None,
|
||||||
@ -316,7 +396,7 @@ class AsyncCapsule:
|
|||||||
"""
|
"""
|
||||||
async with httpx_ws.aconnect_ws(
|
async with httpx_ws.aconnect_ws(
|
||||||
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
||||||
) as ws:
|
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||||
session = AsyncPtySession(ws, self._id)
|
session = AsyncPtySession(ws, self._id)
|
||||||
await session._send_start(
|
await session._send_start(
|
||||||
cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd
|
cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd
|
||||||
@ -335,7 +415,7 @@ class AsyncCapsule:
|
|||||||
"""
|
"""
|
||||||
async with httpx_ws.aconnect_ws(
|
async with httpx_ws.aconnect_ws(
|
||||||
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
||||||
) as ws:
|
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||||
session = AsyncPtySession(ws, self._id)
|
session = AsyncPtySession(ws, self._id)
|
||||||
await session._send_connect(tag)
|
await session._send_connect(tag)
|
||||||
yield session
|
yield session
|
||||||
@ -387,8 +467,8 @@ class AsyncCapsule:
|
|||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
await self._instance_destroy()
|
await self._instance_destroy()
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
logging.warning("Failed to destroy capsule %s: %s", self._id, exc)
|
||||||
try:
|
try:
|
||||||
await self._client.aclose()
|
await self._client.aclose()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import builtins
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
@ -11,6 +13,7 @@ import httpx_ws
|
|||||||
from wrenn._git import Git
|
from wrenn._git import Git
|
||||||
from wrenn.client import WrennClient
|
from wrenn.client import WrennClient
|
||||||
from wrenn.commands import Commands
|
from wrenn.commands import Commands
|
||||||
|
from wrenn.exceptions import WrennNotFoundError
|
||||||
from wrenn.files import Files
|
from wrenn.files import Files
|
||||||
from wrenn.models import Capsule as CapsuleModel
|
from wrenn.models import Capsule as CapsuleModel
|
||||||
from wrenn.models import Status, Template
|
from wrenn.models import Status, Template
|
||||||
@ -26,6 +29,44 @@ def _build_proxy_url(base_url: str, capsule_id: str | None, port: int) -> str:
|
|||||||
return f"{scheme}://{port}-{capsule_id}.{host}"
|
return f"{scheme}://{port}-{capsule_id}.{host}"
|
||||||
|
|
||||||
|
|
||||||
|
_RESUME_INTERVAL = 0.5
|
||||||
|
_DESTROY_INTERVAL = 0.5
|
||||||
|
_PAUSE_INTERVAL = 2.0
|
||||||
|
_START_INTERVAL = 0.5
|
||||||
|
_DEFAULT_WAIT_TIMEOUT = 30.0
|
||||||
|
_FAIL_STATUSES = {Status.error}
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_until(
|
||||||
|
fetch,
|
||||||
|
targets: set[Status],
|
||||||
|
interval: float,
|
||||||
|
timeout: float = _DEFAULT_WAIT_TIMEOUT,
|
||||||
|
fail_on: set[Status] | None = None,
|
||||||
|
) -> CapsuleModel:
|
||||||
|
"""Poll ``fetch()`` until status ∈ ``targets``. Raise on ``fail_on``/timeout."""
|
||||||
|
fail = fail_on if fail_on is not None else _FAIL_STATUSES
|
||||||
|
treat_missing_as_target = Status.missing in targets
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
last: CapsuleModel | None = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
last = fetch()
|
||||||
|
except WrennNotFoundError:
|
||||||
|
if treat_missing_as_target:
|
||||||
|
return CapsuleModel(status=Status.missing)
|
||||||
|
raise
|
||||||
|
if last.status in targets:
|
||||||
|
return last
|
||||||
|
if last.status is not None and last.status in fail:
|
||||||
|
raise RuntimeError(f"Capsule entered {last.status} state while waiting")
|
||||||
|
time.sleep(interval)
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Capsule did not reach {targets} within {timeout}s "
|
||||||
|
f"(last status: {last.status if last else 'unknown'})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _DualMethod:
|
class _DualMethod:
|
||||||
"""Descriptor that dispatches to instance method or classmethod depending on call site."""
|
"""Descriptor that dispatches to instance method or classmethod depending on call site."""
|
||||||
|
|
||||||
@ -94,21 +135,25 @@ class Capsule:
|
|||||||
``WRENN_BASE_URL`` or the default production endpoint.
|
``WRENN_BASE_URL`` or the default production endpoint.
|
||||||
"""
|
"""
|
||||||
if _capsule_id is not None:
|
if _capsule_id is not None:
|
||||||
# Internal construction path (from create/connect classmethods)
|
|
||||||
assert _client is not None
|
assert _client is not None
|
||||||
self._id = _capsule_id
|
self._id: str = _capsule_id
|
||||||
self._client = _client
|
self._client = _client
|
||||||
self._info = _info
|
self._info = _info
|
||||||
else:
|
else:
|
||||||
# Public construction: create a capsule immediately
|
|
||||||
self._client = WrennClient(api_key=api_key, base_url=base_url)
|
self._client = WrennClient(api_key=api_key, base_url=base_url)
|
||||||
|
try:
|
||||||
self._info = self._client.capsules.create(
|
self._info = self._client.capsules.create(
|
||||||
template=template,
|
template=template,
|
||||||
vcpus=vcpus,
|
vcpus=vcpus,
|
||||||
memory_mb=memory_mb,
|
memory_mb=memory_mb,
|
||||||
timeout_sec=timeout,
|
timeout_sec=timeout,
|
||||||
)
|
)
|
||||||
|
if self._info.id is None:
|
||||||
|
raise RuntimeError("API returned a capsule without an ID")
|
||||||
self._id = self._info.id
|
self._id = self._info.id
|
||||||
|
except Exception:
|
||||||
|
self._client.close()
|
||||||
|
raise
|
||||||
|
|
||||||
self.commands = Commands(self._id, self._client.http)
|
self.commands = Commands(self._id, self._client.http)
|
||||||
self.files = Files(self._id, self._client.http)
|
self.files = Files(self._id, self._client.http)
|
||||||
@ -204,15 +249,21 @@ class Capsule:
|
|||||||
client = WrennClient(api_key=api_key, base_url=base_url)
|
client = WrennClient(api_key=api_key, base_url=base_url)
|
||||||
info = client.capsules.get(capsule_id)
|
info = client.capsules.get(capsule_id)
|
||||||
|
|
||||||
if info.status == Status.paused:
|
capsule = cls(
|
||||||
info = client.capsules.resume(capsule_id)
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
_capsule_id=capsule_id,
|
_capsule_id=capsule_id,
|
||||||
_client=client,
|
_client=client,
|
||||||
_info=info,
|
_info=info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if info.status == Status.pausing:
|
||||||
|
info = capsule._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
|
||||||
|
if info.status == Status.paused:
|
||||||
|
client.capsules.resume(capsule_id)
|
||||||
|
if info.status != Status.running:
|
||||||
|
capsule.wait_ready()
|
||||||
|
|
||||||
|
return capsule
|
||||||
|
|
||||||
# ── Dual instance/static lifecycle ──────────────────────────
|
# ── Dual instance/static lifecycle ──────────────────────────
|
||||||
|
|
||||||
destroy = _DualMethod("_instance_destroy", "_static_destroy")
|
destroy = _DualMethod("_instance_destroy", "_static_destroy")
|
||||||
@ -220,25 +271,36 @@ class Capsule:
|
|||||||
resume = _DualMethod("_instance_resume", "_static_resume")
|
resume = _DualMethod("_instance_resume", "_static_resume")
|
||||||
get_info = _DualMethod("_instance_get_info", "_static_get_info")
|
get_info = _DualMethod("_instance_get_info", "_static_get_info")
|
||||||
|
|
||||||
def _instance_destroy(self) -> None:
|
def _instance_destroy(self, wait: bool = False) -> None:
|
||||||
"""Destroy this capsule."""
|
"""Destroy this capsule. If ``wait``, poll until stopped/missing."""
|
||||||
self._client.capsules.destroy(self._id)
|
self._client.capsules.destroy(self._id)
|
||||||
|
if wait:
|
||||||
|
self._wait_for_status({Status.stopped, Status.missing}, _DESTROY_INTERVAL)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _static_destroy(
|
def _static_destroy(
|
||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Destroy a capsule by ID."""
|
"""Destroy a capsule by ID."""
|
||||||
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
client.capsules.destroy(capsule_id)
|
client.capsules.destroy(capsule_id)
|
||||||
|
if wait:
|
||||||
|
_poll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.stopped, Status.missing},
|
||||||
|
_DESTROY_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
def _instance_pause(self) -> CapsuleModel:
|
def _instance_pause(self, wait: bool = False) -> CapsuleModel:
|
||||||
"""Pause this capsule."""
|
"""Pause this capsule. If ``wait``, poll until ``paused``."""
|
||||||
self._info = self._client.capsules.pause(self._id)
|
self._info = self._client.capsules.pause(self._id)
|
||||||
|
if wait:
|
||||||
|
self._info = self._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
|
||||||
return self._info
|
return self._info
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -246,16 +308,26 @@ class Capsule:
|
|||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> CapsuleModel:
|
) -> CapsuleModel:
|
||||||
"""Pause a capsule by ID."""
|
"""Pause a capsule by ID."""
|
||||||
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
return client.capsules.pause(capsule_id)
|
info = client.capsules.pause(capsule_id)
|
||||||
|
if wait:
|
||||||
|
info = _poll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.paused},
|
||||||
|
_PAUSE_INTERVAL,
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
def _instance_resume(self) -> CapsuleModel:
|
def _instance_resume(self, wait: bool = False) -> CapsuleModel:
|
||||||
"""Resume this capsule."""
|
"""Resume this capsule. If ``wait``, poll until ``running``."""
|
||||||
self._info = self._client.capsules.resume(self._id)
|
self._info = self._client.capsules.resume(self._id)
|
||||||
|
if wait:
|
||||||
|
self._info = self._wait_for_status({Status.running}, _RESUME_INTERVAL)
|
||||||
return self._info
|
return self._info
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -263,12 +335,20 @@ class Capsule:
|
|||||||
cls,
|
cls,
|
||||||
capsule_id: str,
|
capsule_id: str,
|
||||||
*,
|
*,
|
||||||
|
wait: bool = False,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
) -> CapsuleModel:
|
) -> CapsuleModel:
|
||||||
"""Resume a capsule by ID."""
|
"""Resume a capsule by ID."""
|
||||||
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
||||||
return client.capsules.resume(capsule_id)
|
info = client.capsules.resume(capsule_id)
|
||||||
|
if wait:
|
||||||
|
info = _poll_until(
|
||||||
|
lambda: client.capsules.get(capsule_id),
|
||||||
|
{Status.running},
|
||||||
|
_RESUME_INTERVAL,
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
def _instance_get_info(self) -> CapsuleModel:
|
def _instance_get_info(self) -> CapsuleModel:
|
||||||
"""Get current info for this capsule."""
|
"""Get current info for this capsule."""
|
||||||
@ -297,29 +377,30 @@ class Capsule:
|
|||||||
"""
|
"""
|
||||||
self._client.capsules.ping(self._id)
|
self._client.capsules.ping(self._id)
|
||||||
|
|
||||||
def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None:
|
def _wait_for_status(
|
||||||
"""Block until the capsule status is ``running``.
|
self,
|
||||||
|
targets: set[Status],
|
||||||
|
interval: float,
|
||||||
|
timeout: float = _DEFAULT_WAIT_TIMEOUT,
|
||||||
|
) -> CapsuleModel:
|
||||||
|
info = _poll_until(
|
||||||
|
lambda: self._client.capsules.get(self._id),
|
||||||
|
targets,
|
||||||
|
interval,
|
||||||
|
timeout,
|
||||||
|
fail_on={Status.error, Status.stopped, Status.missing} - targets,
|
||||||
|
)
|
||||||
|
self._info = info
|
||||||
|
return info
|
||||||
|
|
||||||
Args:
|
def wait_ready(self, timeout: float = _DEFAULT_WAIT_TIMEOUT) -> None:
|
||||||
timeout (float): Maximum seconds to wait. Defaults to ``30``.
|
"""Block until capsule status is ``running``.
|
||||||
interval (float): Polling interval in seconds. Defaults to ``0.5``.
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TimeoutError: If the capsule does not reach ``running`` state
|
TimeoutError: If capsule does not reach ``running`` within ``timeout``.
|
||||||
within ``timeout`` seconds.
|
RuntimeError: If capsule enters error/stopped/missing while waiting.
|
||||||
RuntimeError: If the capsule enters an error, stopped, or paused
|
|
||||||
state while waiting.
|
|
||||||
"""
|
"""
|
||||||
deadline = time.monotonic() + timeout
|
self._wait_for_status({Status.running}, _START_INTERVAL, timeout)
|
||||||
while time.monotonic() < deadline:
|
|
||||||
info = self._client.capsules.get(self._id)
|
|
||||||
if info.status == Status.running:
|
|
||||||
self._info = info
|
|
||||||
return
|
|
||||||
if info.status in (Status.error, Status.stopped, Status.paused):
|
|
||||||
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
|
||||||
time.sleep(interval)
|
|
||||||
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
|
||||||
|
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""Check whether the capsule is currently running.
|
"""Check whether the capsule is currently running.
|
||||||
@ -360,7 +441,7 @@ class Capsule:
|
|||||||
def pty(
|
def pty(
|
||||||
self,
|
self,
|
||||||
cmd: str = "/bin/bash",
|
cmd: str = "/bin/bash",
|
||||||
args: list[str] | None = None,
|
args: builtins.list[str] | None = None,
|
||||||
cols: int = 80,
|
cols: int = 80,
|
||||||
rows: int = 24,
|
rows: int = 24,
|
||||||
envs: dict[str, str] | None = None,
|
envs: dict[str, str] | None = None,
|
||||||
@ -391,7 +472,7 @@ class Capsule:
|
|||||||
"""
|
"""
|
||||||
with httpx_ws.connect_ws(
|
with httpx_ws.connect_ws(
|
||||||
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
||||||
) as ws:
|
) as ws: # type: httpx_ws.WebSocketSession
|
||||||
session = PtySession(ws, self._id)
|
session = PtySession(ws, self._id)
|
||||||
session._send_start(
|
session._send_start(
|
||||||
cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd
|
cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd
|
||||||
@ -410,7 +491,7 @@ class Capsule:
|
|||||||
"""
|
"""
|
||||||
with httpx_ws.connect_ws(
|
with httpx_ws.connect_ws(
|
||||||
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
||||||
) as ws:
|
) as ws: # type: httpx_ws.WebSocketSession
|
||||||
session = PtySession(ws, self._id)
|
session = PtySession(ws, self._id)
|
||||||
session._send_connect(tag)
|
session._send_connect(tag)
|
||||||
yield session
|
yield session
|
||||||
@ -462,8 +543,8 @@ class Capsule:
|
|||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
self._instance_destroy()
|
self._instance_destroy()
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
logging.warning("Failed to destroy capsule %s: %s", self._id, exc)
|
||||||
try:
|
try:
|
||||||
self._client.close()
|
self._client.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import httpx
|
|||||||
|
|
||||||
from wrenn._config import DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL
|
from wrenn._config import DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL
|
||||||
from wrenn.exceptions import handle_response
|
from wrenn.exceptions import handle_response
|
||||||
|
|
||||||
from wrenn.models import (
|
from wrenn.models import (
|
||||||
Template,
|
Template,
|
||||||
)
|
)
|
||||||
@ -13,6 +14,8 @@ from wrenn.models import (
|
|||||||
Capsule as CapsuleModel,
|
Capsule as CapsuleModel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_LONG_TIMEOUT = httpx.Timeout(60.0)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_api_key(api_key: str | None) -> str:
|
def _resolve_api_key(api_key: str | None) -> str:
|
||||||
resolved = api_key or os.environ.get(ENV_API_KEY)
|
resolved = api_key or os.environ.get(ENV_API_KEY)
|
||||||
@ -285,7 +288,9 @@ class SnapshotsResource:
|
|||||||
params: dict = {}
|
params: dict = {}
|
||||||
if overwrite:
|
if overwrite:
|
||||||
params["overwrite"] = "true"
|
params["overwrite"] = "true"
|
||||||
resp = self._http.post("/v1/snapshots", json=payload, params=params)
|
resp = self._http.post(
|
||||||
|
"/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT
|
||||||
|
)
|
||||||
return Template.model_validate(handle_response(resp))
|
return Template.model_validate(handle_response(resp))
|
||||||
|
|
||||||
def list(self, type: str | None = None) -> list[Template]:
|
def list(self, type: str | None = None) -> list[Template]:
|
||||||
@ -347,7 +352,9 @@ class AsyncSnapshotsResource:
|
|||||||
params: dict = {}
|
params: dict = {}
|
||||||
if overwrite:
|
if overwrite:
|
||||||
params["overwrite"] = "true"
|
params["overwrite"] = "true"
|
||||||
resp = await self._http.post("/v1/snapshots", json=payload, params=params)
|
resp = await self._http.post(
|
||||||
|
"/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT
|
||||||
|
)
|
||||||
return Template.model_validate(handle_response(resp))
|
return Template.model_validate(handle_response(resp))
|
||||||
|
|
||||||
async def list(self, type: str | None = None) -> list[Template]:
|
async def list(self, type: str | None = None) -> list[Template]:
|
||||||
|
|||||||
@ -40,6 +40,28 @@ class AsyncCapsule(BaseAsyncCapsule):
|
|||||||
self._kernel_id = None
|
self._kernel_id = None
|
||||||
self._proxy_client = None
|
self._proxy_client = None
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._proxy_client is not None:
|
||||||
|
try:
|
||||||
|
await self._proxy_client.aclose()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._proxy_client = None
|
||||||
|
|
||||||
|
def __del__(self) -> None:
|
||||||
|
if self._proxy_client is not None:
|
||||||
|
try:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
if loop.is_running():
|
||||||
|
loop.create_task(self._proxy_client.aclose())
|
||||||
|
else:
|
||||||
|
loop.run_until_complete(self._proxy_client.aclose())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._proxy_client = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def create(
|
async def create(
|
||||||
cls,
|
cls,
|
||||||
@ -126,8 +148,10 @@ class AsyncCapsule(BaseAsyncCapsule):
|
|||||||
request=resp.request,
|
request=resp.request,
|
||||||
response=resp,
|
response=resp,
|
||||||
)
|
)
|
||||||
except httpx.HTTPStatusError:
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if exc.response.status_code < 500:
|
||||||
raise
|
raise
|
||||||
|
last_exc = exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_exc = exc
|
last_exc = exc
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
@ -164,8 +188,6 @@ class AsyncCapsule(BaseAsyncCapsule):
|
|||||||
},
|
},
|
||||||
"buffers": [],
|
"buffers": [],
|
||||||
"channel": "shell",
|
"channel": "shell",
|
||||||
"msg_id": msg_id,
|
|
||||||
"msg_type": "execute_request",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async def run_code(
|
async def run_code(
|
||||||
@ -201,13 +223,13 @@ class AsyncCapsule(BaseAsyncCapsule):
|
|||||||
ws_url = self._jupyter_ws_url(kernel_id)
|
ws_url = self._jupyter_ws_url(kernel_id)
|
||||||
|
|
||||||
msg = self._jupyter_execute_request(code)
|
msg = self._jupyter_execute_request(code)
|
||||||
msg_id = msg["msg_id"]
|
msg_id = msg["header"]["msg_id"]
|
||||||
|
|
||||||
execution = Execution()
|
execution = Execution()
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
headers = {"X-API-Key": self._client._api_key}
|
headers = {"X-API-Key": self._client._api_key}
|
||||||
|
|
||||||
async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws:
|
async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||||
await ws.send_text(json.dumps(msg))
|
await ws.send_text(json.dumps(msg))
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
time_left = deadline - time.monotonic()
|
time_left = deadline - time.monotonic()
|
||||||
@ -215,7 +237,7 @@ class AsyncCapsule(BaseAsyncCapsule):
|
|||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
data = await asyncio.wait_for(ws.receive_json(), timeout=time_left)
|
data = await asyncio.wait_for(ws.receive_json(), timeout=time_left)
|
||||||
except (asyncio.TimeoutError, Exception):
|
except Exception:
|
||||||
break
|
break
|
||||||
if not data:
|
if not data:
|
||||||
break
|
break
|
||||||
|
|||||||
@ -70,6 +70,17 @@ class Capsule(BaseCapsule):
|
|||||||
self._kernel_id = None
|
self._kernel_id = None
|
||||||
self._proxy_client = None
|
self._proxy_client = None
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._proxy_client is not None:
|
||||||
|
try:
|
||||||
|
self._proxy_client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._proxy_client = None
|
||||||
|
|
||||||
|
def __del__(self) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
cls,
|
cls,
|
||||||
@ -150,8 +161,10 @@ class Capsule(BaseCapsule):
|
|||||||
request=resp.request,
|
request=resp.request,
|
||||||
response=resp,
|
response=resp,
|
||||||
)
|
)
|
||||||
except httpx.HTTPStatusError:
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if exc.response.status_code < 500:
|
||||||
raise
|
raise
|
||||||
|
last_exc = exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_exc = exc
|
last_exc = exc
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
@ -188,8 +201,6 @@ class Capsule(BaseCapsule):
|
|||||||
},
|
},
|
||||||
"buffers": [],
|
"buffers": [],
|
||||||
"channel": "shell",
|
"channel": "shell",
|
||||||
"msg_id": msg_id,
|
|
||||||
"msg_type": "execute_request",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def run_code(
|
def run_code(
|
||||||
@ -227,13 +238,13 @@ class Capsule(BaseCapsule):
|
|||||||
ws_url = self._jupyter_ws_url(kernel_id)
|
ws_url = self._jupyter_ws_url(kernel_id)
|
||||||
|
|
||||||
msg = self._jupyter_execute_request(code)
|
msg = self._jupyter_execute_request(code)
|
||||||
msg_id = msg["msg_id"]
|
msg_id = msg["header"]["msg_id"]
|
||||||
|
|
||||||
execution = Execution()
|
execution = Execution()
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
headers = {"X-API-Key": self._client._api_key}
|
headers = {"X-API-Key": self._client._api_key}
|
||||||
|
|
||||||
with httpx_ws.connect_ws(ws_url, headers=headers) as ws:
|
with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.WebSocketSession
|
||||||
ws.send_text(json.dumps(msg))
|
ws.send_text(json.dumps(msg))
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
time_left = deadline - time.monotonic()
|
time_left = deadline - time.monotonic()
|
||||||
@ -241,7 +252,7 @@ class Capsule(BaseCapsule):
|
|||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
data = ws.receive_json(timeout=time_left)
|
data = ws.receive_json(timeout=time_left)
|
||||||
except (TimeoutError, Exception):
|
except Exception:
|
||||||
break
|
break
|
||||||
if not data:
|
if not data:
|
||||||
break
|
break
|
||||||
|
|||||||
@ -1,16 +1,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import builtins
|
||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterator, Iterator
|
from collections.abc import AsyncIterator, Iterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import overload, Literal
|
from typing import Literal, overload
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import httpx_ws
|
import httpx_ws
|
||||||
|
|
||||||
from wrenn.exceptions import handle_response
|
from wrenn.exceptions import handle_response
|
||||||
|
|
||||||
|
# Both signal a terminated WebSocket: ``WebSocketDisconnect`` is a clean close,
|
||||||
|
# ``WebSocketNetworkError`` an abrupt one. The Wrenn server closes exec/process
|
||||||
|
# streams abruptly, so iterators must treat either as end-of-stream.
|
||||||
|
_WS_CLOSED = (httpx_ws.WebSocketDisconnect, httpx_ws.WebSocketNetworkError)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CommandResult:
|
class CommandResult:
|
||||||
@ -197,8 +203,17 @@ class Commands:
|
|||||||
if tag is not None:
|
if tag is not None:
|
||||||
payload["tag"] = tag
|
payload["tag"] = tag
|
||||||
|
|
||||||
resp = self._http.post(f"/v1/capsules/{self._capsule_id}/exec", json=payload)
|
http_timeout: httpx.Timeout | None = None
|
||||||
|
if not background and timeout is not None:
|
||||||
|
http_timeout = httpx.Timeout(timeout + 10, connect=5.0)
|
||||||
|
|
||||||
|
resp = self._http.post(
|
||||||
|
f"/v1/capsules/{self._capsule_id}/exec",
|
||||||
|
json=payload,
|
||||||
|
timeout=http_timeout,
|
||||||
|
)
|
||||||
data = handle_response(resp)
|
data = handle_response(resp)
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
|
||||||
if background:
|
if background:
|
||||||
return CommandHandle(
|
return CommandHandle(
|
||||||
@ -217,6 +232,7 @@ class Commands:
|
|||||||
"""
|
"""
|
||||||
resp = self._http.get(f"/v1/capsules/{self._capsule_id}/processes")
|
resp = self._http.get(f"/v1/capsules/{self._capsule_id}/processes")
|
||||||
data = handle_response(resp)
|
data = handle_response(resp)
|
||||||
|
assert isinstance(data, dict)
|
||||||
return [
|
return [
|
||||||
ProcessInfo(
|
ProcessInfo(
|
||||||
pid=p.get("pid", 0),
|
pid=p.get("pid", 0),
|
||||||
@ -252,7 +268,7 @@ class Commands:
|
|||||||
with httpx_ws.connect_ws(
|
with httpx_ws.connect_ws(
|
||||||
f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream",
|
f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream",
|
||||||
self._http,
|
self._http,
|
||||||
) as ws:
|
) as ws: # type: httpx_ws.WebSocketSession
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
raw = ws.receive_json()
|
raw = ws.receive_json()
|
||||||
@ -260,10 +276,12 @@ class Commands:
|
|||||||
yield event
|
yield event
|
||||||
if event.type in ("exit", "error"):
|
if event.type in ("exit", "error"):
|
||||||
break
|
break
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
break
|
break
|
||||||
|
|
||||||
def stream(self, cmd: str, args: list[str] | None = None) -> Iterator[StreamEvent]:
|
def stream(
|
||||||
|
self, cmd: str, args: builtins.list[str] | None = None
|
||||||
|
) -> Iterator[StreamEvent]:
|
||||||
"""Execute a command via WebSocket, streaming output as events.
|
"""Execute a command via WebSocket, streaming output as events.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -280,7 +298,7 @@ class Commands:
|
|||||||
with httpx_ws.connect_ws(
|
with httpx_ws.connect_ws(
|
||||||
f"/v1/capsules/{self._capsule_id}/exec/stream",
|
f"/v1/capsules/{self._capsule_id}/exec/stream",
|
||||||
self._http,
|
self._http,
|
||||||
) as ws:
|
) as ws: # type: httpx_ws.WebSocketSession
|
||||||
if args:
|
if args:
|
||||||
start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
|
start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
|
||||||
else:
|
else:
|
||||||
@ -293,7 +311,7 @@ class Commands:
|
|||||||
yield event
|
yield event
|
||||||
if event.type in ("exit", "error"):
|
if event.type in ("exit", "error"):
|
||||||
break
|
break
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
@ -374,10 +392,17 @@ class AsyncCommands:
|
|||||||
if tag is not None:
|
if tag is not None:
|
||||||
payload["tag"] = tag
|
payload["tag"] = tag
|
||||||
|
|
||||||
|
http_timeout: httpx.Timeout | None = None
|
||||||
|
if not background and timeout is not None:
|
||||||
|
http_timeout = httpx.Timeout(timeout + 10, connect=5.0)
|
||||||
|
|
||||||
resp = await self._http.post(
|
resp = await self._http.post(
|
||||||
f"/v1/capsules/{self._capsule_id}/exec", json=payload
|
f"/v1/capsules/{self._capsule_id}/exec",
|
||||||
|
json=payload,
|
||||||
|
timeout=http_timeout,
|
||||||
)
|
)
|
||||||
data = handle_response(resp)
|
data = handle_response(resp)
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
|
||||||
if background:
|
if background:
|
||||||
return CommandHandle(
|
return CommandHandle(
|
||||||
@ -396,6 +421,7 @@ class AsyncCommands:
|
|||||||
"""
|
"""
|
||||||
resp = await self._http.get(f"/v1/capsules/{self._capsule_id}/processes")
|
resp = await self._http.get(f"/v1/capsules/{self._capsule_id}/processes")
|
||||||
data = handle_response(resp)
|
data = handle_response(resp)
|
||||||
|
assert isinstance(data, dict)
|
||||||
return [
|
return [
|
||||||
ProcessInfo(
|
ProcessInfo(
|
||||||
pid=p.get("pid", 0),
|
pid=p.get("pid", 0),
|
||||||
@ -433,7 +459,7 @@ class AsyncCommands:
|
|||||||
async with httpx_ws.aconnect_ws(
|
async with httpx_ws.aconnect_ws(
|
||||||
f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream",
|
f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream",
|
||||||
self._http,
|
self._http,
|
||||||
) as ws:
|
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
raw = await ws.receive_json()
|
raw = await ws.receive_json()
|
||||||
@ -441,11 +467,11 @@ class AsyncCommands:
|
|||||||
yield event
|
yield event
|
||||||
if event.type in ("exit", "error"):
|
if event.type in ("exit", "error"):
|
||||||
break
|
break
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def stream(
|
async def stream(
|
||||||
self, cmd: str, args: list[str] | None = None
|
self, cmd: str, args: builtins.list[str] | None = None
|
||||||
) -> AsyncIterator[StreamEvent]:
|
) -> AsyncIterator[StreamEvent]:
|
||||||
"""Execute a command via WebSocket, streaming output as events.
|
"""Execute a command via WebSocket, streaming output as events.
|
||||||
|
|
||||||
@ -463,7 +489,7 @@ class AsyncCommands:
|
|||||||
async with httpx_ws.aconnect_ws(
|
async with httpx_ws.aconnect_ws(
|
||||||
f"/v1/capsules/{self._capsule_id}/exec/stream",
|
f"/v1/capsules/{self._capsule_id}/exec/stream",
|
||||||
self._http,
|
self._http,
|
||||||
) as ws:
|
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||||
if args:
|
if args:
|
||||||
start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
|
start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
|
||||||
else:
|
else:
|
||||||
@ -476,5 +502,5 @@ class AsyncCommands:
|
|||||||
yield event
|
yield event
|
||||||
if event.type in ("exit", "error"):
|
if event.type in ("exit", "error"):
|
||||||
break
|
break
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -110,13 +110,18 @@ _ERROR_MAP: dict[str, type[WrennError]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def handle_response(resp: httpx.Response) -> dict | list:
|
def _raise_for_status(resp: httpx.Response) -> None:
|
||||||
if resp.status_code >= 400:
|
if resp.status_code < 400:
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
resp.raise_for_status()
|
raise WrennInternalError(
|
||||||
raise
|
code="internal_error",
|
||||||
|
message=resp.text or f"HTTP {resp.status_code}",
|
||||||
|
status_code=resp.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
err = body.get("error", {})
|
err = body.get("error", {})
|
||||||
code = err.get("code", "internal_error")
|
code = err.get("code", "internal_error")
|
||||||
@ -129,7 +134,7 @@ def handle_response(resp: httpx.Response) -> dict | list:
|
|||||||
code=code,
|
code=code,
|
||||||
message=message,
|
message=message,
|
||||||
status_code=resp.status_code,
|
status_code=resp.status_code,
|
||||||
capsule_ids=body.get("sandbox_ids", []),
|
capsule_ids=body.get("capsule_ids") or body.get("sandbox_ids", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
raise exc_cls(
|
raise exc_cls(
|
||||||
@ -138,9 +143,16 @@ def handle_response(resp: httpx.Response) -> dict | list:
|
|||||||
status_code=resp.status_code,
|
status_code=resp.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_response(resp: httpx.Response) -> dict | list:
|
||||||
|
_raise_for_status(resp)
|
||||||
|
|
||||||
if resp.status_code == 204:
|
if resp.status_code == 204:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
if not resp.content:
|
||||||
|
return {}
|
||||||
|
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -5,10 +5,40 @@ from collections.abc import AsyncIterator, Iterator
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from wrenn.exceptions import WrennNotFoundError, handle_response
|
from wrenn.exceptions import WrennNotFoundError, _raise_for_status, handle_response
|
||||||
from wrenn.models import FileEntry, ListDirResponse, MakeDirResponse
|
from wrenn.models import FileEntry, ListDirResponse, MakeDirResponse
|
||||||
|
|
||||||
|
|
||||||
|
def _is_already_exists(resp: httpx.Response) -> bool:
|
||||||
|
"""Detect server's already-exists reply across status codes / code strings.
|
||||||
|
|
||||||
|
Server may return 409 with code "conflict"/"already_exists" or wrap
|
||||||
|
"already_exists" inside an "internal" 500 message.
|
||||||
|
"""
|
||||||
|
if resp.status_code < 400:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
err = body.get("error", {}) if isinstance(body, dict) else {}
|
||||||
|
code = err.get("code", "")
|
||||||
|
msg = err.get("message", "") or ""
|
||||||
|
return code in {"conflict", "already_exists"} or "already_exists" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def _find_entry(list_fn, path: str) -> FileEntry | None:
|
||||||
|
parent = os.path.dirname(path)
|
||||||
|
name = os.path.basename(path)
|
||||||
|
try:
|
||||||
|
for entry in list_fn(parent, depth=1):
|
||||||
|
if entry.name == name:
|
||||||
|
return entry
|
||||||
|
except WrennNotFoundError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Files:
|
class Files:
|
||||||
"""Sync filesystem interface. Accessed via ``capsule.files``."""
|
"""Sync filesystem interface. Accessed via ``capsule.files``."""
|
||||||
|
|
||||||
@ -46,7 +76,7 @@ class Files:
|
|||||||
f"/v1/capsules/{self._capsule_id}/files/read",
|
f"/v1/capsules/{self._capsule_id}/files/read",
|
||||||
json={"path": path},
|
json={"path": path},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
_raise_for_status(resp)
|
||||||
return resp.content
|
return resp.content
|
||||||
|
|
||||||
def write(self, path: str, data: str | bytes) -> None:
|
def write(self, path: str, data: str | bytes) -> None:
|
||||||
@ -65,7 +95,7 @@ class Files:
|
|||||||
files={"file": ("upload", data)},
|
files={"file": ("upload", data)},
|
||||||
data={"path": path},
|
data={"path": path},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
_raise_for_status(resp)
|
||||||
|
|
||||||
def list(self, path: str, depth: int = 1) -> list[FileEntry]:
|
def list(self, path: str, depth: int = 1) -> list[FileEntry]:
|
||||||
"""List directory contents.
|
"""List directory contents.
|
||||||
@ -118,17 +148,10 @@ class Files:
|
|||||||
f"/v1/capsules/{self._capsule_id}/files/mkdir",
|
f"/v1/capsules/{self._capsule_id}/files/mkdir",
|
||||||
json={"path": path},
|
json={"path": path},
|
||||||
)
|
)
|
||||||
if resp.status_code == 409:
|
if _is_already_exists(resp):
|
||||||
try:
|
existing = _find_entry(self.list, path)
|
||||||
body = resp.json()
|
if existing is not None:
|
||||||
if body.get("error", {}).get("code") == "conflict":
|
return existing
|
||||||
parent = os.path.dirname(path)
|
|
||||||
name = os.path.basename(path)
|
|
||||||
for entry in self.list(parent, depth=1):
|
|
||||||
if entry.name == name:
|
|
||||||
return entry
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
parsed = MakeDirResponse.model_validate(handle_response(resp))
|
parsed = MakeDirResponse.model_validate(handle_response(resp))
|
||||||
if parsed.entry is None:
|
if parsed.entry is None:
|
||||||
raise RuntimeError("mkdir response missing entry")
|
raise RuntimeError("mkdir response missing entry")
|
||||||
@ -179,7 +202,7 @@ class Files:
|
|||||||
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}"
|
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
_raise_for_status(resp)
|
||||||
|
|
||||||
def download_stream(self, path: str) -> Iterator[bytes]:
|
def download_stream(self, path: str) -> Iterator[bytes]:
|
||||||
"""Stream a large file out of the capsule.
|
"""Stream a large file out of the capsule.
|
||||||
@ -243,7 +266,7 @@ class AsyncFiles:
|
|||||||
f"/v1/capsules/{self._capsule_id}/files/read",
|
f"/v1/capsules/{self._capsule_id}/files/read",
|
||||||
json={"path": path},
|
json={"path": path},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
_raise_for_status(resp)
|
||||||
return resp.content
|
return resp.content
|
||||||
|
|
||||||
async def write(self, path: str, data: str | bytes) -> None:
|
async def write(self, path: str, data: str | bytes) -> None:
|
||||||
@ -262,7 +285,7 @@ class AsyncFiles:
|
|||||||
files={"file": ("upload", data)},
|
files={"file": ("upload", data)},
|
||||||
data={"path": path},
|
data={"path": path},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
_raise_for_status(resp)
|
||||||
|
|
||||||
async def list(self, path: str, depth: int = 1) -> list[FileEntry]:
|
async def list(self, path: str, depth: int = 1) -> list[FileEntry]:
|
||||||
"""List directory contents.
|
"""List directory contents.
|
||||||
@ -315,17 +338,12 @@ class AsyncFiles:
|
|||||||
f"/v1/capsules/{self._capsule_id}/files/mkdir",
|
f"/v1/capsules/{self._capsule_id}/files/mkdir",
|
||||||
json={"path": path},
|
json={"path": path},
|
||||||
)
|
)
|
||||||
if resp.status_code == 409:
|
if _is_already_exists(resp):
|
||||||
try:
|
|
||||||
body = resp.json()
|
|
||||||
if body.get("error", {}).get("code") == "conflict":
|
|
||||||
parent = os.path.dirname(path)
|
parent = os.path.dirname(path)
|
||||||
name = os.path.basename(path)
|
name = os.path.basename(path)
|
||||||
for entry in await self.list(parent, depth=1):
|
for entry in await self.list(parent, depth=1):
|
||||||
if entry.name == name:
|
if entry.name == name:
|
||||||
return entry
|
return entry
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
parsed = MakeDirResponse.model_validate(handle_response(resp))
|
parsed = MakeDirResponse.model_validate(handle_response(resp))
|
||||||
if parsed.entry is None:
|
if parsed.entry is None:
|
||||||
raise RuntimeError("mkdir response missing entry")
|
raise RuntimeError("mkdir response missing entry")
|
||||||
@ -377,7 +395,7 @@ class AsyncFiles:
|
|||||||
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}"
|
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
_raise_for_status(resp)
|
||||||
|
|
||||||
async def download_stream(self, path: str) -> AsyncIterator[bytes]:
|
async def download_stream(self, path: str) -> AsyncIterator[bytes]:
|
||||||
"""Stream a large file out of the capsule.
|
"""Stream a large file out of the capsule.
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
from wrenn.models._generated import (
|
from wrenn.models._generated import (
|
||||||
APIKeyResponse,
|
APIKeyResponse,
|
||||||
AuthResponse,
|
|
||||||
Capsule,
|
Capsule,
|
||||||
CreateAPIKeyRequest,
|
CreateAPIKeyRequest,
|
||||||
CreateCapsuleRequest,
|
CreateCapsuleRequest,
|
||||||
@ -34,7 +33,6 @@ from wrenn.models._generated import (
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"APIKeyResponse",
|
"APIKeyResponse",
|
||||||
"AuthResponse",
|
|
||||||
"CreateAPIKeyRequest",
|
"CreateAPIKeyRequest",
|
||||||
"CreateHostRequest",
|
"CreateHostRequest",
|
||||||
"CreateHostResponse",
|
"CreateHostResponse",
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
# generated by datamodel-codegen:
|
# generated by datamodel-codegen:
|
||||||
# filename: openapi.yaml
|
# filename: openapi.yaml
|
||||||
# timestamp: 2026-04-22T20:21:34+00:00
|
# timestamp: 2026-05-19T08:54:50+00:00
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
|
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
|
||||||
from typing import Annotated
|
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
|
||||||
|
|
||||||
@ -27,14 +27,20 @@ class SignupResponse(BaseModel):
|
|||||||
] = None
|
] = None
|
||||||
|
|
||||||
|
|
||||||
class AuthResponse(BaseModel):
|
class SessionResponse(BaseModel):
|
||||||
token: Annotated[str | None, Field(description="JWT token (valid for 6 hours)")] = (
|
"""
|
||||||
None
|
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
|
user_id: str | None = None
|
||||||
team_id: str | None = None
|
team_id: str | None = None
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
is_admin: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class CreateAPIKeyRequest(BaseModel):
|
class CreateAPIKeyRequest(BaseModel):
|
||||||
@ -62,10 +68,17 @@ class CreateCapsuleRequest(BaseModel):
|
|||||||
template: str | None = "minimal"
|
template: str | None = "minimal"
|
||||||
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(
|
||||||
description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause.\n"
|
description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause. Positive values below 60 are silently clamped to 60 (the agent's startup envelope).\n",
|
||||||
|
ge=0,
|
||||||
),
|
),
|
||||||
] = 0
|
] = 0
|
||||||
|
|
||||||
@ -133,7 +146,10 @@ class Status(StrEnum):
|
|||||||
pending = "pending"
|
pending = "pending"
|
||||||
starting = "starting"
|
starting = "starting"
|
||||||
running = "running"
|
running = "running"
|
||||||
|
pausing = "pausing"
|
||||||
paused = "paused"
|
paused = "paused"
|
||||||
|
resuming = "resuming"
|
||||||
|
stopping = "stopping"
|
||||||
hibernated = "hibernated"
|
hibernated = "hibernated"
|
||||||
stopped = "stopped"
|
stopped = "stopped"
|
||||||
missing = "missing"
|
missing = "missing"
|
||||||
@ -153,6 +169,13 @@ class Capsule(BaseModel):
|
|||||||
started_at: AwareDatetime | None = None
|
started_at: AwareDatetime | None = None
|
||||||
last_active_at: AwareDatetime | None = None
|
last_active_at: AwareDatetime | None = None
|
||||||
last_updated: AwareDatetime | None = None
|
last_updated: AwareDatetime | None = None
|
||||||
|
metadata: Annotated[
|
||||||
|
dict[str, str] | None,
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
disk_size_mb: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class CreateSnapshotRequest(BaseModel):
|
class CreateSnapshotRequest(BaseModel):
|
||||||
@ -177,6 +200,13 @@ class Template(BaseModel):
|
|||||||
memory_mb: int | None = None
|
memory_mb: int | None = None
|
||||||
size_bytes: int | None = None
|
size_bytes: int | None = None
|
||||||
created_at: AwareDatetime | None = None
|
created_at: AwareDatetime | None = None
|
||||||
|
platform: Annotated[
|
||||||
|
bool | None,
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
metadata: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ExecRequest(BaseModel):
|
class ExecRequest(BaseModel):
|
||||||
@ -399,7 +429,7 @@ class HostDeletePreview(BaseModel):
|
|||||||
host: Host | None = None
|
host: Host | None = None
|
||||||
sandbox_ids: Annotated[
|
sandbox_ids: Annotated[
|
||||||
list[str] | None,
|
list[str] | None,
|
||||||
Field(description="IDs of capsulees that would be destroyed on force-delete."),
|
Field(description="IDs of capsules that would be destroyed on force-delete."),
|
||||||
] = None
|
] = None
|
||||||
|
|
||||||
|
|
||||||
@ -407,8 +437,7 @@ class Error(BaseModel):
|
|||||||
code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None
|
code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None
|
||||||
message: str | None = None
|
message: str | None = None
|
||||||
sandbox_ids: Annotated[
|
sandbox_ids: Annotated[
|
||||||
list[str] | None,
|
list[str] | None, Field(description="IDs of active capsules blocking deletion.")
|
||||||
Field(description="IDs of active capsulees blocking deletion."),
|
|
||||||
] = None
|
] = None
|
||||||
|
|
||||||
|
|
||||||
@ -476,7 +505,9 @@ class MetricPoint(BaseModel):
|
|||||||
] = None
|
] = None
|
||||||
mem_bytes: Annotated[
|
mem_bytes: Annotated[
|
||||||
int | None,
|
int | None,
|
||||||
Field(description="Resident memory in bytes (VmRSS of Firecracker process)"),
|
Field(
|
||||||
|
description="Resident memory in bytes (VmRSS of Cloud Hypervisor process)"
|
||||||
|
),
|
||||||
] = None
|
] = None
|
||||||
disk_bytes: Annotated[
|
disk_bytes: Annotated[
|
||||||
int | None, Field(description="Allocated disk bytes for the CoW sparse file")
|
int | None, Field(description="Allocated disk bytes for the CoW sparse file")
|
||||||
@ -494,12 +525,12 @@ class Provider(StrEnum):
|
|||||||
|
|
||||||
|
|
||||||
class Event(StrEnum):
|
class Event(StrEnum):
|
||||||
capsule_created = "capsule.created"
|
capsule_create = "capsule.create"
|
||||||
capsule_running = "capsule.running"
|
capsule_pause = "capsule.pause"
|
||||||
capsule_paused = "capsule.paused"
|
capsule_resume = "capsule.resume"
|
||||||
capsule_destroyed = "capsule.destroyed"
|
capsule_destroy = "capsule.destroy"
|
||||||
template_snapshot_created = "template.snapshot.created"
|
template_snapshot_create = "template.snapshot.create"
|
||||||
template_snapshot_deleted = "template.snapshot.deleted"
|
template_snapshot_delete = "template.snapshot.delete"
|
||||||
host_up = "host.up"
|
host_up = "host.up"
|
||||||
host_down = "host.down"
|
host_down = "host.down"
|
||||||
|
|
||||||
@ -591,6 +622,106 @@ class Error1(BaseModel):
|
|||||||
error: Error2 | None = None
|
error: Error2 | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ActorType(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"
|
||||||
|
capsule_create = "capsule.create"
|
||||||
|
capsule_pause = "capsule.pause"
|
||||||
|
capsule_resume = "capsule.resume"
|
||||||
|
capsule_destroy = "capsule.destroy"
|
||||||
|
capsule_state_changed = "capsule.state.changed"
|
||||||
|
template_snapshot_create = "template.snapshot.create"
|
||||||
|
template_snapshot_delete = "template.snapshot.delete"
|
||||||
|
host_up = "host.up"
|
||||||
|
host_down = "host.down"
|
||||||
|
|
||||||
|
|
||||||
|
class Outcome(StrEnum):
|
||||||
|
"""
|
||||||
|
Present for action events (capsule.* except state.changed,
|
||||||
|
template.snapshot.*). Absent for host.up/down, capsule.state.changed,
|
||||||
|
and the connected sentinel.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
success = "success"
|
||||||
|
error = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class Resource(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Type4(StrEnum):
|
||||||
|
user = "user"
|
||||||
|
api_key = "api_key"
|
||||||
|
system = "system"
|
||||||
|
|
||||||
|
|
||||||
|
class Actor(BaseModel):
|
||||||
|
type: Type4 | None = None
|
||||||
|
id: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SSEEvent(BaseModel):
|
||||||
|
"""
|
||||||
|
Wire format of one SSE message body. The event name (`event:` line) is
|
||||||
|
the `kind` and the JSON below is the `data:` line.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
event: Event2 | None = None
|
||||||
|
outcome: Annotated[
|
||||||
|
Outcome | None,
|
||||||
|
Field(
|
||||||
|
description="Present for action events (capsule.* except state.changed,\ntemplate.snapshot.*). Absent for host.up/down, capsule.state.changed,\nand the connected sentinel.\n"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
resource: Resource | None = None
|
||||||
|
actor: Actor | None = None
|
||||||
|
metadata: Annotated[
|
||||||
|
dict[str, str] | None,
|
||||||
|
Field(
|
||||||
|
description="Event-specific context. Examples: `reason` (ttl_expired,\nhost_failure, cleanup_after_create_error, orphaned),\n`host_ip`, `from`/`to` (for capsule.state.changed).\n"
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
error: Annotated[
|
||||||
|
str | None, Field(description="Failure reason; only set when outcome=error.")
|
||||||
|
] = None
|
||||||
|
sandbox: Annotated[
|
||||||
|
Capsule | None,
|
||||||
|
Field(description="Populated for capsule.* events; null if DB lookup failed."),
|
||||||
|
] = None
|
||||||
|
timestamp: AwareDatetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class ListDirResponse(BaseModel):
|
class ListDirResponse(BaseModel):
|
||||||
entries: list[FileEntry] | None = None
|
entries: list[FileEntry] | None = None
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,10 @@ from typing import Any
|
|||||||
import httpx_ws
|
import httpx_ws
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
# A clean (``WebSocketDisconnect``) or abrupt (``WebSocketNetworkError``) close
|
||||||
|
# both mean the PTY stream has ended; iteration must stop on either.
|
||||||
|
_WS_CLOSED = (httpx_ws.WebSocketDisconnect, httpx_ws.WebSocketNetworkError)
|
||||||
|
|
||||||
|
|
||||||
class PtyEventType(StrEnum):
|
class PtyEventType(StrEnum):
|
||||||
started = "started"
|
started = "started"
|
||||||
@ -109,6 +113,13 @@ class PtySession:
|
|||||||
def _send_connect(self, tag: str) -> None:
|
def _send_connect(self, tag: str) -> None:
|
||||||
self._ws.send_text(json.dumps({"type": "connect", "tag": tag}))
|
self._ws.send_text(json.dumps({"type": "connect", "tag": tag}))
|
||||||
|
|
||||||
|
def _send_pong(self) -> None:
|
||||||
|
"""Reply to a server keepalive ``ping`` so the session stays open."""
|
||||||
|
try:
|
||||||
|
self._ws.send_text(json.dumps({"type": "pong"}))
|
||||||
|
except _WS_CLOSED:
|
||||||
|
pass
|
||||||
|
|
||||||
def write(self, data: bytes) -> None:
|
def write(self, data: bytes) -> None:
|
||||||
"""Send raw bytes to the PTY stdin.
|
"""Send raw bytes to the PTY stdin.
|
||||||
|
|
||||||
@ -144,7 +155,7 @@ class PtySession:
|
|||||||
raise StopIteration
|
raise StopIteration
|
||||||
try:
|
try:
|
||||||
raw = self._ws.receive_text()
|
raw = self._ws.receive_text()
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
raise StopIteration
|
raise StopIteration
|
||||||
event = _parse_pty_event(json.loads(raw))
|
event = _parse_pty_event(json.loads(raw))
|
||||||
if event.type == PtyEventType.started:
|
if event.type == PtyEventType.started:
|
||||||
@ -152,8 +163,11 @@ class PtySession:
|
|||||||
self._tag = event.tag
|
self._tag = event.tag
|
||||||
if event.pid is not None:
|
if event.pid is not None:
|
||||||
self._pid = event.pid
|
self._pid = event.pid
|
||||||
|
if event.type == PtyEventType.ping:
|
||||||
|
self._send_pong()
|
||||||
if event.type == PtyEventType.exit:
|
if event.type == PtyEventType.exit:
|
||||||
raise StopIteration
|
self._done = True
|
||||||
|
return event
|
||||||
if event.type == PtyEventType.error and event.fatal:
|
if event.type == PtyEventType.error and event.fatal:
|
||||||
self._done = True
|
self._done = True
|
||||||
return event
|
return event
|
||||||
@ -235,6 +249,13 @@ class AsyncPtySession:
|
|||||||
async def _send_connect(self, tag: str) -> None:
|
async def _send_connect(self, tag: str) -> None:
|
||||||
await self._ws.send_text(json.dumps({"type": "connect", "tag": tag}))
|
await self._ws.send_text(json.dumps({"type": "connect", "tag": tag}))
|
||||||
|
|
||||||
|
async def _send_pong(self) -> None:
|
||||||
|
"""Reply to a server keepalive ``ping`` so the session stays open."""
|
||||||
|
try:
|
||||||
|
await self._ws.send_text(json.dumps({"type": "pong"}))
|
||||||
|
except _WS_CLOSED:
|
||||||
|
pass
|
||||||
|
|
||||||
async def write(self, data: bytes) -> None:
|
async def write(self, data: bytes) -> None:
|
||||||
"""Send raw bytes to the PTY stdin.
|
"""Send raw bytes to the PTY stdin.
|
||||||
|
|
||||||
@ -272,7 +293,7 @@ class AsyncPtySession:
|
|||||||
raise StopAsyncIteration
|
raise StopAsyncIteration
|
||||||
try:
|
try:
|
||||||
raw = await self._ws.receive_text()
|
raw = await self._ws.receive_text()
|
||||||
except httpx_ws.WebSocketDisconnect:
|
except _WS_CLOSED:
|
||||||
raise StopAsyncIteration
|
raise StopAsyncIteration
|
||||||
event = _parse_pty_event(json.loads(raw))
|
event = _parse_pty_event(json.loads(raw))
|
||||||
if event.type == PtyEventType.started:
|
if event.type == PtyEventType.started:
|
||||||
@ -280,8 +301,11 @@ class AsyncPtySession:
|
|||||||
self._tag = event.tag
|
self._tag = event.tag
|
||||||
if event.pid is not None:
|
if event.pid is not None:
|
||||||
self._pid = event.pid
|
self._pid = event.pid
|
||||||
|
if event.type == PtyEventType.ping:
|
||||||
|
await self._send_pong()
|
||||||
if event.type == PtyEventType.exit:
|
if event.type == PtyEventType.exit:
|
||||||
raise StopAsyncIteration
|
self._done = True
|
||||||
|
return event
|
||||||
if event.type == PtyEventType.error and event.fatal:
|
if event.type == PtyEventType.error and event.fatal:
|
||||||
self._done = True
|
self._done = True
|
||||||
return event
|
return event
|
||||||
|
|||||||
@ -1,104 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from collections.abc import AsyncGenerator, Generator
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
|
|
||||||
from wrenn.async_capsule import AsyncCapsule
|
|
||||||
from wrenn.capsule import Capsule
|
|
||||||
from wrenn.client import AsyncWrennClient, WrennClient
|
|
||||||
|
|
||||||
WRENN_API_KEY = os.environ.get("WRENN_API_KEY")
|
|
||||||
WRENN_BASE_URL = os.environ.get("WRENN_BASE_URL", "http://localhost:8080")
|
|
||||||
|
|
||||||
_env_loaded = False
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_env() -> None:
|
|
||||||
global _env_loaded
|
|
||||||
if _env_loaded:
|
|
||||||
return
|
|
||||||
_env_loaded = True
|
|
||||||
env_file = Path(__file__).resolve().parent.parent / ".env"
|
|
||||||
if not env_file.exists():
|
|
||||||
return
|
|
||||||
for line in env_file.read_text().splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
|
||||||
continue
|
|
||||||
key, _, value = line.partition("=")
|
|
||||||
key, value = key.strip(), value.strip().strip("\"'")
|
|
||||||
if key and key not in os.environ:
|
|
||||||
os.environ[key] = value
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _load_env():
|
|
||||||
_ensure_env()
|
|
||||||
|
|
||||||
|
|
||||||
def _has_auth() -> bool:
|
|
||||||
return bool(WRENN_API_KEY)
|
|
||||||
|
|
||||||
|
|
||||||
requires_auth = pytest.mark.skipif(
|
|
||||||
not _has_auth(),
|
|
||||||
reason="Set WRENN_API_KEY to run integration tests",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client() -> Generator[WrennClient, None, None]:
|
|
||||||
with WrennClient(
|
|
||||||
api_key=WRENN_API_KEY,
|
|
||||||
base_url=WRENN_BASE_URL,
|
|
||||||
) as c:
|
|
||||||
yield c
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def async_client() -> AsyncGenerator[AsyncWrennClient, None]:
|
|
||||||
async with AsyncWrennClient(api_key=WRENN_API_KEY, base_url=WRENN_BASE_URL) as c:
|
|
||||||
yield c
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def async_minimal_capsule() -> AsyncGenerator[AsyncCapsule, None]:
|
|
||||||
"""Provides a ready-to-use minimal capsule and cleans it up afterward."""
|
|
||||||
async with await AsyncCapsule.create(
|
|
||||||
template="minimal",
|
|
||||||
timeout=120,
|
|
||||||
wait=True,
|
|
||||||
api_key=WRENN_API_KEY,
|
|
||||||
base_url=WRENN_BASE_URL,
|
|
||||||
) as cap:
|
|
||||||
yield cap
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def async_python_capsule() -> AsyncGenerator[AsyncCapsule, None]:
|
|
||||||
"""Provides a ready-to-use Python interpreter capsule."""
|
|
||||||
async with await AsyncCapsule.create(
|
|
||||||
template="python-interpreter-v0-beta",
|
|
||||||
timeout=120,
|
|
||||||
wait=True,
|
|
||||||
api_key=WRENN_API_KEY,
|
|
||||||
base_url=WRENN_BASE_URL,
|
|
||||||
) as cap:
|
|
||||||
yield cap
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def minimal_capsule() -> Generator[Capsule, None, None]:
|
|
||||||
"""Provides a ready-to-use minimal capsule and cleans it up afterward."""
|
|
||||||
with Capsule(
|
|
||||||
template="minimal",
|
|
||||||
timeout=120,
|
|
||||||
wait=True,
|
|
||||||
api_key=WRENN_API_KEY,
|
|
||||||
base_url=WRENN_BASE_URL,
|
|
||||||
) as cap:
|
|
||||||
yield cap
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from wrenn import Capsule, CommandResult
|
|
||||||
from wrenn.commands import CommandHandle, ProcessInfo
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
|
||||||
|
|
||||||
|
|
||||||
class TestCommands:
|
|
||||||
"""Shared capsule for command execution tests."""
|
|
||||||
|
|
||||||
capsule: Capsule
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setup_class(cls):
|
|
||||||
cls.capsule = Capsule(wait=True)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def teardown_class(cls):
|
|
||||||
try:
|
|
||||||
cls.capsule.destroy()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_run_foreground(self):
|
|
||||||
result = self.capsule.commands.run("echo hello")
|
|
||||||
assert isinstance(result, CommandResult)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "hello" in result.stdout
|
|
||||||
|
|
||||||
def test_run_stderr(self):
|
|
||||||
result = self.capsule.commands.run("echo error >&2")
|
|
||||||
assert "error" in result.stderr
|
|
||||||
|
|
||||||
def test_run_exit_code(self):
|
|
||||||
result = self.capsule.commands.run("exit 42")
|
|
||||||
assert result.exit_code == 42
|
|
||||||
|
|
||||||
def test_run_with_envs(self):
|
|
||||||
result = self.capsule.commands.run("export MY_VAR=test_value && echo $MY_VAR")
|
|
||||||
assert "test_value" in result.stdout
|
|
||||||
|
|
||||||
def test_run_with_cwd(self):
|
|
||||||
result = self.capsule.commands.run("cd /tmp && pwd")
|
|
||||||
assert result.stdout.strip() == "/tmp"
|
|
||||||
|
|
||||||
def test_run_multiline_output(self):
|
|
||||||
result = self.capsule.commands.run("echo -e 'line1\\nline2\\nline3'")
|
|
||||||
assert result.exit_code == 0
|
|
||||||
lines = result.stdout.strip().splitlines()
|
|
||||||
assert len(lines) == 3
|
|
||||||
|
|
||||||
def test_run_background(self):
|
|
||||||
handle = self.capsule.commands.run("sleep 30", background=True, tag="bg-test")
|
|
||||||
assert isinstance(handle, CommandHandle)
|
|
||||||
assert handle.pid > 0
|
|
||||||
assert handle.tag == "bg-test"
|
|
||||||
assert handle.capsule_id == self.capsule.capsule_id
|
|
||||||
|
|
||||||
self.capsule.commands.kill(handle.pid)
|
|
||||||
|
|
||||||
def test_list_processes(self):
|
|
||||||
handle = self.capsule.commands.run("sleep 30", background=True, tag="list-test")
|
|
||||||
try:
|
|
||||||
time.sleep(0.5)
|
|
||||||
processes = self.capsule.commands.list()
|
|
||||||
assert isinstance(processes, list)
|
|
||||||
pids = [p.pid for p in processes]
|
|
||||||
assert handle.pid in pids
|
|
||||||
|
|
||||||
proc = next(p for p in processes if p.pid == handle.pid)
|
|
||||||
assert isinstance(proc, ProcessInfo)
|
|
||||||
finally:
|
|
||||||
self.capsule.commands.kill(handle.pid)
|
|
||||||
|
|
||||||
def test_kill_process(self):
|
|
||||||
handle = self.capsule.commands.run("sleep 30", background=True)
|
|
||||||
self.capsule.commands.kill(handle.pid)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
processes = self.capsule.commands.list()
|
|
||||||
pids = [p.pid for p in processes]
|
|
||||||
assert handle.pid not in pids
|
|
||||||
|
|
||||||
def test_run_duration_ms(self):
|
|
||||||
result = self.capsule.commands.run("sleep 1")
|
|
||||||
assert result.duration_ms is None or result.duration_ms >= 900
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from wrenn import Capsule
|
|
||||||
from wrenn.models import FileEntry
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
|
||||||
|
|
||||||
|
|
||||||
class TestFiles:
|
|
||||||
"""Shared capsule for filesystem tests."""
|
|
||||||
|
|
||||||
capsule: Capsule
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setup_class(cls):
|
|
||||||
cls.capsule = Capsule(wait=True)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def teardown_class(cls):
|
|
||||||
try:
|
|
||||||
cls.capsule.destroy()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_write_and_read(self):
|
|
||||||
self.capsule.files.write("/tmp/test.txt", "hello world")
|
|
||||||
content = self.capsule.files.read("/tmp/test.txt")
|
|
||||||
assert content == "hello world"
|
|
||||||
|
|
||||||
def test_write_and_read_bytes(self):
|
|
||||||
data = b"\x00\x01\x02\xff"
|
|
||||||
self.capsule.files.write("/tmp/test.bin", data)
|
|
||||||
result = self.capsule.files.read_bytes("/tmp/test.bin")
|
|
||||||
assert result == data
|
|
||||||
|
|
||||||
def test_list_directory(self):
|
|
||||||
self.capsule.files.write("/tmp/listdir/a.txt", "a")
|
|
||||||
self.capsule.files.write("/tmp/listdir/b.txt", "b")
|
|
||||||
entries = self.capsule.files.list("/tmp/listdir")
|
|
||||||
assert isinstance(entries, list)
|
|
||||||
names = [e.name for e in entries]
|
|
||||||
assert "a.txt" in names
|
|
||||||
assert "b.txt" in names
|
|
||||||
|
|
||||||
def test_exists(self):
|
|
||||||
self.capsule.files.write("/tmp/exists_test.txt", "x")
|
|
||||||
assert self.capsule.files.exists("/tmp/exists_test.txt")
|
|
||||||
assert not self.capsule.files.exists("/tmp/does_not_exist_xyz.txt")
|
|
||||||
|
|
||||||
def test_make_dir(self):
|
|
||||||
entry = self.capsule.files.make_dir("/tmp/newdir")
|
|
||||||
assert isinstance(entry, FileEntry)
|
|
||||||
assert self.capsule.files.exists("/tmp/newdir")
|
|
||||||
|
|
||||||
def test_make_dir_idempotent(self):
|
|
||||||
self.capsule.files.make_dir("/tmp/idempotent_dir")
|
|
||||||
entry = self.capsule.files.make_dir("/tmp/idempotent_dir")
|
|
||||||
assert isinstance(entry, FileEntry)
|
|
||||||
|
|
||||||
def test_remove_file(self):
|
|
||||||
self.capsule.files.write("/tmp/to_remove.txt", "delete me")
|
|
||||||
assert self.capsule.files.exists("/tmp/to_remove.txt")
|
|
||||||
self.capsule.files.remove("/tmp/to_remove.txt")
|
|
||||||
assert not self.capsule.files.exists("/tmp/to_remove.txt")
|
|
||||||
|
|
||||||
def test_remove_directory(self):
|
|
||||||
self.capsule.files.make_dir("/tmp/dir_to_remove")
|
|
||||||
self.capsule.files.write("/tmp/dir_to_remove/child.txt", "data")
|
|
||||||
self.capsule.files.remove("/tmp/dir_to_remove")
|
|
||||||
assert not self.capsule.files.exists("/tmp/dir_to_remove")
|
|
||||||
|
|
||||||
def test_write_creates_parent_dirs(self):
|
|
||||||
self.capsule.files.write("/tmp/deep/nested/dir/file.txt", "nested")
|
|
||||||
content = self.capsule.files.read("/tmp/deep/nested/dir/file.txt")
|
|
||||||
assert content == "nested"
|
|
||||||
|
|
||||||
def test_list_with_depth(self):
|
|
||||||
self.capsule.files.write("/tmp/depth_test/a/b.txt", "deep")
|
|
||||||
entries_shallow = self.capsule.files.list("/tmp/depth_test", depth=1)
|
|
||||||
entries_deep = self.capsule.files.list("/tmp/depth_test", depth=2)
|
|
||||||
assert len(entries_deep) >= len(entries_shallow)
|
|
||||||
|
|
||||||
def test_overwrite_file(self):
|
|
||||||
self.capsule.files.write("/tmp/overwrite.txt", "original")
|
|
||||||
self.capsule.files.write("/tmp/overwrite.txt", "updated")
|
|
||||||
content = self.capsule.files.read("/tmp/overwrite.txt")
|
|
||||||
assert content == "updated"
|
|
||||||
|
|
||||||
def test_upload_and_download_stream(self):
|
|
||||||
chunks = [b"chunk1", b"chunk2", b"chunk3"]
|
|
||||||
self.capsule.files.upload_stream("/tmp/streamed.bin", iter(chunks))
|
|
||||||
downloaded = b"".join(self.capsule.files.download_stream("/tmp/streamed.bin"))
|
|
||||||
assert downloaded == b"chunk1chunk2chunk3"
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from wrenn import Capsule
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
|
||||||
|
|
||||||
|
|
||||||
class TestGit:
|
|
||||||
"""Shared capsule for git operation tests.
|
|
||||||
|
|
||||||
Initializes a repo at /root (default cwd) since the exec API
|
|
||||||
does not support the cwd parameter.
|
|
||||||
"""
|
|
||||||
|
|
||||||
capsule: Capsule
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setup_class(cls):
|
|
||||||
cls.capsule = Capsule(wait=True)
|
|
||||||
cls.capsule.git.init(".", initial_branch="main")
|
|
||||||
cls.capsule.git.configure_user("Test User", "test@example.com")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def teardown_class(cls):
|
|
||||||
try:
|
|
||||||
cls.capsule.destroy()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_init_created_repo(self):
|
|
||||||
assert self.capsule.files.exists("/root/.git")
|
|
||||||
|
|
||||||
def test_status_clean(self):
|
|
||||||
status = self.capsule.git.status()
|
|
||||||
assert status.branch == "main"
|
|
||||||
|
|
||||||
def test_add_and_commit(self):
|
|
||||||
self.capsule.files.write("/root/hello.txt", "hello git")
|
|
||||||
self.capsule.git.add(all=True)
|
|
||||||
result = self.capsule.git.commit("initial commit")
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
def test_status_after_commit(self):
|
|
||||||
status = self.capsule.git.status()
|
|
||||||
assert status.is_clean
|
|
||||||
|
|
||||||
def test_status_with_changes(self):
|
|
||||||
self.capsule.files.write("/root/dirty.txt", "uncommitted")
|
|
||||||
try:
|
|
||||||
status = self.capsule.git.status()
|
|
||||||
assert not status.is_clean
|
|
||||||
paths = [f.path for f in status.files]
|
|
||||||
assert "dirty.txt" in paths
|
|
||||||
finally:
|
|
||||||
self.capsule.files.remove("/root/dirty.txt")
|
|
||||||
|
|
||||||
def test_branches(self):
|
|
||||||
branches = self.capsule.git.branches()
|
|
||||||
assert len(branches) >= 1
|
|
||||||
names = [b.name for b in branches]
|
|
||||||
assert "main" in names
|
|
||||||
current = [b for b in branches if b.is_current]
|
|
||||||
assert len(current) == 1
|
|
||||||
|
|
||||||
def test_create_and_checkout_branch(self):
|
|
||||||
self.capsule.git.create_branch("feature-1")
|
|
||||||
branches = self.capsule.git.branches()
|
|
||||||
names = [b.name for b in branches]
|
|
||||||
assert "feature-1" in names
|
|
||||||
|
|
||||||
current = [b for b in branches if b.is_current]
|
|
||||||
assert current[0].name == "feature-1"
|
|
||||||
|
|
||||||
self.capsule.git.checkout_branch("main")
|
|
||||||
|
|
||||||
def test_delete_branch(self):
|
|
||||||
self.capsule.git.create_branch("to-delete")
|
|
||||||
self.capsule.git.checkout_branch("main")
|
|
||||||
self.capsule.git.delete_branch("to-delete")
|
|
||||||
|
|
||||||
branches = self.capsule.git.branches()
|
|
||||||
names = [b.name for b in branches]
|
|
||||||
assert "to-delete" not in names
|
|
||||||
|
|
||||||
def test_set_and_get_config(self):
|
|
||||||
self.capsule.git.set_config("test.key", "test-value")
|
|
||||||
value = self.capsule.git.get_config("test.key")
|
|
||||||
assert value == "test-value"
|
|
||||||
|
|
||||||
def test_get_config_missing_returns_none(self):
|
|
||||||
value = self.capsule.git.get_config("nonexistent.key")
|
|
||||||
assert value is None
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from wrenn import Capsule
|
|
||||||
from wrenn.models import Capsule as CapsuleModel, Status
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
|
||||||
|
|
||||||
|
|
||||||
class TestCapsuleLifecycle:
|
|
||||||
"""Each test manages its own capsule to test create/destroy paths."""
|
|
||||||
|
|
||||||
def test_create_and_destroy(self):
|
|
||||||
capsule = Capsule()
|
|
||||||
capsule_id = capsule.capsule_id
|
|
||||||
try:
|
|
||||||
assert capsule_id
|
|
||||||
assert capsule.info is not None
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
|
|
||||||
info = Capsule.get_info(capsule_id)
|
|
||||||
assert info.status in (Status.stopped, Status.missing)
|
|
||||||
|
|
||||||
def test_create_with_wait(self):
|
|
||||||
capsule = Capsule(wait=True)
|
|
||||||
try:
|
|
||||||
assert capsule.info is not None
|
|
||||||
assert capsule.info.status == Status.running
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
|
|
||||||
def test_context_manager_destroys(self):
|
|
||||||
with Capsule(wait=True) as capsule:
|
|
||||||
capsule_id = capsule.capsule_id
|
|
||||||
assert capsule.is_running()
|
|
||||||
|
|
||||||
info = Capsule.get_info(capsule_id)
|
|
||||||
assert info.status in (Status.stopped, Status.missing)
|
|
||||||
|
|
||||||
def test_get_info(self):
|
|
||||||
capsule = Capsule(wait=True)
|
|
||||||
try:
|
|
||||||
info = capsule.get_info()
|
|
||||||
assert isinstance(info, CapsuleModel)
|
|
||||||
assert info.id == capsule.capsule_id
|
|
||||||
assert info.status == Status.running
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
|
|
||||||
def test_pause_and_resume(self):
|
|
||||||
capsule = Capsule(wait=True)
|
|
||||||
try:
|
|
||||||
paused = capsule.pause()
|
|
||||||
assert paused.status == Status.paused
|
|
||||||
assert not capsule.is_running()
|
|
||||||
|
|
||||||
resumed = capsule.resume()
|
|
||||||
assert resumed.status == Status.running
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
|
|
||||||
def test_static_destroy(self):
|
|
||||||
capsule = Capsule(wait=True)
|
|
||||||
capsule_id = capsule.capsule_id
|
|
||||||
try:
|
|
||||||
Capsule.destroy(capsule_id)
|
|
||||||
except Exception:
|
|
||||||
capsule.destroy()
|
|
||||||
raise
|
|
||||||
|
|
||||||
info = Capsule.get_info(capsule_id)
|
|
||||||
assert info.status in (Status.stopped, Status.missing)
|
|
||||||
|
|
||||||
def test_connect_to_existing(self):
|
|
||||||
capsule = Capsule(wait=True)
|
|
||||||
try:
|
|
||||||
connected = Capsule.connect(capsule.capsule_id)
|
|
||||||
assert connected.capsule_id == capsule.capsule_id
|
|
||||||
assert connected.info is not None
|
|
||||||
assert connected.info.status == Status.running
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
|
|
||||||
def test_connect_resumes_paused(self):
|
|
||||||
capsule = Capsule(wait=True)
|
|
||||||
try:
|
|
||||||
capsule.pause()
|
|
||||||
connected = Capsule.connect(capsule.capsule_id)
|
|
||||||
assert connected.info is not None
|
|
||||||
assert connected.info.status == Status.running
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
|
|
||||||
def test_list_capsules(self):
|
|
||||||
capsule = Capsule(wait=True)
|
|
||||||
try:
|
|
||||||
capsules = Capsule.list()
|
|
||||||
assert isinstance(capsules, list)
|
|
||||||
ids = [c.id for c in capsules]
|
|
||||||
assert capsule.capsule_id in ids
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
|
|
||||||
def test_wait_ready(self):
|
|
||||||
capsule = Capsule()
|
|
||||||
try:
|
|
||||||
capsule.wait_ready(timeout=60)
|
|
||||||
assert capsule.is_running()
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
|
|
||||||
def test_ping(self):
|
|
||||||
capsule = Capsule(wait=True)
|
|
||||||
try:
|
|
||||||
capsule.ping()
|
|
||||||
finally:
|
|
||||||
capsule.destroy()
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
import respx
|
import respx
|
||||||
|
|
||||||
from wrenn.capsule import Capsule, _build_proxy_url
|
from wrenn.capsule import Capsule, _build_proxy_url
|
||||||
@ -30,9 +31,13 @@ class TestCapsuleCreate:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
def test_capsule_constructor_creates(self):
|
def test_capsule_constructor_creates(self):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "cl-1", "status": "pending", "template": "minimal"}
|
202, json={"id": "cl-1", "status": "starting", "template": "minimal"}
|
||||||
|
)
|
||||||
|
cap = Capsule(
|
||||||
|
template="minimal",
|
||||||
|
api_key="wrn_test1234567890abcdef12345678",
|
||||||
|
base_url=BASE,
|
||||||
)
|
)
|
||||||
cap = Capsule(template="minimal", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
|
||||||
assert cap.capsule_id == "cl-1"
|
assert cap.capsule_id == "cl-1"
|
||||||
assert hasattr(cap, "commands")
|
assert hasattr(cap, "commands")
|
||||||
assert hasattr(cap, "files")
|
assert hasattr(cap, "files")
|
||||||
@ -40,7 +45,7 @@ class TestCapsuleCreate:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
def test_capsule_create_classmethod(self):
|
def test_capsule_create_classmethod(self):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "cl-2", "status": "pending"}
|
202, json={"id": "cl-2", "status": "starting"}
|
||||||
)
|
)
|
||||||
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||||
assert cap.capsule_id == "cl-2"
|
assert cap.capsule_id == "cl-2"
|
||||||
@ -48,9 +53,9 @@ class TestCapsuleCreate:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
def test_capsule_context_manager_kills(self):
|
def test_capsule_context_manager_kills(self):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "cl-1", "status": "pending"}
|
202, json={"id": "cl-1", "status": "starting"}
|
||||||
)
|
)
|
||||||
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
|
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
|
||||||
with Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as cap:
|
with Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as cap:
|
||||||
assert cap.capsule_id == "cl-1"
|
assert cap.capsule_id == "cl-1"
|
||||||
assert kill_route.called
|
assert kill_route.called
|
||||||
@ -59,7 +64,7 @@ class TestCapsuleCreate:
|
|||||||
def test_capsule_env_var(self, monkeypatch):
|
def test_capsule_env_var(self, monkeypatch):
|
||||||
monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env_key")
|
monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env_key")
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "cl-3", "status": "pending"}
|
202, json={"id": "cl-3", "status": "starting"}
|
||||||
)
|
)
|
||||||
cap = Capsule(base_url=BASE)
|
cap = Capsule(base_url=BASE)
|
||||||
assert cap.capsule_id == "cl-3"
|
assert cap.capsule_id == "cl-3"
|
||||||
@ -68,17 +73,21 @@ class TestCapsuleCreate:
|
|||||||
class TestCapsuleStaticMethods:
|
class TestCapsuleStaticMethods:
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_static_destroy(self):
|
def test_static_destroy(self):
|
||||||
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
|
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
|
||||||
Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
Capsule._static_destroy(
|
||||||
|
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||||
|
)
|
||||||
assert route.called
|
assert route.called
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_static_pause(self):
|
def test_static_pause(self):
|
||||||
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
|
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
|
||||||
200, json={"id": "cl-1", "status": "paused"}
|
202, json={"id": "cl-1", "status": "pausing"}
|
||||||
)
|
)
|
||||||
info = Capsule._static_pause("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
info = Capsule._static_pause(
|
||||||
assert info.status.value == "paused"
|
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||||
|
)
|
||||||
|
assert info.status.value == "pausing"
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_static_list(self):
|
def test_static_list(self):
|
||||||
@ -106,18 +115,24 @@ class TestCapsuleConnect:
|
|||||||
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
||||||
200, json={"id": "cl-1", "status": "running"}
|
200, json={"id": "cl-1", "status": "running"}
|
||||||
)
|
)
|
||||||
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
cap = Capsule.connect(
|
||||||
|
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||||
|
)
|
||||||
assert cap.capsule_id == "cl-1"
|
assert cap.capsule_id == "cl-1"
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_connect_paused_resumes(self):
|
def test_connect_paused_resumes(self):
|
||||||
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
get_route = respx.get(f"{BASE}/v1/capsules/cl-1")
|
||||||
200, json={"id": "cl-1", "status": "paused"}
|
get_route.side_effect = [
|
||||||
)
|
httpx.Response(200, json={"id": "cl-1", "status": "paused"}),
|
||||||
|
httpx.Response(200, json={"id": "cl-1", "status": "running"}),
|
||||||
|
]
|
||||||
respx.post(f"{BASE}/v1/capsules/cl-1/resume").respond(
|
respx.post(f"{BASE}/v1/capsules/cl-1/resume").respond(
|
||||||
200, json={"id": "cl-1", "status": "running"}
|
202, json={"id": "cl-1", "status": "resuming"}
|
||||||
|
)
|
||||||
|
cap = Capsule.connect(
|
||||||
|
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||||
)
|
)
|
||||||
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
|
||||||
assert cap.capsule_id == "cl-1"
|
assert cap.capsule_id == "cl-1"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -36,10 +36,10 @@ class TestCapsules:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
def test_create(self, client):
|
def test_create(self, client):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201,
|
202,
|
||||||
json={
|
json={
|
||||||
"id": "sb-1",
|
"id": "sb-1",
|
||||||
"status": "pending",
|
"status": "starting",
|
||||||
"template": "base-python",
|
"template": "base-python",
|
||||||
"vcpus": 2,
|
"vcpus": 2,
|
||||||
"memory_mb": 1024,
|
"memory_mb": 1024,
|
||||||
@ -48,12 +48,12 @@ class TestCapsules:
|
|||||||
resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024)
|
resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024)
|
||||||
assert isinstance(resp, Capsule)
|
assert isinstance(resp, Capsule)
|
||||||
assert resp.id == "sb-1"
|
assert resp.id == "sb-1"
|
||||||
assert resp.status == Status.pending
|
assert resp.status == Status.starting
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_create_defaults(self, client):
|
def test_create_defaults(self, client):
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "sb-2", "status": "pending"}
|
202, json={"id": "sb-2", "status": "starting"}
|
||||||
)
|
)
|
||||||
resp = client.capsules.create()
|
resp = client.capsules.create()
|
||||||
assert resp.id == "sb-2"
|
assert resp.id == "sb-2"
|
||||||
@ -77,25 +77,25 @@ class TestCapsules:
|
|||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_destroy(self, client):
|
def test_destroy(self, client):
|
||||||
route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(204)
|
route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(202)
|
||||||
client.capsules.destroy("sb-1")
|
client.capsules.destroy("sb-1")
|
||||||
assert route.called
|
assert route.called
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_pause(self, client):
|
def test_pause(self, client):
|
||||||
respx.post(f"{BASE}/v1/capsules/sb-1/pause").respond(
|
respx.post(f"{BASE}/v1/capsules/sb-1/pause").respond(
|
||||||
200, json={"id": "sb-1", "status": "paused"}
|
202, json={"id": "sb-1", "status": "pausing"}
|
||||||
)
|
)
|
||||||
resp = client.capsules.pause("sb-1")
|
resp = client.capsules.pause("sb-1")
|
||||||
assert resp.status == Status.paused
|
assert resp.status == Status.pausing
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_resume(self, client):
|
def test_resume(self, client):
|
||||||
respx.post(f"{BASE}/v1/capsules/sb-1/resume").respond(
|
respx.post(f"{BASE}/v1/capsules/sb-1/resume").respond(
|
||||||
200, json={"id": "sb-1", "status": "running"}
|
202, json={"id": "sb-1", "status": "resuming"}
|
||||||
)
|
)
|
||||||
resp = client.capsules.resume("sb-1")
|
resp = client.capsules.resume("sb-1")
|
||||||
assert resp.status == Status.running
|
assert resp.status == Status.resuming
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_ping(self, client):
|
def test_ping(self, client):
|
||||||
@ -238,7 +238,7 @@ class TestAsyncClient:
|
|||||||
async def test_async_capsules_create(self, async_client):
|
async def test_async_capsules_create(self, async_client):
|
||||||
async with async_client:
|
async with async_client:
|
||||||
respx.post(f"{BASE}/v1/capsules").respond(
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
201, json={"id": "sb-1", "status": "pending"}
|
202, json={"id": "sb-1", "status": "starting"}
|
||||||
)
|
)
|
||||||
resp = await async_client.capsules.create(template="base-python")
|
resp = await async_client.capsules.create(template="base-python")
|
||||||
assert resp.id == "sb-1"
|
assert resp.id == "sb-1"
|
||||||
|
|||||||
490
tests/test_commands.py
Normal file
490
tests/test_commands.py
Normal file
@ -0,0 +1,490 @@
|
|||||||
|
"""Unit tests for wrenn.commands — Commands / AsyncCommands.
|
||||||
|
|
||||||
|
Covers payload construction (cwd, envs, tag, timeout), foreground/background
|
||||||
|
dispatch, base64 response decoding, stream-event parsing, and the
|
||||||
|
WebSocket-backed ``stream`` / ``connect`` iterators (with a fake WS).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from contextlib import asynccontextmanager, contextmanager
|
||||||
|
|
||||||
|
import httpx_ws
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from wrenn.client import AsyncWrennClient, WrennClient
|
||||||
|
from wrenn.commands import (
|
||||||
|
AsyncCommands,
|
||||||
|
CommandHandle,
|
||||||
|
CommandResult,
|
||||||
|
Commands,
|
||||||
|
ProcessInfo,
|
||||||
|
StreamErrorEvent,
|
||||||
|
StreamEvent,
|
||||||
|
StreamExitEvent,
|
||||||
|
StreamStartEvent,
|
||||||
|
StreamStderrEvent,
|
||||||
|
StreamStdoutEvent,
|
||||||
|
_decode_exec_response,
|
||||||
|
_parse_stream_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
BASE = "https://app.wrenn.dev/api"
|
||||||
|
CAPSULE_ID = "cl-cmd123"
|
||||||
|
EXEC_URL = f"{BASE}/v1/capsules/{CAPSULE_ID}/exec"
|
||||||
|
PROC_URL = f"{BASE}/v1/capsules/{CAPSULE_ID}/processes"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_commands() -> Commands:
|
||||||
|
client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||||
|
return Commands(CAPSULE_ID, client.http)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_async_commands() -> AsyncCommands:
|
||||||
|
client = AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||||
|
return AsyncCommands(CAPSULE_ID, client.http)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _decode_exec_response ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecodeExecResponse:
|
||||||
|
def test_plain_text(self):
|
||||||
|
result = _decode_exec_response(
|
||||||
|
{"stdout": "hello\n", "stderr": "", "exit_code": 0, "duration_ms": 12}
|
||||||
|
)
|
||||||
|
assert isinstance(result, CommandResult)
|
||||||
|
assert result.stdout == "hello\n"
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.duration_ms == 12
|
||||||
|
|
||||||
|
def test_base64_stdout(self):
|
||||||
|
encoded = base64.b64encode(b"binary\xff\x00out").decode()
|
||||||
|
result = _decode_exec_response(
|
||||||
|
{"stdout": encoded, "encoding": "base64", "exit_code": 0}
|
||||||
|
)
|
||||||
|
assert "binary" in result.stdout
|
||||||
|
|
||||||
|
def test_base64_stderr(self):
|
||||||
|
out = base64.b64encode(b"ok").decode()
|
||||||
|
err = base64.b64encode(b"warning").decode()
|
||||||
|
result = _decode_exec_response(
|
||||||
|
{"stdout": out, "stderr": err, "encoding": "base64", "exit_code": 1}
|
||||||
|
)
|
||||||
|
assert result.stdout == "ok"
|
||||||
|
assert result.stderr == "warning"
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
def test_missing_fields_default(self):
|
||||||
|
result = _decode_exec_response({})
|
||||||
|
assert result.stdout == ""
|
||||||
|
assert result.stderr == ""
|
||||||
|
assert result.exit_code == -1
|
||||||
|
assert result.duration_ms is None
|
||||||
|
|
||||||
|
def test_null_stdout_coerced_to_empty(self):
|
||||||
|
result = _decode_exec_response({"stdout": None, "stderr": None})
|
||||||
|
assert result.stdout == ""
|
||||||
|
assert result.stderr == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── _parse_stream_event ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseStreamEvent:
|
||||||
|
def test_start(self):
|
||||||
|
event = _parse_stream_event({"type": "start", "pid": 99})
|
||||||
|
assert isinstance(event, StreamStartEvent)
|
||||||
|
assert event.type == "start"
|
||||||
|
assert event.pid == 99
|
||||||
|
|
||||||
|
def test_stdout(self):
|
||||||
|
event = _parse_stream_event({"type": "stdout", "data": "out"})
|
||||||
|
assert isinstance(event, StreamStdoutEvent)
|
||||||
|
assert event.data == "out"
|
||||||
|
|
||||||
|
def test_stderr(self):
|
||||||
|
event = _parse_stream_event({"type": "stderr", "data": "err"})
|
||||||
|
assert isinstance(event, StreamStderrEvent)
|
||||||
|
assert event.data == "err"
|
||||||
|
|
||||||
|
def test_exit(self):
|
||||||
|
event = _parse_stream_event({"type": "exit", "exit_code": 7})
|
||||||
|
assert isinstance(event, StreamExitEvent)
|
||||||
|
assert event.exit_code == 7
|
||||||
|
|
||||||
|
def test_error(self):
|
||||||
|
event = _parse_stream_event({"type": "error", "data": "boom"})
|
||||||
|
assert isinstance(event, StreamErrorEvent)
|
||||||
|
assert event.data == "boom"
|
||||||
|
|
||||||
|
def test_unknown_type(self):
|
||||||
|
event = _parse_stream_event({"type": "weird"})
|
||||||
|
assert isinstance(event, StreamEvent)
|
||||||
|
assert event.type == "weird"
|
||||||
|
|
||||||
|
def test_missing_type(self):
|
||||||
|
event = _parse_stream_event({})
|
||||||
|
assert event.type == "unknown"
|
||||||
|
|
||||||
|
def test_exit_missing_code_defaults(self):
|
||||||
|
event = _parse_stream_event({"type": "exit"})
|
||||||
|
assert isinstance(event, StreamExitEvent)
|
||||||
|
assert event.exit_code == -1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Commands.run — payload construction ───────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunPayload:
|
||||||
|
@respx.mock
|
||||||
|
def test_foreground_basic_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"stdout": "hi", "exit_code": 0})
|
||||||
|
result = _make_commands().run("echo hi")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["cmd"] == "/bin/sh"
|
||||||
|
assert body["args"] == ["-c", "echo hi"]
|
||||||
|
assert body["background"] is False
|
||||||
|
assert body["timeout_sec"] == 30
|
||||||
|
assert result.stdout == "hi"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_cwd_in_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("pwd", cwd="/tmp/work")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["cwd"] == "/tmp/work"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_cwd_omitted_when_none(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("pwd")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert "cwd" not in body
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_envs_in_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("env", envs={"FOO": "bar", "BAZ": "qux"})
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["envs"] == {"FOO": "bar", "BAZ": "qux"}
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_empty_envs_still_sent(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("env", envs={})
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["envs"] == {}
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_tag_in_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("echo x", tag="my-tag")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["tag"] == "my-tag"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_custom_timeout_in_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("sleep 1", timeout=120)
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["timeout_sec"] == 120
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_timeout_none_omits_field(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("echo x", timeout=None)
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert "timeout_sec" not in body
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_all_kwargs_combined(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0})
|
||||||
|
_make_commands().run("echo x", timeout=60, envs={"A": "1"}, cwd="/srv", tag="t")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["cwd"] == "/srv"
|
||||||
|
assert body["envs"] == {"A": "1"}
|
||||||
|
assert body["tag"] == "t"
|
||||||
|
assert body["timeout_sec"] == 60
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunBackground:
|
||||||
|
@respx.mock
|
||||||
|
def test_background_returns_handle(self):
|
||||||
|
respx.post(EXEC_URL).respond(200, json={"pid": 1234, "tag": "bg"})
|
||||||
|
handle = _make_commands().run("sleep 100", background=True)
|
||||||
|
assert isinstance(handle, CommandHandle)
|
||||||
|
assert handle.pid == 1234
|
||||||
|
assert handle.tag == "bg"
|
||||||
|
assert handle.capsule_id == CAPSULE_ID
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_background_omits_timeout_sec(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"pid": 1, "tag": "x"})
|
||||||
|
_make_commands().run("sleep 100", background=True, timeout=30)
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert "timeout_sec" not in body
|
||||||
|
assert body["background"] is True
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_background_carries_cwd_and_envs(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"pid": 5, "tag": "t"})
|
||||||
|
_make_commands().run(
|
||||||
|
"server", background=True, cwd="/app", envs={"PORT": "80"}, tag="srv"
|
||||||
|
)
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["cwd"] == "/app"
|
||||||
|
assert body["envs"] == {"PORT": "80"}
|
||||||
|
assert body["tag"] == "srv"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_background_missing_pid_defaults_zero(self):
|
||||||
|
respx.post(EXEC_URL).respond(200, json={"tag": "x"})
|
||||||
|
handle = _make_commands().run("x", background=True)
|
||||||
|
assert handle.pid == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestListAndKill:
|
||||||
|
@respx.mock
|
||||||
|
def test_list_parses_processes(self):
|
||||||
|
respx.get(PROC_URL).respond(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"processes": [
|
||||||
|
{
|
||||||
|
"pid": 10,
|
||||||
|
"tag": "web",
|
||||||
|
"cmd": "/bin/sh",
|
||||||
|
"args": ["-c", "serve"],
|
||||||
|
},
|
||||||
|
{"pid": 11},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
procs = _make_commands().list()
|
||||||
|
assert len(procs) == 2
|
||||||
|
assert isinstance(procs[0], ProcessInfo)
|
||||||
|
assert procs[0].pid == 10
|
||||||
|
assert procs[0].tag == "web"
|
||||||
|
assert procs[0].args == ["-c", "serve"]
|
||||||
|
assert procs[1].pid == 11
|
||||||
|
assert procs[1].tag is None
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_list_empty(self):
|
||||||
|
respx.get(PROC_URL).respond(200, json={"processes": []})
|
||||||
|
assert _make_commands().list() == []
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_list_missing_key(self):
|
||||||
|
respx.get(PROC_URL).respond(200, json={})
|
||||||
|
assert _make_commands().list() == []
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_kill_sends_delete(self):
|
||||||
|
route = respx.delete(f"{PROC_URL}/42").respond(204)
|
||||||
|
_make_commands().kill(42)
|
||||||
|
assert route.called
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_kill_unknown_pid_raises(self):
|
||||||
|
from wrenn.exceptions import WrennNotFoundError
|
||||||
|
|
||||||
|
respx.delete(f"{PROC_URL}/999").respond(
|
||||||
|
404, json={"error": {"code": "not_found", "message": "no such process"}}
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennNotFoundError):
|
||||||
|
_make_commands().kill(999)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fake WebSocket plumbing for stream / connect ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeWS:
|
||||||
|
"""Synchronous fake WebSocket session."""
|
||||||
|
|
||||||
|
def __init__(self, messages: list) -> None:
|
||||||
|
self._messages = list(messages)
|
||||||
|
self.sent: list[str] = []
|
||||||
|
|
||||||
|
def send_text(self, text: str) -> None:
|
||||||
|
self.sent.append(text)
|
||||||
|
|
||||||
|
def receive_json(self) -> dict:
|
||||||
|
if not self._messages:
|
||||||
|
raise httpx_ws.WebSocketDisconnect()
|
||||||
|
msg = self._messages.pop(0)
|
||||||
|
if isinstance(msg, Exception):
|
||||||
|
raise msg
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
class _AsyncFakeWS:
|
||||||
|
"""Asynchronous fake WebSocket session."""
|
||||||
|
|
||||||
|
def __init__(self, messages: list) -> None:
|
||||||
|
self._messages = list(messages)
|
||||||
|
self.sent: list[str] = []
|
||||||
|
|
||||||
|
async def send_text(self, text: str) -> None:
|
||||||
|
self.sent.append(text)
|
||||||
|
|
||||||
|
async def receive_json(self) -> dict:
|
||||||
|
if not self._messages:
|
||||||
|
raise httpx_ws.WebSocketDisconnect()
|
||||||
|
msg = self._messages.pop(0)
|
||||||
|
if isinstance(msg, Exception):
|
||||||
|
raise msg
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_sync_ws(monkeypatch, ws: _FakeWS) -> None:
|
||||||
|
@contextmanager
|
||||||
|
def _fake_connect(url, client):
|
||||||
|
yield ws
|
||||||
|
|
||||||
|
monkeypatch.setattr("wrenn.commands.httpx_ws.connect_ws", _fake_connect)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_async_ws(monkeypatch, ws: _AsyncFakeWS) -> None:
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _fake_aconnect(url, client):
|
||||||
|
yield ws
|
||||||
|
|
||||||
|
monkeypatch.setattr("wrenn.commands.httpx_ws.aconnect_ws", _fake_aconnect)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Commands.stream ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestStream:
|
||||||
|
def test_stream_sends_shell_wrapped_start(self, monkeypatch):
|
||||||
|
ws = _FakeWS([{"type": "exit", "exit_code": 0}])
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
list(_make_commands().stream("echo hi"))
|
||||||
|
start = json.loads(ws.sent[0])
|
||||||
|
assert start == {"type": "start", "cmd": "/bin/sh", "args": ["-c", "echo hi"]}
|
||||||
|
|
||||||
|
def test_stream_with_explicit_args(self, monkeypatch):
|
||||||
|
ws = _FakeWS([{"type": "exit", "exit_code": 0}])
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
list(_make_commands().stream("/usr/bin/env", args=["python", "-V"]))
|
||||||
|
start = json.loads(ws.sent[0])
|
||||||
|
assert start == {
|
||||||
|
"type": "start",
|
||||||
|
"cmd": "/usr/bin/env",
|
||||||
|
"args": ["python", "-V"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_stream_yields_events_until_exit(self, monkeypatch):
|
||||||
|
ws = _FakeWS(
|
||||||
|
[
|
||||||
|
{"type": "start", "pid": 3},
|
||||||
|
{"type": "stdout", "data": "line1"},
|
||||||
|
{"type": "stderr", "data": "warn"},
|
||||||
|
{"type": "exit", "exit_code": 0},
|
||||||
|
{"type": "stdout", "data": "after-exit-ignored"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
events = list(_make_commands().stream("echo line1"))
|
||||||
|
assert [e.type for e in events] == ["start", "stdout", "stderr", "exit"]
|
||||||
|
|
||||||
|
def test_stream_stops_on_error(self, monkeypatch):
|
||||||
|
ws = _FakeWS([{"type": "error", "data": "fatal"}])
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
events = list(_make_commands().stream("bad"))
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].type == "error"
|
||||||
|
|
||||||
|
def test_stream_handles_disconnect(self, monkeypatch):
|
||||||
|
ws = _FakeWS([{"type": "stdout", "data": "x"}]) # then disconnect
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
events = list(_make_commands().stream("echo x"))
|
||||||
|
assert [e.type for e in events] == ["stdout"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Commands.connect ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestConnect:
|
||||||
|
def test_connect_yields_until_exit(self, monkeypatch):
|
||||||
|
ws = _FakeWS(
|
||||||
|
[
|
||||||
|
{"type": "stdout", "data": "tick"},
|
||||||
|
{"type": "exit", "exit_code": 0},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
events = list(_make_commands().connect(55))
|
||||||
|
assert [e.type for e in events] == ["stdout", "exit"]
|
||||||
|
|
||||||
|
def test_connect_handles_disconnect(self, monkeypatch):
|
||||||
|
ws = _FakeWS([]) # immediate disconnect
|
||||||
|
_patch_sync_ws(monkeypatch, ws)
|
||||||
|
assert list(_make_commands().connect(1)) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── AsyncCommands ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncCommands:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_run_payload(self):
|
||||||
|
route = respx.post(EXEC_URL).respond(200, json={"stdout": "hi", "exit_code": 0})
|
||||||
|
cmds = _make_async_commands()
|
||||||
|
result = await cmds.run("echo hi", cwd="/tmp", envs={"K": "v"}, tag="z")
|
||||||
|
body = json.loads(route.calls[0].request.content)
|
||||||
|
assert body["cwd"] == "/tmp"
|
||||||
|
assert body["envs"] == {"K": "v"}
|
||||||
|
assert body["tag"] == "z"
|
||||||
|
assert result.stdout == "hi"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_run_background(self):
|
||||||
|
respx.post(EXEC_URL).respond(200, json={"pid": 7, "tag": "bg"})
|
||||||
|
handle = await _make_async_commands().run("sleep 1", background=True)
|
||||||
|
assert isinstance(handle, CommandHandle)
|
||||||
|
assert handle.pid == 7
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_list(self):
|
||||||
|
respx.get(PROC_URL).respond(200, json={"processes": [{"pid": 1, "tag": "a"}]})
|
||||||
|
procs = await _make_async_commands().list()
|
||||||
|
assert len(procs) == 1
|
||||||
|
assert procs[0].pid == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_kill(self):
|
||||||
|
route = respx.delete(f"{PROC_URL}/3").respond(204)
|
||||||
|
await _make_async_commands().kill(3)
|
||||||
|
assert route.called
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_stream(self, monkeypatch):
|
||||||
|
ws = _AsyncFakeWS(
|
||||||
|
[
|
||||||
|
{"type": "start", "pid": 1},
|
||||||
|
{"type": "stdout", "data": "out"},
|
||||||
|
{"type": "exit", "exit_code": 0},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
_patch_async_ws(monkeypatch, ws)
|
||||||
|
events = [e async for e in _make_async_commands().stream("echo out")]
|
||||||
|
assert [e.type for e in events] == ["start", "stdout", "exit"]
|
||||||
|
start = json.loads(ws.sent[0])
|
||||||
|
assert start["cmd"] == "/bin/sh"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_connect(self, monkeypatch):
|
||||||
|
ws = _AsyncFakeWS([{"type": "exit", "exit_code": 0}])
|
||||||
|
_patch_async_ws(monkeypatch, ws)
|
||||||
|
events = [e async for e in _make_async_commands().connect(9)]
|
||||||
|
assert [e.type for e in events] == ["exit"]
|
||||||
@ -311,12 +311,14 @@ class TestPtySessionIteration:
|
|||||||
ws.receive_text.side_effect = messages
|
ws.receive_text.side_effect = messages
|
||||||
session = PtySession(ws, "cl-abc")
|
session = PtySession(ws, "cl-abc")
|
||||||
events = list(session)
|
events = list(session)
|
||||||
assert len(events) == 2
|
assert len(events) == 3
|
||||||
assert events[0].type == PtyEventType.started
|
assert events[0].type == PtyEventType.started
|
||||||
assert session.tag == "pty-abc12345"
|
assert session.tag == "pty-abc12345"
|
||||||
assert session.pid == 1
|
assert session.pid == 1
|
||||||
assert events[1].type == PtyEventType.output
|
assert events[1].type == PtyEventType.output
|
||||||
assert events[1].data == b"hello"
|
assert events[1].data == b"hello"
|
||||||
|
assert events[2].type == PtyEventType.exit
|
||||||
|
assert events[2].exit_code == 0
|
||||||
|
|
||||||
def test_iter_stops_on_fatal_error(self):
|
def test_iter_stops_on_fatal_error(self):
|
||||||
ws = MagicMock()
|
ws = MagicMock()
|
||||||
@ -339,6 +341,39 @@ class TestPtySessionIteration:
|
|||||||
assert events == []
|
assert events == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestPtySessionPong:
|
||||||
|
def test_ping_triggers_pong(self):
|
||||||
|
ws = MagicMock()
|
||||||
|
ws.receive_text.side_effect = [
|
||||||
|
json.dumps({"type": "ping"}),
|
||||||
|
json.dumps({"type": "exit", "exit_code": 0}),
|
||||||
|
]
|
||||||
|
session = PtySession(ws, "cl-abc")
|
||||||
|
events = list(session)
|
||||||
|
assert events[0].type == PtyEventType.ping
|
||||||
|
sent = [json.loads(c[0][0]) for c in ws.send_text.call_args_list]
|
||||||
|
assert {"type": "pong"} in sent
|
||||||
|
|
||||||
|
def test_no_pong_without_ping(self):
|
||||||
|
ws = MagicMock()
|
||||||
|
ws.receive_text.side_effect = [
|
||||||
|
json.dumps({"type": "output", "data": ""}),
|
||||||
|
json.dumps({"type": "exit", "exit_code": 0}),
|
||||||
|
]
|
||||||
|
session = PtySession(ws, "cl-abc")
|
||||||
|
list(session)
|
||||||
|
sent = [json.loads(c[0][0]) for c in ws.send_text.call_args_list]
|
||||||
|
assert {"type": "pong"} not in sent
|
||||||
|
|
||||||
|
def test_send_pong_swallows_closed_ws(self):
|
||||||
|
import httpx_ws
|
||||||
|
|
||||||
|
ws = MagicMock()
|
||||||
|
ws.send_text.side_effect = httpx_ws.WebSocketNetworkError()
|
||||||
|
session = PtySession(ws, "cl-abc")
|
||||||
|
session._send_pong() # must not raise
|
||||||
|
|
||||||
|
|
||||||
class TestPtySessionContextManager:
|
class TestPtySessionContextManager:
|
||||||
def test_exit_kills_and_closes(self):
|
def test_exit_kills_and_closes(self):
|
||||||
ws = MagicMock()
|
ws = MagicMock()
|
||||||
@ -448,6 +483,28 @@ class TestAsyncPtySession:
|
|||||||
assert sent["cmd"] == "/bin/zsh"
|
assert sent["cmd"] == "/bin/zsh"
|
||||||
assert sent["cols"] == 100
|
assert sent["cols"] == 100
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_ping_triggers_pong(self):
|
||||||
|
ws = AsyncMock()
|
||||||
|
ws.receive_text.side_effect = [
|
||||||
|
json.dumps({"type": "ping"}),
|
||||||
|
json.dumps({"type": "exit", "exit_code": 0}),
|
||||||
|
]
|
||||||
|
session = AsyncPtySession(ws, "cl-abc")
|
||||||
|
events = [e async for e in session]
|
||||||
|
assert events[0].type == PtyEventType.ping
|
||||||
|
sent = [json.loads(c[0][0]) for c in ws.send_text.call_args_list]
|
||||||
|
assert {"type": "pong"} in sent
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_send_pong_swallows_closed_ws(self):
|
||||||
|
import httpx_ws
|
||||||
|
|
||||||
|
ws = AsyncMock()
|
||||||
|
ws.send_text.side_effect = httpx_ws.WebSocketNetworkError()
|
||||||
|
session = AsyncPtySession(ws, "cl-abc")
|
||||||
|
await session._send_pong() # must not raise
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_iteration(self):
|
async def test_async_iteration(self):
|
||||||
ws = AsyncMock()
|
ws = AsyncMock()
|
||||||
@ -461,10 +518,11 @@ class TestAsyncPtySession:
|
|||||||
events = []
|
events = []
|
||||||
async for event in session:
|
async for event in session:
|
||||||
events.append(event)
|
events.append(event)
|
||||||
assert len(events) == 2
|
assert len(events) == 3
|
||||||
assert events[0].type == PtyEventType.started
|
assert events[0].type == PtyEventType.started
|
||||||
assert session.tag == "pty-xyz"
|
assert session.tag == "pty-xyz"
|
||||||
assert session.pid == 5
|
assert session.pid == 5
|
||||||
|
assert events[2].type == PtyEventType.exit
|
||||||
|
|
||||||
|
|
||||||
class TestExports:
|
class TestExports:
|
||||||
|
|||||||
408
tests/test_integration.py
Normal file
408
tests/test_integration.py
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wrenn import Capsule, CommandResult
|
||||||
|
from wrenn.commands import CommandHandle, ProcessInfo
|
||||||
|
from wrenn.models import Capsule as CapsuleModel, FileEntry, Status
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_env_loaded = False
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_env() -> None:
|
||||||
|
global _env_loaded
|
||||||
|
if _env_loaded:
|
||||||
|
return
|
||||||
|
_env_loaded = True
|
||||||
|
env_file = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
if not env_file.exists():
|
||||||
|
return
|
||||||
|
for line in env_file.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key, value = key.strip(), value.strip().strip("\"'")
|
||||||
|
if key and key not in os.environ:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapsuleLifecycle:
|
||||||
|
"""Each test manages its own capsule to test create/destroy paths."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
_ensure_env()
|
||||||
|
|
||||||
|
def test_create_and_destroy(self):
|
||||||
|
capsule = Capsule()
|
||||||
|
capsule_id = capsule.capsule_id
|
||||||
|
try:
|
||||||
|
assert capsule_id
|
||||||
|
assert capsule.info is not None
|
||||||
|
finally:
|
||||||
|
capsule.destroy(wait=True)
|
||||||
|
|
||||||
|
info = Capsule.get_info(capsule_id)
|
||||||
|
assert info.status in (Status.stopped, Status.missing)
|
||||||
|
|
||||||
|
def test_create_with_wait(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
assert capsule.info is not None
|
||||||
|
assert capsule.info.status == Status.running
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_context_manager_destroys(self):
|
||||||
|
with Capsule(wait=True) as capsule:
|
||||||
|
capsule_id = capsule.capsule_id
|
||||||
|
assert capsule.is_running()
|
||||||
|
|
||||||
|
info = Capsule.get_info(capsule_id)
|
||||||
|
assert info.status in (Status.stopping, Status.stopped, Status.missing)
|
||||||
|
|
||||||
|
def test_get_info(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
info = capsule.get_info()
|
||||||
|
assert isinstance(info, CapsuleModel)
|
||||||
|
assert info.id == capsule.capsule_id
|
||||||
|
assert info.status == Status.running
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_pause_and_resume(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
paused = capsule.pause(wait=True)
|
||||||
|
assert paused.status == Status.paused
|
||||||
|
assert not capsule.is_running()
|
||||||
|
|
||||||
|
resumed = capsule.resume(wait=True)
|
||||||
|
assert resumed.status == Status.running
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_static_destroy(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
capsule_id = capsule.capsule_id
|
||||||
|
try:
|
||||||
|
Capsule.destroy(capsule_id, wait=True)
|
||||||
|
except Exception:
|
||||||
|
capsule.destroy()
|
||||||
|
raise
|
||||||
|
|
||||||
|
info = Capsule.get_info(capsule_id)
|
||||||
|
assert info.status in (Status.stopped, Status.missing)
|
||||||
|
|
||||||
|
def test_connect_to_existing(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
connected = Capsule.connect(capsule.capsule_id)
|
||||||
|
assert connected.capsule_id == capsule.capsule_id
|
||||||
|
assert connected.info is not None
|
||||||
|
assert connected.info.status == Status.running
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_connect_resumes_paused(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
capsule.pause()
|
||||||
|
connected = Capsule.connect(capsule.capsule_id)
|
||||||
|
assert connected.info is not None
|
||||||
|
assert connected.info.status == Status.running
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_list_capsules(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
capsules = Capsule.list()
|
||||||
|
assert isinstance(capsules, list)
|
||||||
|
ids = [c.id for c in capsules]
|
||||||
|
assert capsule.capsule_id in ids
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_wait_ready(self):
|
||||||
|
capsule = Capsule()
|
||||||
|
try:
|
||||||
|
capsule.wait_ready(timeout=60)
|
||||||
|
assert capsule.is_running()
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_ping(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
capsule.ping()
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommands:
|
||||||
|
"""Shared capsule for command execution tests."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_run_foreground(self):
|
||||||
|
result = self.capsule.commands.run("echo hello")
|
||||||
|
assert isinstance(result, CommandResult)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "hello" in result.stdout
|
||||||
|
|
||||||
|
def test_run_stderr(self):
|
||||||
|
result = self.capsule.commands.run("echo error >&2")
|
||||||
|
assert "error" in result.stderr
|
||||||
|
|
||||||
|
def test_run_exit_code(self):
|
||||||
|
result = self.capsule.commands.run("exit 42")
|
||||||
|
assert result.exit_code == 42
|
||||||
|
|
||||||
|
def test_run_with_envs(self):
|
||||||
|
result = self.capsule.commands.run("export MY_VAR=test_value && echo $MY_VAR")
|
||||||
|
assert "test_value" in result.stdout
|
||||||
|
|
||||||
|
def test_run_with_cwd(self):
|
||||||
|
result = self.capsule.commands.run("cd /tmp && pwd")
|
||||||
|
assert result.stdout.strip() == "/tmp"
|
||||||
|
|
||||||
|
def test_run_multiline_output(self):
|
||||||
|
result = self.capsule.commands.run("echo -e 'line1\\nline2\\nline3'")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
lines = result.stdout.strip().splitlines()
|
||||||
|
assert len(lines) == 3
|
||||||
|
|
||||||
|
def test_run_background(self):
|
||||||
|
handle = self.capsule.commands.run("sleep 30", background=True, tag="bg-test")
|
||||||
|
assert isinstance(handle, CommandHandle)
|
||||||
|
assert handle.pid > 0
|
||||||
|
assert handle.tag == "bg-test"
|
||||||
|
assert handle.capsule_id == self.capsule.capsule_id
|
||||||
|
|
||||||
|
self.capsule.commands.kill(handle.pid)
|
||||||
|
|
||||||
|
def test_list_processes(self):
|
||||||
|
handle = self.capsule.commands.run("sleep 30", background=True, tag="list-test")
|
||||||
|
try:
|
||||||
|
time.sleep(0.5)
|
||||||
|
processes = self.capsule.commands.list()
|
||||||
|
assert isinstance(processes, list)
|
||||||
|
pids = [p.pid for p in processes]
|
||||||
|
assert handle.pid in pids
|
||||||
|
|
||||||
|
proc = next(p for p in processes if p.pid == handle.pid)
|
||||||
|
assert isinstance(proc, ProcessInfo)
|
||||||
|
finally:
|
||||||
|
self.capsule.commands.kill(handle.pid)
|
||||||
|
|
||||||
|
def test_kill_process(self):
|
||||||
|
handle = self.capsule.commands.run("sleep 30", background=True)
|
||||||
|
self.capsule.commands.kill(handle.pid)
|
||||||
|
# Registry prune runs asynchronously after the process end event,
|
||||||
|
# so poll rather than asserting on a zero-delay list().
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if handle.pid not in [p.pid for p in self.capsule.commands.list()]:
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
assert handle.pid not in [p.pid for p in self.capsule.commands.list()]
|
||||||
|
|
||||||
|
def test_run_duration_ms(self):
|
||||||
|
result = self.capsule.commands.run("sleep 1")
|
||||||
|
assert result.duration_ms is None or result.duration_ms >= 900
|
||||||
|
|
||||||
|
|
||||||
|
class TestFiles:
|
||||||
|
"""Shared capsule for filesystem tests."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_write_and_read(self):
|
||||||
|
self.capsule.files.write("/tmp/test.txt", "hello world")
|
||||||
|
content = self.capsule.files.read("/tmp/test.txt")
|
||||||
|
assert content == "hello world"
|
||||||
|
|
||||||
|
def test_write_and_read_bytes(self):
|
||||||
|
data = b"\x00\x01\x02\xff"
|
||||||
|
self.capsule.files.write("/tmp/test.bin", data)
|
||||||
|
result = self.capsule.files.read_bytes("/tmp/test.bin")
|
||||||
|
assert result == data
|
||||||
|
|
||||||
|
def test_list_directory(self):
|
||||||
|
self.capsule.files.write("/tmp/listdir/a.txt", "a")
|
||||||
|
self.capsule.files.write("/tmp/listdir/b.txt", "b")
|
||||||
|
entries = self.capsule.files.list("/tmp/listdir")
|
||||||
|
assert isinstance(entries, list)
|
||||||
|
names = [e.name for e in entries]
|
||||||
|
assert "a.txt" in names
|
||||||
|
assert "b.txt" in names
|
||||||
|
|
||||||
|
def test_exists(self):
|
||||||
|
self.capsule.files.write("/tmp/exists_test.txt", "x")
|
||||||
|
assert self.capsule.files.exists("/tmp/exists_test.txt")
|
||||||
|
assert not self.capsule.files.exists("/tmp/does_not_exist_xyz.txt")
|
||||||
|
|
||||||
|
def test_make_dir(self):
|
||||||
|
entry = self.capsule.files.make_dir("/tmp/newdir")
|
||||||
|
assert isinstance(entry, FileEntry)
|
||||||
|
assert self.capsule.files.exists("/tmp/newdir")
|
||||||
|
|
||||||
|
def test_make_dir_idempotent(self):
|
||||||
|
self.capsule.files.make_dir("/tmp/idempotent_dir")
|
||||||
|
entry = self.capsule.files.make_dir("/tmp/idempotent_dir")
|
||||||
|
assert isinstance(entry, FileEntry)
|
||||||
|
|
||||||
|
def test_remove_file(self):
|
||||||
|
self.capsule.files.write("/tmp/to_remove.txt", "delete me")
|
||||||
|
assert self.capsule.files.exists("/tmp/to_remove.txt")
|
||||||
|
self.capsule.files.remove("/tmp/to_remove.txt")
|
||||||
|
assert not self.capsule.files.exists("/tmp/to_remove.txt")
|
||||||
|
|
||||||
|
def test_remove_directory(self):
|
||||||
|
self.capsule.files.make_dir("/tmp/dir_to_remove")
|
||||||
|
self.capsule.files.write("/tmp/dir_to_remove/child.txt", "data")
|
||||||
|
self.capsule.files.remove("/tmp/dir_to_remove")
|
||||||
|
assert not self.capsule.files.exists("/tmp/dir_to_remove")
|
||||||
|
|
||||||
|
def test_write_creates_parent_dirs(self):
|
||||||
|
self.capsule.files.write("/tmp/deep/nested/dir/file.txt", "nested")
|
||||||
|
content = self.capsule.files.read("/tmp/deep/nested/dir/file.txt")
|
||||||
|
assert content == "nested"
|
||||||
|
|
||||||
|
def test_list_with_depth(self):
|
||||||
|
self.capsule.files.write("/tmp/depth_test/a/b.txt", "deep")
|
||||||
|
entries_shallow = self.capsule.files.list("/tmp/depth_test", depth=1)
|
||||||
|
entries_deep = self.capsule.files.list("/tmp/depth_test", depth=2)
|
||||||
|
assert len(entries_deep) >= len(entries_shallow)
|
||||||
|
|
||||||
|
def test_overwrite_file(self):
|
||||||
|
self.capsule.files.write("/tmp/overwrite.txt", "original")
|
||||||
|
self.capsule.files.write("/tmp/overwrite.txt", "updated")
|
||||||
|
content = self.capsule.files.read("/tmp/overwrite.txt")
|
||||||
|
assert content == "updated"
|
||||||
|
|
||||||
|
def test_upload_and_download_stream(self):
|
||||||
|
chunks = [b"chunk1", b"chunk2", b"chunk3"]
|
||||||
|
self.capsule.files.upload_stream("/tmp/streamed.bin", iter(chunks))
|
||||||
|
downloaded = b"".join(self.capsule.files.download_stream("/tmp/streamed.bin"))
|
||||||
|
assert downloaded == b"chunk1chunk2chunk3"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGit:
|
||||||
|
"""Shared capsule for git operation tests.
|
||||||
|
|
||||||
|
Initializes a repo at /root (default cwd) since the exec API
|
||||||
|
does not support the cwd parameter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
cls.capsule.git.init(".", initial_branch="main")
|
||||||
|
cls.capsule.git.configure_user("Test User", "test@example.com")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_init_created_repo(self):
|
||||||
|
assert self.capsule.files.exists("/root/.git")
|
||||||
|
|
||||||
|
def test_status_clean(self):
|
||||||
|
status = self.capsule.git.status()
|
||||||
|
assert status.branch == "main"
|
||||||
|
|
||||||
|
def test_add_and_commit(self):
|
||||||
|
self.capsule.files.write("/root/hello.txt", "hello git")
|
||||||
|
self.capsule.git.add(all=True)
|
||||||
|
result = self.capsule.git.commit("initial commit")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_status_after_commit(self):
|
||||||
|
status = self.capsule.git.status()
|
||||||
|
assert status.is_clean
|
||||||
|
|
||||||
|
def test_status_with_changes(self):
|
||||||
|
self.capsule.files.write("/root/dirty.txt", "uncommitted")
|
||||||
|
try:
|
||||||
|
status = self.capsule.git.status()
|
||||||
|
assert not status.is_clean
|
||||||
|
paths = [f.path for f in status.files]
|
||||||
|
assert "dirty.txt" in paths
|
||||||
|
finally:
|
||||||
|
self.capsule.files.remove("/root/dirty.txt")
|
||||||
|
|
||||||
|
def test_branches(self):
|
||||||
|
branches = self.capsule.git.branches()
|
||||||
|
assert len(branches) >= 1
|
||||||
|
names = [b.name for b in branches]
|
||||||
|
assert "main" in names
|
||||||
|
current = [b for b in branches if b.is_current]
|
||||||
|
assert len(current) == 1
|
||||||
|
|
||||||
|
def test_create_and_checkout_branch(self):
|
||||||
|
self.capsule.git.create_branch("feature-1")
|
||||||
|
branches = self.capsule.git.branches()
|
||||||
|
names = [b.name for b in branches]
|
||||||
|
assert "feature-1" in names
|
||||||
|
|
||||||
|
current = [b for b in branches if b.is_current]
|
||||||
|
assert current[0].name == "feature-1"
|
||||||
|
|
||||||
|
self.capsule.git.checkout_branch("main")
|
||||||
|
|
||||||
|
def test_delete_branch(self):
|
||||||
|
self.capsule.git.create_branch("to-delete")
|
||||||
|
self.capsule.git.checkout_branch("main")
|
||||||
|
self.capsule.git.delete_branch("to-delete")
|
||||||
|
|
||||||
|
branches = self.capsule.git.branches()
|
||||||
|
names = [b.name for b in branches]
|
||||||
|
assert "to-delete" not in names
|
||||||
|
|
||||||
|
def test_set_and_get_config(self):
|
||||||
|
self.capsule.git.set_config("test.key", "test-value")
|
||||||
|
value = self.capsule.git.get_config("test.key")
|
||||||
|
assert value == "test-value"
|
||||||
|
|
||||||
|
def test_get_config_missing_returns_none(self):
|
||||||
|
value = self.capsule.git.get_config("nonexistent.key")
|
||||||
|
assert value is None
|
||||||
499
tests/test_integration_advanced.py
Normal file
499
tests/test_integration_advanced.py
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
"""Advanced integration tests against a live Wrenn server.
|
||||||
|
|
||||||
|
Skipped automatically when ``WRENN_API_KEY`` is not set (see conftest.py).
|
||||||
|
|
||||||
|
Covers working-directory / environment handling, long-running commands
|
||||||
|
(``apt-get``), interactive PTY sessions, streaming exec, and real ``git``
|
||||||
|
workflows including cloning ``github.com/wrennhq/wrenn``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wrenn import Capsule
|
||||||
|
from wrenn.commands import StreamExitEvent, StreamStartEvent
|
||||||
|
from wrenn.exceptions import WrennError
|
||||||
|
from wrenn.pty import PtyEventType
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
WRENN_REPO = "https://github.com/wrennhq/wrenn"
|
||||||
|
|
||||||
|
_env_loaded = False
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_env() -> None:
|
||||||
|
global _env_loaded
|
||||||
|
if _env_loaded:
|
||||||
|
return
|
||||||
|
_env_loaded = True
|
||||||
|
env_file = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
if not env_file.exists():
|
||||||
|
return
|
||||||
|
for line in env_file.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key, value = key.strip(), value.strip().strip("\"'")
|
||||||
|
if key and key not in os.environ:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Working directory & environment
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandEnvironment:
|
||||||
|
"""cwd / envs handling for foreground commands."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_cwd_changes_working_directory(self):
|
||||||
|
result = self.capsule.commands.run("pwd", cwd="/tmp")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "/tmp"
|
||||||
|
|
||||||
|
def test_default_cwd_is_home(self):
|
||||||
|
result = self.capsule.commands.run("pwd")
|
||||||
|
assert result.stdout.strip() == "/root"
|
||||||
|
|
||||||
|
def test_cwd_resolves_relative_paths(self):
|
||||||
|
self.capsule.files.make_dir("/tmp/cwd_probe/sub")
|
||||||
|
result = self.capsule.commands.run("ls", cwd="/tmp/cwd_probe")
|
||||||
|
assert "sub" in result.stdout
|
||||||
|
|
||||||
|
def test_cwd_nonexistent_raises(self):
|
||||||
|
with pytest.raises(WrennError):
|
||||||
|
self.capsule.commands.run("pwd", cwd="/no/such/dir/xyz")
|
||||||
|
|
||||||
|
def test_cwd_does_not_persist_between_calls(self):
|
||||||
|
# Each run is a fresh process — `cd` in one does not affect the next.
|
||||||
|
self.capsule.commands.run("cd /tmp")
|
||||||
|
result = self.capsule.commands.run("pwd")
|
||||||
|
assert result.stdout.strip() == "/root"
|
||||||
|
|
||||||
|
def test_single_env_var(self):
|
||||||
|
result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"})
|
||||||
|
assert result.stdout.strip() == "hi"
|
||||||
|
|
||||||
|
def test_multiple_env_vars(self):
|
||||||
|
result = self.capsule.commands.run(
|
||||||
|
"echo $A-$B-$C", envs={"A": "1", "B": "2", "C": "3"}
|
||||||
|
)
|
||||||
|
assert result.stdout.strip() == "1-2-3"
|
||||||
|
|
||||||
|
def test_env_vars_do_not_leak_between_calls(self):
|
||||||
|
self.capsule.commands.run("echo $SECRET", envs={"SECRET": "leaky"})
|
||||||
|
result = self.capsule.commands.run("echo [$SECRET]")
|
||||||
|
assert result.stdout.strip() == "[]"
|
||||||
|
|
||||||
|
def test_env_var_with_special_chars(self):
|
||||||
|
value = "a b&c|d;e"
|
||||||
|
result = self.capsule.commands.run('printf "%s" "$X"', envs={"X": value})
|
||||||
|
assert result.stdout == value
|
||||||
|
|
||||||
|
def test_base_environment_present(self):
|
||||||
|
result = self.capsule.commands.run("echo $HOME; echo $PATH")
|
||||||
|
lines = result.stdout.strip().splitlines()
|
||||||
|
assert lines[0] == "/root"
|
||||||
|
assert "/usr/bin" in lines[1]
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Long-running commands
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestLongRunningCommands:
|
||||||
|
"""apt-get installs and other slow commands."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_apt_get_install(self):
|
||||||
|
result = self.capsule.commands.run(
|
||||||
|
"apt-get update -qq && apt-get install -y -qq cowsay", timeout=300
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_apt_installed_binary_runs(self):
|
||||||
|
# Depends on test_apt_get_install having installed the package.
|
||||||
|
self.capsule.commands.run("apt-get install -y -qq cowsay", timeout=300)
|
||||||
|
result = self.capsule.commands.run("/usr/games/cowsay moo")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "moo" in result.stdout
|
||||||
|
|
||||||
|
def test_foreground_timeout_raises(self):
|
||||||
|
# A command exceeding its timeout surfaces as a server-side error.
|
||||||
|
with pytest.raises(WrennError):
|
||||||
|
self.capsule.commands.run("sleep 20", timeout=2)
|
||||||
|
|
||||||
|
def test_long_sleep_in_background_returns_immediately(self):
|
||||||
|
start = time.monotonic()
|
||||||
|
handle = self.capsule.commands.run(
|
||||||
|
"sleep 60", background=True, tag="long-sleep"
|
||||||
|
)
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
assert elapsed < 10
|
||||||
|
assert handle.pid > 0
|
||||||
|
self.capsule.commands.kill(handle.pid)
|
||||||
|
|
||||||
|
def test_slow_command_within_timeout(self):
|
||||||
|
result = self.capsule.commands.run("sleep 3 && echo done", timeout=30)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "done"
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# PTY sessions
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
def _drain_pty(term, *, max_events: int = 200) -> tuple[bytes, int | None]:
|
||||||
|
"""Collect PTY output until exit; return (output, exit_code)."""
|
||||||
|
output = b""
|
||||||
|
exit_code: int | None = None
|
||||||
|
for i, event in enumerate(term):
|
||||||
|
if event.type == PtyEventType.output and event.data:
|
||||||
|
output += event.data
|
||||||
|
elif event.type == PtyEventType.exit:
|
||||||
|
exit_code = event.exit_code
|
||||||
|
break
|
||||||
|
elif event.type == PtyEventType.error and event.fatal:
|
||||||
|
break
|
||||||
|
if i >= max_events:
|
||||||
|
break
|
||||||
|
return output, exit_code
|
||||||
|
|
||||||
|
|
||||||
|
class TestPty:
|
||||||
|
"""Interactive PTY behaviour."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_pty_runs_command_and_exits(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash") as term:
|
||||||
|
term.write(b"echo pty-result-$((6*7))\n")
|
||||||
|
term.write(b"exit\n")
|
||||||
|
output, exit_code = _drain_pty(term)
|
||||||
|
assert b"pty-result-42" in output
|
||||||
|
assert exit_code is not None
|
||||||
|
|
||||||
|
def test_pty_started_event_sets_tag_and_pid(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash") as term:
|
||||||
|
term.write(b"exit\n")
|
||||||
|
_drain_pty(term)
|
||||||
|
assert term.tag is not None
|
||||||
|
assert term.tag.startswith("pty-")
|
||||||
|
assert term.pid is not None and term.pid > 0
|
||||||
|
|
||||||
|
def test_pty_respects_cwd(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash", cwd="/tmp") as term:
|
||||||
|
term.write(b"pwd\n")
|
||||||
|
term.write(b"exit\n")
|
||||||
|
output, _ = _drain_pty(term)
|
||||||
|
assert b"/tmp" in output
|
||||||
|
|
||||||
|
def test_pty_respects_envs(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash", envs={"PTY_VAR": "xyzzy"}) as term:
|
||||||
|
term.write(b"echo marker-$PTY_VAR\n")
|
||||||
|
term.write(b"exit\n")
|
||||||
|
output, _ = _drain_pty(term)
|
||||||
|
assert b"marker-xyzzy" in output
|
||||||
|
|
||||||
|
def test_pty_resize(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash", cols=80, rows=24) as term:
|
||||||
|
term.resize(120, 40)
|
||||||
|
term.write(b"echo resized\n")
|
||||||
|
term.write(b"exit\n")
|
||||||
|
output, _ = _drain_pty(term)
|
||||||
|
assert b"resized" in output
|
||||||
|
|
||||||
|
def test_pty_explicit_command(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/echo", args=["hello-from-argv"]) as term:
|
||||||
|
output, exit_code = _drain_pty(term)
|
||||||
|
assert b"hello-from-argv" in output
|
||||||
|
|
||||||
|
def test_pty_exit_code_nonzero(self):
|
||||||
|
with self.capsule.pty(cmd="/bin/bash") as term:
|
||||||
|
term.write(b"exit 3\n")
|
||||||
|
_, exit_code = _drain_pty(term)
|
||||||
|
assert exit_code == 3
|
||||||
|
|
||||||
|
def test_pty_survives_idle_ping_cycle(self):
|
||||||
|
# The server emits a keepalive `ping` (~every 30s); the SDK must
|
||||||
|
# auto-reply `pong` and the session must stay usable afterwards.
|
||||||
|
with self.capsule.pty(cmd="/bin/bash") as term:
|
||||||
|
saw_ping = False
|
||||||
|
for event in term:
|
||||||
|
if event.type == PtyEventType.ping:
|
||||||
|
saw_ping = True
|
||||||
|
break
|
||||||
|
if event.type == PtyEventType.exit:
|
||||||
|
break
|
||||||
|
if event.type == PtyEventType.error and event.fatal:
|
||||||
|
break
|
||||||
|
assert saw_ping, "no keepalive ping received"
|
||||||
|
term.write(b"echo still-alive\n")
|
||||||
|
term.write(b"exit\n")
|
||||||
|
output, _ = _drain_pty(term)
|
||||||
|
assert b"still-alive" in output
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Streaming exec
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamingExec:
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_stream_emits_start_and_exit(self):
|
||||||
|
events = list(self.capsule.commands.stream("echo streamed"))
|
||||||
|
types = [e.type for e in events]
|
||||||
|
assert "exit" in types
|
||||||
|
starts = [e for e in events if isinstance(e, StreamStartEvent)]
|
||||||
|
exits = [e for e in events if isinstance(e, StreamExitEvent)]
|
||||||
|
assert exits and exits[0].exit_code == 0
|
||||||
|
if starts:
|
||||||
|
assert starts[0].pid > 0
|
||||||
|
|
||||||
|
def test_stream_captures_stdout(self):
|
||||||
|
events = list(self.capsule.commands.stream("for i in 1 2 3; do echo n$i; done"))
|
||||||
|
out = "".join(
|
||||||
|
e.data for e in events if e.type == "stdout" and getattr(e, "data", None)
|
||||||
|
)
|
||||||
|
assert "n1" in out and "n3" in out
|
||||||
|
|
||||||
|
def test_stream_nonzero_exit(self):
|
||||||
|
events = list(self.capsule.commands.stream("exit 5"))
|
||||||
|
exits = [e for e in events if isinstance(e, StreamExitEvent)]
|
||||||
|
assert exits and exits[0].exit_code == 5
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Process connect — attach to a background process over WebSocket
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestProcessConnect:
|
||||||
|
"""commands.connect — must survive the server's abrupt WebSocket close."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_connect_streams_running_process(self):
|
||||||
|
handle = self.capsule.commands.run(
|
||||||
|
"for i in $(seq 1 5); do echo tick$i; sleep 1; done",
|
||||||
|
background=True,
|
||||||
|
tag="connect-run",
|
||||||
|
)
|
||||||
|
time.sleep(0.3)
|
||||||
|
events = list(self.capsule.commands.connect(handle.pid))
|
||||||
|
types = [e.type for e in events]
|
||||||
|
assert "exit" in types
|
||||||
|
# connect streams output from the attach point onward, so early
|
||||||
|
# ticks may be missed — assert it captured the live tail.
|
||||||
|
out = "".join(
|
||||||
|
e.data for e in events if e.type == "stdout" and getattr(e, "data", None)
|
||||||
|
)
|
||||||
|
assert "tick" in out
|
||||||
|
|
||||||
|
def test_connect_to_finished_process_does_not_raise(self):
|
||||||
|
handle = self.capsule.commands.run("echo quick", background=True)
|
||||||
|
time.sleep(2)
|
||||||
|
# Process already exited — server closes the WebSocket abruptly;
|
||||||
|
# the iterator must terminate cleanly rather than raise.
|
||||||
|
events = list(self.capsule.commands.connect(handle.pid))
|
||||||
|
assert isinstance(events, list)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
# Git — real workflows including cloning wrennhq/wrenn
|
||||||
|
# ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitClone:
|
||||||
|
"""Clone github.com/wrennhq/wrenn and operate on it."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
cls.capsule.git.clone(WRENN_REPO, "/root/wrenn", depth=1, timeout=300)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_clone_created_repo(self):
|
||||||
|
assert self.capsule.files.exists("/root/wrenn/.git")
|
||||||
|
|
||||||
|
def test_clone_checked_out_files(self):
|
||||||
|
entries = self.capsule.files.list("/root/wrenn")
|
||||||
|
names = [e.name for e in entries]
|
||||||
|
assert "README.md" in names
|
||||||
|
|
||||||
|
def test_status_of_clone_is_clean(self):
|
||||||
|
status = self.capsule.git.status(cwd="/root/wrenn")
|
||||||
|
assert status.branch == "main"
|
||||||
|
assert status.is_clean
|
||||||
|
|
||||||
|
def test_branches_lists_main(self):
|
||||||
|
branches = self.capsule.git.branches(cwd="/root/wrenn")
|
||||||
|
names = [b.name for b in branches]
|
||||||
|
assert "main" in names
|
||||||
|
assert any(b.is_current for b in branches)
|
||||||
|
|
||||||
|
def test_remote_get_origin(self):
|
||||||
|
url = self.capsule.git.remote_get("origin", cwd="/root/wrenn")
|
||||||
|
assert url is not None
|
||||||
|
assert "wrennhq/wrenn" in url
|
||||||
|
|
||||||
|
def test_git_log_has_commit(self):
|
||||||
|
result = self.capsule.commands.run("git log --oneline -1", cwd="/root/wrenn")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip()
|
||||||
|
|
||||||
|
def test_modify_add_commit(self):
|
||||||
|
marker = uuid.uuid4().hex
|
||||||
|
self.capsule.git.configure_user(
|
||||||
|
"CI Bot", "ci@example.com", cwd="/root/wrenn", scope="local"
|
||||||
|
)
|
||||||
|
self.capsule.files.write(f"/root/wrenn/sdk_probe_{marker}.txt", marker)
|
||||||
|
self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/root/wrenn")
|
||||||
|
|
||||||
|
staged = self.capsule.git.status(cwd="/root/wrenn")
|
||||||
|
assert staged.has_staged
|
||||||
|
|
||||||
|
result = self.capsule.git.commit("probe commit", cwd="/root/wrenn")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
after = self.capsule.git.status(cwd="/root/wrenn")
|
||||||
|
assert after.is_clean
|
||||||
|
assert after.ahead >= 1
|
||||||
|
|
||||||
|
def test_create_and_checkout_branch_in_clone(self):
|
||||||
|
self.capsule.git.create_branch("sdk-feature", cwd="/root/wrenn")
|
||||||
|
branches = self.capsule.git.branches(cwd="/root/wrenn")
|
||||||
|
current = [b for b in branches if b.is_current]
|
||||||
|
assert current and current[0].name == "sdk-feature"
|
||||||
|
self.capsule.git.checkout_branch("main", cwd="/root/wrenn")
|
||||||
|
|
||||||
|
def test_diff_via_commands(self):
|
||||||
|
self.capsule.files.write("/root/wrenn/README.md", "overwritten\n")
|
||||||
|
try:
|
||||||
|
result = self.capsule.commands.run("git diff --stat", cwd="/root/wrenn")
|
||||||
|
assert "README.md" in result.stdout
|
||||||
|
finally:
|
||||||
|
self.capsule.git.restore(["README.md"], worktree=True, cwd="/root/wrenn")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitErrors:
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
_ensure_env()
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_clone_nonexistent_repo_raises(self):
|
||||||
|
from wrenn._git import GitError
|
||||||
|
|
||||||
|
with pytest.raises(GitError):
|
||||||
|
self.capsule.git.clone(
|
||||||
|
"https://github.com/wrennhq/this-repo-does-not-exist-xyz",
|
||||||
|
"/root/missing",
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_status_outside_repo_raises(self):
|
||||||
|
from wrenn._git import GitError
|
||||||
|
|
||||||
|
with pytest.raises(GitError):
|
||||||
|
self.capsule.git.status(cwd="/tmp")
|
||||||
|
|
||||||
|
def test_clone_with_branch(self):
|
||||||
|
self.capsule.git.clone(
|
||||||
|
WRENN_REPO, "/root/wrenn-main", branch="main", depth=1, timeout=300
|
||||||
|
)
|
||||||
|
status = self.capsule.git.status(cwd="/root/wrenn-main")
|
||||||
|
assert status.branch == "main"
|
||||||
93
uv.lock
generated
93
uv.lock
generated
@ -72,6 +72,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfgv"
|
||||||
|
version = "3.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "charset-normalizer"
|
name = "charset-normalizer"
|
||||||
version = "3.4.7"
|
version = "3.4.7"
|
||||||
@ -226,6 +235,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "distlib"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dnspython"
|
name = "dnspython"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
@ -282,6 +300,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filelock"
|
||||||
|
version = "3.29.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "genson"
|
name = "genson"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@ -343,6 +370,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/98/f8/a6bc80313a9e93c888fa10534dfce2ad76ff86911b6f485777ce6de6a073/httpx_ws-0.9.0-py3-none-any.whl", hash = "sha256:71640d2fb1bf9a225775015b33cd755cfd4c5f7e21c885192fe3adc4c387b248", size = 15759, upload-time = "2026-03-28T14:11:11.887Z" },
|
{ url = "https://files.pythonhosted.org/packages/98/f8/a6bc80313a9e93c888fa10534dfce2ad76ff86911b6f485777ce6de6a073/httpx_ws-0.9.0-py3-none-any.whl", hash = "sha256:71640d2fb1bf9a225775015b33cd755cfd4c5f7e21c885192fe3adc4c387b248", size = 15759, upload-time = "2026-03-28T14:11:11.887Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "identify"
|
||||||
|
version = "2.6.19"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.11"
|
version = "3.11"
|
||||||
@ -548,6 +584,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nodeenv"
|
||||||
|
version = "1.10.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nr-date"
|
name = "nr-date"
|
||||||
version = "2.1.0"
|
version = "2.1.0"
|
||||||
@ -615,6 +660,22 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pre-commit"
|
||||||
|
version = "4.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cfgv" },
|
||||||
|
{ name = "identify" },
|
||||||
|
{ name = "nodeenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "virtualenv" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.12.5"
|
version = "2.12.5"
|
||||||
@ -745,6 +806,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-discovery"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "filelock" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytokens"
|
name = "pytokens"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@ -956,6 +1030,21 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "virtualenv"
|
||||||
|
version = "21.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "distlib" },
|
||||||
|
{ name = "filelock" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
{ name = "python-discovery" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3f/8b/6331f7a7fe70131c301106ec1e7cf23e2501bf7d4ca3636805801ca191bb/virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e", size = 7614069, upload-time = "2026-04-27T17:05:58.927Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/eb/03bfb1299d4c4510329e470f13f9a4ce793df7fcb5a2fd3510f911066f61/virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", size = 7594690, upload-time = "2026-04-27T17:05:55.468Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "watchdog"
|
name = "watchdog"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@ -1032,7 +1121,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wrenn"
|
name = "wrenn"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "email-validator" },
|
{ name = "email-validator" },
|
||||||
@ -1045,6 +1134,7 @@ dependencies = [
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "datamodel-code-generator", extra = ["ruff"] },
|
{ name = "datamodel-code-generator", extra = ["ruff"] },
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
|
{ name = "pre-commit" },
|
||||||
{ name = "pydoc-markdown" },
|
{ name = "pydoc-markdown" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
@ -1064,6 +1154,7 @@ requires-dist = [
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" },
|
{ name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" },
|
||||||
{ name = "mypy", specifier = ">=1.20.0" },
|
{ name = "mypy", specifier = ">=1.20.0" },
|
||||||
|
{ name = "pre-commit", specifier = ">=4.6.0" },
|
||||||
{ name = "pydoc-markdown", specifier = ">=4.8.2" },
|
{ name = "pydoc-markdown", specifier = ">=4.8.2" },
|
||||||
{ name = "pytest", specifier = ">=9.0.3" },
|
{ name = "pytest", specifier = ">=9.0.3" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user