Compare commits
41 Commits
feat/clien
...
369c75af24
| Author | SHA1 | Date | |
|---|---|---|---|
| 369c75af24 | |||
| 41ee41e9cd | |||
| fce514c49c | |||
| 87cc16e9e2 | |||
| 08f6a1ab84 | |||
| 51c6987515 | |||
| 800a8566db | |||
| e057ec2407 | |||
| e5e4e1a85b | |||
| 6112c71abc | |||
| a42f0b2e71 | |||
| d9c028564e | |||
| 06b4a8cbcb | |||
| 04e5dc652f | |||
| 4a7db8e204 | |||
| a76be96682 | |||
| be573d07a3 | |||
| dc66ac24d5 | |||
| b5e2b12ef1 | |||
| 213af4aee7 | |||
| aa9477ffe8 | |||
| 2bb3dbd71d | |||
| d46a715839 | |||
| 3f26a2fbcf | |||
| 2faf0dd0ae | |||
| 68c7d0de42 | |||
| ad64c85393 | |||
| bab53aedbe | |||
| 82e181dd7e | |||
| ee1f55635f | |||
| 6bdf28e2ae | |||
| 61bc040098 | |||
| 7b35ffb60c | |||
| 42bcc792d6 | |||
| 3f97c73b2f | |||
| 7e7ecbd48a | |||
| 7b9a06d1b5 | |||
| 3d0eda5c60 | |||
| eecf1dc65b | |||
| 3cced768a4 | |||
| 2ad8cdebc1 |
24
.github/workflows/release.yml
vendored
Normal file
24
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
name: Publish to PyPI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
pypi-publish:
|
||||
name: Upload release to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
|
||||
- name: Build package
|
||||
run: uv build
|
||||
|
||||
- name: Publish package distributions to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -175,3 +175,10 @@ cython_debug/
|
||||
.pypirc
|
||||
|
||||
CODE_EXECUTION.md
|
||||
|
||||
.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,46 +1,28 @@
|
||||
when:
|
||||
event: push
|
||||
branch:
|
||||
- main
|
||||
- dev
|
||||
|
||||
variables:
|
||||
- &python_image "ghcr.io/astral-sh/uv:python3.13-bookworm-slim"
|
||||
- &uv_cache_dir "/root/.cache/uv"
|
||||
|
||||
steps:
|
||||
- name: restore-cache
|
||||
image: woodpeckerci/plugin-cache
|
||||
settings:
|
||||
restore: true
|
||||
cache_key: "uv-{{ checksum \"uv.lock\" }}"
|
||||
mount:
|
||||
- /root/.cache/uv
|
||||
|
||||
- name: lint
|
||||
image: *python_image
|
||||
environment:
|
||||
UV_CACHE_DIR: *uv_cache_dir
|
||||
UV_FROZEN: 1
|
||||
commands:
|
||||
- uv sync --no-install-project
|
||||
- make lint
|
||||
|
||||
- name: test
|
||||
image: *python_image
|
||||
environment:
|
||||
UV_CACHE_DIR: *uv_cache_dir
|
||||
UV_FROZEN: 1
|
||||
commands:
|
||||
- uv sync --no-install-project
|
||||
- make test
|
||||
|
||||
- name: rebuild-cache
|
||||
image: woodpeckerci/plugin-cache
|
||||
unit-tests:
|
||||
image: ghcr.io/astral-sh/uv:python3.13-bookworm
|
||||
when:
|
||||
- status: [success]
|
||||
settings:
|
||||
rebuild: true
|
||||
cache_key: "uv-{{ checksum \"uv.lock\" }}"
|
||||
mount:
|
||||
- /root/.cache/uv
|
||||
event: push
|
||||
path:
|
||||
- "src/**"
|
||||
- "tests/**"
|
||||
commands:
|
||||
- uv sync --dev
|
||||
- uv run pytest -m "not integration" -v
|
||||
|
||||
integration-tests:
|
||||
image: ghcr.io/astral-sh/uv:python3.13-bookworm
|
||||
when:
|
||||
event: pull_request
|
||||
branch:
|
||||
- main
|
||||
- dev
|
||||
path:
|
||||
- "src/**"
|
||||
- "tests/**"
|
||||
environment:
|
||||
WRENN_API_KEY:
|
||||
from_secret: WRENN_API_KEY
|
||||
commands:
|
||||
- uv sync --dev
|
||||
- uv run pytest -m integration -v
|
||||
|
||||
80
AGENTS.md
80
AGENTS.md
@ -1,80 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
## What this repo is
|
||||
|
||||
Python SDK for **Wrenn** (microVM code execution platform). Communicates with the Control Plane via REST + WebSockets only — no gRPC. The `envd` and `HostAgentService` are internal to the Go backend and never reachable from this SDK.
|
||||
|
||||
## Build & dev commands
|
||||
|
||||
All commands go through `uv` and the `Makefile`. Never use raw `pip`, `venv`, or `python -m venv`.
|
||||
|
||||
```bash
|
||||
make generate # Fetch openapi.yaml → src/wrenn/models/_generated.py
|
||||
make lint # ruff check + ruff format --check on src/
|
||||
make test # runs ONLY tests/test_client.py
|
||||
make test-integration # runs ALL tests (unit + integration, needs live server)
|
||||
make check # lint + test (test_client.py only)
|
||||
```
|
||||
|
||||
To run all unit tests (not just test_client.py):
|
||||
|
||||
```bash
|
||||
uv run pytest tests/test_client.py tests/test_sandbox_features.py tests/test_filesystem_pty.py -v
|
||||
```
|
||||
|
||||
To run a single test:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/test_client.py::TestAuth::test_signup -v
|
||||
```
|
||||
|
||||
## Code generation (CRITICAL)
|
||||
|
||||
Models in `src/wrenn/models/_generated.py` are generated by `datamodel-codegen` from `api/openapi.yaml`.
|
||||
|
||||
1. **Never edit `_generated.py`** — overwritten on next `make generate`.
|
||||
2. All user-facing models must be re-exported in `src/wrenn/models/__init__.py` via `__all__`.
|
||||
3. To extend a generated model with custom methods, subclass it (e.g. `Sandbox` in `sandbox.py` subclasses the generated `SandboxModel`).
|
||||
|
||||
## Dependency management
|
||||
|
||||
```bash
|
||||
uv add <package> # runtime dep
|
||||
uv add --dev <package> # dev dep
|
||||
uv run <command> # run in managed .venv
|
||||
```
|
||||
|
||||
## Implemented resource namespaces
|
||||
|
||||
Only these are currently implemented in `client.py`:
|
||||
|
||||
- **`client.auth`** — `signup`, `login`
|
||||
- **`client.api_keys`** — `create`, `list`, `delete`
|
||||
- **`client.sandboxes`** — `create`, `list`, `get`, `destroy`
|
||||
- **`client.snapshots`** — `create`, `list`, `delete`
|
||||
- **`client.hosts`** — `create`, `list`, `get`, `delete`, `regenerate_token`, `list_tags`, `add_tag`, `remove_tag`
|
||||
|
||||
Both sync and async variants exist for every resource.
|
||||
|
||||
## Architecture notes
|
||||
|
||||
- **Sync/async parity**: `WrennClient` + `AsyncWrennClient` in `client.py`, using `httpx.Client`/`httpx.AsyncClient`. Async methods on `Sandbox` are prefixed `async_` (e.g. `async_exec`, `async_upload`).
|
||||
- **WebSocket library**: `httpx-ws` (not `websockets`). Used for `exec_stream`, `pty`, and `run_code`.
|
||||
- **Sandbox proxy URL**: `get_url(port)` returns `ws://` or `wss://` scheme. The `http_client` property converts to `http://`/`https://` automatically.
|
||||
- **`Sandbox`** (in `sandbox.py`) is the main developer-facing class — subclasses generated model, adds lifecycle methods (`exec`, `upload`, `download`, `list_dir`, `mkdir`, `remove`, `pty`, `run_code`, `wait_ready`, `pause`, `resume`, `destroy`, `ping`, `metrics`), context manager support, and proxy helpers.
|
||||
- **Error handling**: `handle_response()` in `exceptions.py` maps server error `code` field to typed exceptions (not just HTTP status). All inherit from `WrennError` with `.code`, `.message`, `.status_code`.
|
||||
|
||||
## Testing
|
||||
|
||||
- **HTTP mocking**: `respx` library (not `responses` or `pytest-httpx`). Mock routes with `@respx.mock` decorator or `respx.mock` context manager.
|
||||
- **Async tests**: use `@pytest.mark.asyncio` (backed by `pytest-asyncio`).
|
||||
- **Integration tests**: in `test_integration.py`, require env vars `WRENN_API_KEY` or `WRENN_TOKEN` (plus optional `WRENN_BASE_URL`, `WRENN_TEST_EMAIL`, `WRENN_TEST_PASSWORD`). They are skipped via `@requires_auth` if credentials are absent.
|
||||
- **Fixtures**: test fixtures create `WrennClient(api_key="wrn_test1234567890abcdef12345678")` with context manager cleanup.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
- **Python 3.13+** with modern syntax (`|` unions, `list[str]` generics).
|
||||
- **Strict typing** throughout. `pyright`/`mypy` available but not in CI.
|
||||
- **`ruff`** is the sole linter and formatter. Do not use `black`, `isort`, or `flake8`.
|
||||
- **Google-style docstrings** on all public APIs.
|
||||
- **No comments** unless explicitly asked.
|
||||
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.
|
||||
10
Makefile
10
Makefile
@ -2,7 +2,7 @@
|
||||
.PHONY: generate lint test check test-integration
|
||||
|
||||
# Variables
|
||||
SPEC_URL = "https://git.omukk.dev/wrenn/wrenn/raw/branch/dev/internal/api/openapi.yaml"
|
||||
SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml"
|
||||
SPEC_PATH = "api/openapi.yaml"
|
||||
|
||||
generate:
|
||||
@ -21,7 +21,9 @@ generate:
|
||||
--use-schema-description \
|
||||
--target-python-version 3.13 \
|
||||
--use-annotated \
|
||||
--openapi-scopes schemas
|
||||
--openapi-scopes schemas \
|
||||
--formatters ruff-format ruff-check \
|
||||
--input-file-type openapi
|
||||
|
||||
lint:
|
||||
uv run ruff check src/
|
||||
@ -34,3 +36,7 @@ test-integration:
|
||||
uv run pytest tests/ -v -m "integration or not integration"
|
||||
|
||||
check: lint test
|
||||
|
||||
gen-docs:
|
||||
mkdir -p docs
|
||||
uv run pydoc-markdown > docs/reference.md
|
||||
|
||||
635
README.md
635
README.md
@ -1,6 +1,8 @@
|
||||
# Wrenn Python SDK
|
||||
|
||||
Python client for the [Wrenn](https://wrenn.dev) microVM code execution platform. Create isolated capsules, execute commands, manage files, run interactive terminals, and execute persistent code — all from Python.
|
||||
Python client for the [Wrenn](https://wrenn.dev) microVM platform. Create isolated capsules, execute commands, manage files, run interactive terminals, and execute persistent code -- all from Python.
|
||||
|
||||
Designed as a drop-in replacement for [e2b](https://e2b.dev). If you're migrating, just swap your imports.
|
||||
|
||||
## Installation
|
||||
|
||||
@ -10,97 +12,144 @@ pip install wrenn
|
||||
|
||||
Requires Python 3.13+.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from wrenn import WrennClient
|
||||
|
||||
client = WrennClient(api_key="wrn_your_api_key_here")
|
||||
|
||||
# Create a capsule and run a command
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60)
|
||||
|
||||
result = cap.exec("echo", args=["hello world"])
|
||||
print(result.stdout) # "hello world"
|
||||
print(result.exit_code) # 0
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
The SDK supports two authentication methods:
|
||||
Set the `WRENN_API_KEY` environment variable:
|
||||
|
||||
```python
|
||||
# API key
|
||||
client = WrennClient(api_key="wrn_...")
|
||||
|
||||
# JWT token
|
||||
client = WrennClient(token="eyJ...")
|
||||
```bash
|
||||
export WRENN_API_KEY="wrn_your_api_key_here"
|
||||
```
|
||||
|
||||
You can obtain an API key via the dashboard or create one programmatically:
|
||||
Optionally override the API base URL:
|
||||
|
||||
```python
|
||||
with WrennClient(token="jwt_token") as client:
|
||||
key = client.api_keys.create(name="my-key")
|
||||
print(key.key) # wrn_...
|
||||
```bash
|
||||
export WRENN_BASE_URL="https://app.wrenn.dev/api" # default
|
||||
```
|
||||
|
||||
## Capsules
|
||||
|
||||
Capsules are isolated microVM environments. Create, manage, and interact with them:
|
||||
You can also pass credentials directly:
|
||||
|
||||
```python
|
||||
# Create
|
||||
cap = client.capsules.create(
|
||||
template="base-python",
|
||||
vcpus=2,
|
||||
memory_mb=1024,
|
||||
timeout_sec=300,
|
||||
)
|
||||
from wrenn import Capsule
|
||||
|
||||
# List
|
||||
for c in client.capsules.list():
|
||||
print(c.id, c.status)
|
||||
capsule = Capsule(api_key="wrn_...", base_url="https://...")
|
||||
```
|
||||
|
||||
# Get
|
||||
cap = client.capsules.get("cl-abc123")
|
||||
---
|
||||
|
||||
# Destroy
|
||||
client.capsules.destroy("cl-abc123")
|
||||
## Wrenn Capsules
|
||||
|
||||
### Quick Start
|
||||
|
||||
```python
|
||||
from wrenn import Capsule
|
||||
|
||||
# Create a capsule (reads WRENN_API_KEY from env)
|
||||
with Capsule(template="minimal") as capsule:
|
||||
result = capsule.commands.run("echo hello")
|
||||
print(result.stdout) # "hello\n"
|
||||
```
|
||||
|
||||
### Creating Capsules
|
||||
|
||||
```python
|
||||
from wrenn import Capsule
|
||||
|
||||
# Direct construction (creates immediately)
|
||||
capsule = Capsule()
|
||||
capsule = Capsule(template="base-python", vcpus=2, memory_mb=1024, timeout=300)
|
||||
|
||||
# With auto-wait (blocks until capsule is running)
|
||||
capsule = Capsule(template="minimal", wait=True)
|
||||
|
||||
# Via factory classmethod
|
||||
capsule = Capsule.create(template="minimal", wait=True)
|
||||
```
|
||||
|
||||
### Context Manager
|
||||
|
||||
Use capsules as context managers for automatic cleanup:
|
||||
Use capsules as context managers for automatic cleanup (destroys capsule on exit):
|
||||
|
||||
```python
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60)
|
||||
cap.exec("python -c 'print(42)'")
|
||||
# cap.destroy() is called automatically
|
||||
with Capsule(template="minimal", wait=True) as capsule:
|
||||
capsule.commands.run("echo hello")
|
||||
# capsule is automatically destroyed
|
||||
```
|
||||
|
||||
## Command Execution
|
||||
### Connecting to Existing Capsules
|
||||
|
||||
### `exec()` — One-off Commands
|
||||
|
||||
Starts a fresh process for each call. No state persists between calls.
|
||||
Attach to a running capsule by ID. If it's paused, it will be resumed automatically:
|
||||
|
||||
```python
|
||||
result = cap.exec("python", args=["-c", "import os; print(os.getcwd())"])
|
||||
print(result.stdout) # "/home/user\n"
|
||||
print(result.stderr) # ""
|
||||
print(result.exit_code) # 0
|
||||
print(result.duration_ms) # 42
|
||||
capsule = Capsule.connect("cl-abc123")
|
||||
result = capsule.commands.run("echo still running")
|
||||
```
|
||||
|
||||
### `exec_stream()` — Streaming Output
|
||||
|
||||
Stream real-time output from long-running commands:
|
||||
For code interpreter capsules:
|
||||
|
||||
```python
|
||||
for event in cap.exec_stream("python", args=["-u", "train.py"]):
|
||||
from wrenn.code_interpreter import Capsule as CodeCapsule
|
||||
|
||||
capsule = CodeCapsule.connect("cl-abc123")
|
||||
result = capsule.run_code("print('reconnected')")
|
||||
```
|
||||
|
||||
### Lifecycle Management
|
||||
|
||||
```python
|
||||
# Instance methods
|
||||
capsule.pause()
|
||||
capsule.resume()
|
||||
capsule.destroy()
|
||||
capsule.ping() # reset inactivity timer
|
||||
capsule.wait_ready() # block until running
|
||||
|
||||
info = capsule.get_info()
|
||||
print(info.status) # "running"
|
||||
print(capsule.is_running()) # True
|
||||
|
||||
# Static methods (no instance needed)
|
||||
Capsule.destroy("cl-abc123", api_key="wrn_...")
|
||||
Capsule.pause("cl-abc123")
|
||||
Capsule.resume("cl-abc123")
|
||||
info = Capsule.get_info("cl-abc123")
|
||||
|
||||
# List all capsules
|
||||
capsules = Capsule.list()
|
||||
```
|
||||
|
||||
### Command Execution
|
||||
|
||||
Commands are accessed via `capsule.commands`:
|
||||
|
||||
```python
|
||||
# Foreground (blocks until complete)
|
||||
result = capsule.commands.run("python -c 'print(42)'")
|
||||
print(result.stdout) # "42\n"
|
||||
print(result.stderr) # ""
|
||||
print(result.exit_code) # 0
|
||||
print(result.duration_ms) # 35
|
||||
|
||||
# With options
|
||||
result = capsule.commands.run(
|
||||
"python train.py",
|
||||
timeout=120,
|
||||
envs={"CUDA_VISIBLE_DEVICES": "0"},
|
||||
cwd="/app",
|
||||
)
|
||||
|
||||
# Background process
|
||||
handle = capsule.commands.run("python server.py", background=True)
|
||||
print(handle.pid) # 1234
|
||||
print(handle.tag) # "exec-abc123"
|
||||
```
|
||||
|
||||
#### Streaming Output
|
||||
|
||||
```python
|
||||
import sys
|
||||
|
||||
# Stream a new command
|
||||
for event in capsule.commands.stream("python", args=["-u", "train.py"]):
|
||||
match event.type:
|
||||
case "stdout":
|
||||
print(event.data, end="")
|
||||
@ -108,77 +157,147 @@ for event in cap.exec_stream("python", args=["-u", "train.py"]):
|
||||
print(event.data, end="", file=sys.stderr)
|
||||
case "exit":
|
||||
print(f"\nExited with code {event.exit_code}")
|
||||
|
||||
# Connect to a running background process
|
||||
for event in capsule.commands.connect(handle.pid):
|
||||
if event.type == "stdout":
|
||||
print(event.data, end="")
|
||||
```
|
||||
|
||||
### `run_code()` — Stateful Code Execution
|
||||
|
||||
Execute Python code in a persistent Jupyter kernel. Variables, imports, and function definitions survive across calls:
|
||||
#### Process Management
|
||||
|
||||
```python
|
||||
with client.capsules.create(template="python-interpreter-v0-beta") as cap:
|
||||
cap.wait_ready(timeout=60)
|
||||
# List running processes
|
||||
for proc in capsule.commands.list():
|
||||
print(proc.pid, proc.cmd, proc.tag)
|
||||
|
||||
cap.run_code("x = 42")
|
||||
r = cap.run_code("x * 2")
|
||||
print(r.text) # "84"
|
||||
|
||||
cap.run_code("def greet(name): return f'hello {name}'")
|
||||
r = cap.run_code("greet('world')")
|
||||
print(r.text) # "'hello world'"
|
||||
|
||||
r = cap.run_code("1/0")
|
||||
print(r.error) # "ZeroDivisionError: division by zero\n..."
|
||||
# Kill a process
|
||||
capsule.commands.kill(pid=1234)
|
||||
```
|
||||
|
||||
**`CodeResult` fields:**
|
||||
### Filesystem
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `text` | `str \| None` | Plain text representation |
|
||||
| `data` | `dict \| None` | Rich MIME bundle (e.g. `{"image/png": "..."}`) |
|
||||
| `stdout` | `str` | Accumulated stdout |
|
||||
| `stderr` | `str` | Accumulated stderr |
|
||||
| `error` | `str \| None` | Error traceback string |
|
||||
|
||||
## Filesystem
|
||||
|
||||
Upload, download, and manage files inside capsules:
|
||||
Files are accessed via `capsule.files`:
|
||||
|
||||
```python
|
||||
# Upload / Download
|
||||
cap.upload("/app/main.py", b"print('hello')")
|
||||
content = cap.download("/app/main.py")
|
||||
# Write and read files
|
||||
capsule.files.write("/app/main.py", "print('hello')")
|
||||
content = capsule.files.read("/app/main.py") # str
|
||||
raw = capsule.files.read_bytes("/app/main.py") # bytes
|
||||
|
||||
# Streaming (for large files)
|
||||
# Check existence
|
||||
capsule.files.exists("/app/main.py") # True
|
||||
|
||||
# List directory
|
||||
entries = capsule.files.list("/home/user", depth=1)
|
||||
for entry in entries:
|
||||
print(entry.name, entry.type, entry.size)
|
||||
|
||||
# Create directory
|
||||
capsule.files.make_dir("/app/data")
|
||||
|
||||
# Remove file or directory
|
||||
capsule.files.remove("/app/old_data")
|
||||
```
|
||||
|
||||
#### Streaming (Large Files)
|
||||
|
||||
```python
|
||||
# Streaming upload
|
||||
def chunks():
|
||||
yield b"chunk1"
|
||||
yield b"chunk2"
|
||||
|
||||
cap.stream_upload("/data/large.bin", chunks())
|
||||
for chunk in cap.stream_download("/data/large.bin"):
|
||||
capsule.files.upload_stream("/data/large.bin", chunks())
|
||||
|
||||
# Streaming download
|
||||
for chunk in capsule.files.download_stream("/data/large.bin"):
|
||||
process(chunk)
|
||||
|
||||
# Directory operations
|
||||
entries = cap.list_dir("/home/user", depth=1)
|
||||
for entry in entries:
|
||||
print(entry.name, entry.type, entry.size)
|
||||
|
||||
cap.mkdir("/home/user/data")
|
||||
cap.remove("/home/user/old_data")
|
||||
```
|
||||
|
||||
## Interactive Terminal (PTY)
|
||||
### Git
|
||||
|
||||
Open a full interactive terminal session over WebSocket:
|
||||
Git operations are accessed via `capsule.git`. All commands execute the real `git` binary inside the capsule:
|
||||
|
||||
```python
|
||||
with cap.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term:
|
||||
# Initialize a repo
|
||||
capsule.git.init("/app", initial_branch="main")
|
||||
|
||||
# Configure user
|
||||
capsule.git.configure_user("Alice", "alice@example.com", cwd="/app")
|
||||
|
||||
# Stage and commit
|
||||
capsule.git.add(all=True, cwd="/app")
|
||||
capsule.git.commit("initial commit", cwd="/app")
|
||||
|
||||
# Check status
|
||||
status = capsule.git.status(cwd="/app")
|
||||
print(status.branch) # "main"
|
||||
print(status.is_clean) # True
|
||||
for f in status.files:
|
||||
print(f.path, f.index_status, f.work_tree_status)
|
||||
|
||||
# Branches
|
||||
branches = capsule.git.branches(cwd="/app")
|
||||
capsule.git.create_branch("feature", cwd="/app")
|
||||
capsule.git.checkout_branch("main", cwd="/app")
|
||||
capsule.git.delete_branch("feature", cwd="/app")
|
||||
```
|
||||
|
||||
#### Clone with Authentication
|
||||
|
||||
```python
|
||||
# Clone a private repo (credentials are stripped from remote URL after clone)
|
||||
capsule.git.clone(
|
||||
"https://github.com/org/repo.git",
|
||||
username="user",
|
||||
password="ghp_token",
|
||||
cwd="/app",
|
||||
)
|
||||
|
||||
# Push/pull with inline credentials (temporarily embedded, then restored)
|
||||
capsule.git.push("origin", "main", username="user", password="ghp_token", cwd="/app")
|
||||
capsule.git.pull("origin", "main", username="user", password="ghp_token", cwd="/app")
|
||||
```
|
||||
|
||||
#### Configuration and Remotes
|
||||
|
||||
```python
|
||||
capsule.git.set_config("core.autocrlf", "false", cwd="/app")
|
||||
value = capsule.git.get_config("user.name", cwd="/app") # str | None
|
||||
|
||||
capsule.git.remote_add("upstream", "https://github.com/org/repo.git", cwd="/app")
|
||||
url = capsule.git.remote_get("origin", cwd="/app") # str | None
|
||||
```
|
||||
|
||||
Git errors raise `GitCommandError` (or `GitAuthError` for authentication failures), both inheriting from `GitError`:
|
||||
|
||||
```python
|
||||
from wrenn import GitCommandError, GitAuthError
|
||||
|
||||
try:
|
||||
capsule.git.push("origin", "main", username="user", password="bad", cwd="/app")
|
||||
except GitAuthError as e:
|
||||
print(e.stderr)
|
||||
print(e.exit_code)
|
||||
```
|
||||
|
||||
### Interactive Terminal (PTY)
|
||||
|
||||
```python
|
||||
import sys
|
||||
|
||||
with capsule.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term:
|
||||
term.write(b"ls -la\n")
|
||||
for event in term:
|
||||
if event.type == "output":
|
||||
sys.stdout.buffer.write(event.data)
|
||||
elif event.type == "exit":
|
||||
break
|
||||
|
||||
# Reconnect to an existing session
|
||||
with capsule.pty_connect(term.tag) as term:
|
||||
term.write(b"echo reconnected\n")
|
||||
```
|
||||
|
||||
**PtySession methods:**
|
||||
@ -188,123 +307,189 @@ with cap.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term:
|
||||
| `write(data: bytes)` | Send raw bytes to stdin |
|
||||
| `resize(cols, rows)` | Resize the terminal |
|
||||
| `kill()` | Send SIGKILL to the process |
|
||||
| `tag` | Session tag (available after `started` event) |
|
||||
| `pid` | Process PID (available after `started` event) |
|
||||
| `tag` | Session tag (after `started` event) |
|
||||
| `pid` | Process PID (after `started` event) |
|
||||
|
||||
Reconnect to an existing session using the tag:
|
||||
### Proxy URL
|
||||
|
||||
Access services running inside a capsule:
|
||||
|
||||
```python
|
||||
with cap.pty_connect(term.tag) as term:
|
||||
term.write(b"echo reconnected\n")
|
||||
url = capsule.get_url(8080)
|
||||
# "wss://8080-cl-abc123.app.wrenn.dev"
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
### Snapshots
|
||||
|
||||
Pause and resume capsules to save resources:
|
||||
Create reusable templates from running capsules:
|
||||
|
||||
```python
|
||||
cap = client.capsules.create(template="minimal")
|
||||
cap.wait_ready(timeout=60)
|
||||
|
||||
# Pause (snapshots and releases resources)
|
||||
cap.pause()
|
||||
print(cap.status) # "paused"
|
||||
|
||||
# Resume (restores from snapshot)
|
||||
cap.resume()
|
||||
cap.wait_ready(timeout=60)
|
||||
template = capsule.create_snapshot(name="my-template", overwrite=True)
|
||||
```
|
||||
|
||||
Keep a capsule alive with `ping()`:
|
||||
---
|
||||
|
||||
## Code Interpreter
|
||||
|
||||
The `wrenn.code_interpreter` module provides a specialized capsule for stateful code execution via a persistent Jupyter kernel.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```python
|
||||
cap.ping() # Resets the inactivity timer
|
||||
from wrenn.code_interpreter import Capsule
|
||||
|
||||
with Capsule(wait=True) as capsule:
|
||||
result = capsule.run_code("print('hello')")
|
||||
print("".join(result.logs.stdout)) # "hello\n"
|
||||
```
|
||||
|
||||
## Proxy URL
|
||||
### Stateful Execution
|
||||
|
||||
Access services running inside a capsule through the proxy:
|
||||
Variables, imports, and function definitions persist across `run_code` calls:
|
||||
|
||||
```python
|
||||
url = cap.get_url(8888)
|
||||
# "wss://8888-cl-abc123.api.wrenn.dev"
|
||||
from wrenn.code_interpreter import Capsule
|
||||
|
||||
# Pre-configured HTTP client targeting port 8888
|
||||
resp = cap.http_client.get("/api/kernels")
|
||||
with Capsule(wait=True) as capsule:
|
||||
capsule.run_code("x = 42")
|
||||
result = capsule.run_code("x * 2")
|
||||
print(result.text) # "84"
|
||||
|
||||
capsule.run_code("import math")
|
||||
result = capsule.run_code("math.pi")
|
||||
print(result.text) # "3.141592653589793"
|
||||
|
||||
capsule.run_code("def greet(name): return f'hello {name}'")
|
||||
result = capsule.run_code("greet('world')")
|
||||
print(result.text) # "hello world"
|
||||
```
|
||||
|
||||
## Snapshots
|
||||
The `text` property returns the `text/plain` value of the main `execute_result` (the last expression in the cell). Printed output goes to `result.logs.stdout` instead.
|
||||
|
||||
Create templates from running capsules:
|
||||
### Error Handling in Code
|
||||
|
||||
```python
|
||||
# Create a snapshot
|
||||
template = client.snapshots.create(
|
||||
capsule_id="cl-abc123",
|
||||
name="my-template",
|
||||
overwrite=True,
|
||||
result = capsule.run_code("1 / 0")
|
||||
print(result.error.name) # "ZeroDivisionError"
|
||||
print(result.error.value) # "division by zero"
|
||||
print(result.error.traceback) # full traceback string
|
||||
```
|
||||
|
||||
### Rich Output
|
||||
|
||||
Each call to `display()`, `plt.show()`, or similar produces a `Result` in `execution.results`. Known MIME types are unpacked into named fields:
|
||||
|
||||
```python
|
||||
result = capsule.run_code("""
|
||||
import matplotlib.pyplot as plt
|
||||
plt.plot([1, 2, 3])
|
||||
plt.show()
|
||||
""")
|
||||
for r in result.results:
|
||||
if r.png:
|
||||
print(f"Got PNG image ({len(r.png)} bytes base64)")
|
||||
print(r.formats()) # e.g. ["text", "png"]
|
||||
```
|
||||
|
||||
### Streaming Callbacks
|
||||
|
||||
```python
|
||||
capsule.run_code(
|
||||
code,
|
||||
on_result=lambda r: print("result:", r.formats()),
|
||||
on_stdout=lambda text: print("stdout:", text),
|
||||
on_stderr=lambda text: print("stderr:", text),
|
||||
on_error=lambda err: print(f"error: {err.name}: {err.value}"),
|
||||
)
|
||||
|
||||
# List templates
|
||||
for t in client.snapshots.list():
|
||||
print(t.name, t.type)
|
||||
|
||||
# Delete
|
||||
client.snapshots.delete("my-template")
|
||||
```
|
||||
|
||||
## Hosts
|
||||
### Custom Templates
|
||||
|
||||
Manage host machines:
|
||||
By default, `code-runner-beta` template is used. You can specify a custom template:
|
||||
|
||||
```python
|
||||
host = client.hosts.create(type="regular")
|
||||
client.hosts.list()
|
||||
client.hosts.get("h-1")
|
||||
client.hosts.delete("h-1")
|
||||
client.hosts.regenerate_token("h-1")
|
||||
client.hosts.list_tags("h-1")
|
||||
client.hosts.add_tag("h-1", "gpu")
|
||||
client.hosts.remove_tag("h-1", "gpu")
|
||||
capsule = Capsule(template="my-custom-jupyter-template", wait=True)
|
||||
result = capsule.run_code("print('running on custom template')")
|
||||
```
|
||||
|
||||
### Execution Model
|
||||
|
||||
`run_code()` returns an `Execution` object:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `results` | `list[Result]` | All rich outputs (charts, images, expression values) |
|
||||
| `logs` | `Logs` | `.stdout: list[str]` and `.stderr: list[str]` chunks |
|
||||
| `error` | `ExecutionError \| None` | `.name`, `.value`, `.traceback` |
|
||||
| `execution_count` | `int \| None` | Jupyter cell execution counter |
|
||||
| `text` | `str \| None` | (property) `text/plain` of the main `execute_result` |
|
||||
|
||||
Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `pdf`, `latex`, `json`, `javascript`, plus `extra` for unknown types. String expression results have quotes stripped automatically.
|
||||
|
||||
### Code Interpreter + Commands/Files
|
||||
|
||||
The code interpreter capsule inherits all standard capsule features:
|
||||
|
||||
```python
|
||||
from wrenn.code_interpreter import Capsule
|
||||
|
||||
with Capsule(wait=True) as capsule:
|
||||
# Use run_code for Jupyter execution
|
||||
capsule.run_code("import pandas as pd; df = pd.DataFrame({'a': [1,2,3]})")
|
||||
capsule.run_code("df.to_csv('/tmp/data.csv', index=False)")
|
||||
|
||||
# Use standard file operations
|
||||
content = capsule.files.read("/tmp/data.csv")
|
||||
print(content)
|
||||
|
||||
# Use standard command execution
|
||||
result = capsule.commands.run("wc -l /tmp/data.csv")
|
||||
print(result.stdout)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Async Support
|
||||
|
||||
All operations have async variants. Use `AsyncWrennClient` and prefix capsule methods with `async_`:
|
||||
All operations have async variants via `AsyncCapsule`:
|
||||
|
||||
### Async Capsule
|
||||
|
||||
```python
|
||||
from wrenn import AsyncWrennClient
|
||||
from wrenn import AsyncCapsule
|
||||
|
||||
async with AsyncWrennClient(api_key="wrn_...") as client:
|
||||
cap = await client.capsules.create(template="minimal")
|
||||
await cap.async_wait_ready(timeout=60)
|
||||
async with await AsyncCapsule.create(template="minimal", wait=True) as capsule:
|
||||
result = await capsule.commands.run("echo hello")
|
||||
print(result.stdout)
|
||||
|
||||
result = await cap.async_exec("echo", args=["hello"])
|
||||
await cap.async_upload("/app/file.txt", b"data")
|
||||
entries = await cap.async_list_dir("/home/user")
|
||||
r = await cap.async_run_code("42 * 2")
|
||||
await capsule.files.write("/app/file.txt", "data")
|
||||
entries = await capsule.files.list("/app")
|
||||
|
||||
await cap.async_destroy()
|
||||
await capsule.pause()
|
||||
await capsule.resume()
|
||||
```
|
||||
|
||||
**Async method mapping:**
|
||||
### Async Code Interpreter
|
||||
|
||||
| Sync | Async |
|
||||
|------|-------|
|
||||
| `exec()` | `async_exec()` |
|
||||
| `upload()` | `async_upload()` |
|
||||
| `download()` | `async_download()` |
|
||||
| `stream_upload()` | `async_stream_upload()` |
|
||||
| `stream_download()` | `async_stream_download()` |
|
||||
| `list_dir()` | `async_list_dir()` |
|
||||
| `mkdir()` | `async_mkdir()` |
|
||||
| `remove()` | `async_remove()` |
|
||||
| `wait_ready()` | `async_wait_ready()` |
|
||||
| `pause()` | `async_pause()` |
|
||||
| `resume()` | `async_resume()` |
|
||||
| `destroy()` | `async_destroy()` |
|
||||
| `ping()` | `async_ping()` |
|
||||
| `run_code()` | `async_run_code()` |
|
||||
```python
|
||||
from wrenn.code_interpreter import AsyncCapsule
|
||||
|
||||
async with await AsyncCapsule.create(wait=True) as capsule:
|
||||
result = await capsule.run_code("2 + 2")
|
||||
print(result.text) # "4"
|
||||
```
|
||||
|
||||
### Async PTY
|
||||
|
||||
```python
|
||||
async with capsule.pty(cmd="/bin/bash") as term:
|
||||
await term.write(b"ls -la\n")
|
||||
async for event in term:
|
||||
if event.type == "output":
|
||||
sys.stdout.buffer.write(event.data)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
@ -318,14 +503,14 @@ from wrenn import (
|
||||
WrennForbiddenError, # 403
|
||||
WrennNotFoundError, # 404
|
||||
WrennConflictError, # 409
|
||||
WrennHostHasCapsulesError, # 409 — host has running capsules
|
||||
WrennHostHasCapsulesError, # 409 (host has running capsules)
|
||||
WrennAgentError, # 502
|
||||
WrennInternalError, # 500
|
||||
WrennHostUnavailableError, # 503
|
||||
)
|
||||
|
||||
try:
|
||||
client.capsules.get("nonexistent")
|
||||
Capsule.get_info("nonexistent")
|
||||
except WrennNotFoundError as e:
|
||||
print(e.code) # "not_found"
|
||||
print(e.message) # "capsule not found"
|
||||
@ -334,6 +519,67 @@ except WrennNotFoundError as e:
|
||||
|
||||
All exceptions inherit from `WrennError` and expose `.code`, `.message`, and `.status_code`.
|
||||
|
||||
---
|
||||
|
||||
## Migrating from e2b
|
||||
|
||||
Replace your imports:
|
||||
|
||||
```python
|
||||
# Before
|
||||
from e2b import Sandbox
|
||||
sandbox = Sandbox()
|
||||
|
||||
# After
|
||||
from wrenn import Capsule
|
||||
capsule = Capsule()
|
||||
```
|
||||
|
||||
For code interpreter:
|
||||
|
||||
```python
|
||||
# Before
|
||||
from e2b_code_interpreter import Sandbox
|
||||
sandbox = Sandbox()
|
||||
result = sandbox.run_code("print('hello')")
|
||||
|
||||
# After
|
||||
from wrenn.code_interpreter import Capsule
|
||||
capsule = Capsule()
|
||||
result = capsule.run_code("print('hello')")
|
||||
```
|
||||
|
||||
The `Sandbox` name is available as a deprecated alias in both modules:
|
||||
|
||||
```python
|
||||
from wrenn import Sandbox # works, emits FutureWarning
|
||||
from wrenn.code_interpreter import Sandbox # works, emits FutureWarning
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Low-Level Client
|
||||
|
||||
For direct API access, use `WrennClient` / `AsyncWrennClient`:
|
||||
|
||||
```python
|
||||
from wrenn import WrennClient
|
||||
|
||||
with WrennClient(api_key="wrn_...") as client:
|
||||
capsule = client.capsules.create(template="minimal")
|
||||
client.capsules.pause(capsule.id)
|
||||
client.capsules.resume(capsule.id)
|
||||
client.capsules.ping(capsule.id)
|
||||
client.capsules.destroy(capsule.id)
|
||||
|
||||
# Snapshots
|
||||
template = client.snapshots.create(capsule_id="cl-abc", name="my-snap")
|
||||
templates = client.snapshots.list()
|
||||
client.snapshots.delete("my-snap")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
This project uses [uv](https://docs.astral.sh/uv/) for dependency management.
|
||||
@ -350,21 +596,28 @@ make test
|
||||
|
||||
# Run all tests (including integration)
|
||||
make test-integration
|
||||
|
||||
# Regenerate models from OpenAPI spec
|
||||
make generate
|
||||
```
|
||||
|
||||
### Running Integration Tests
|
||||
|
||||
Integration tests require a live Wrenn server. Set environment variables:
|
||||
Integration tests require a live Wrenn server. Set credentials via environment or a `.env` file at the project root:
|
||||
|
||||
```bash
|
||||
# Option 1: environment variable
|
||||
export WRENN_API_KEY="wrn_..."
|
||||
export WRENN_BASE_URL="http://localhost:8080" # optional
|
||||
|
||||
# Option 2: .env file
|
||||
echo 'WRENN_API_KEY=wrn_...' > .env
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
make test-integration
|
||||
```
|
||||
|
||||
Tests are automatically skipped when `WRENN_API_KEY` is not available.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
1378
api/openapi.yaml
1378
api/openapi.yaml
File diff suppressed because it is too large
Load Diff
4267
docs/reference.md
Normal file
4267
docs/reference.md
Normal file
File diff suppressed because it is too large
Load Diff
12
pydoc-markdown.yml
Normal file
12
pydoc-markdown.yml
Normal file
@ -0,0 +1,12 @@
|
||||
loaders:
|
||||
- type: python
|
||||
search_path: [src]
|
||||
|
||||
processors:
|
||||
- type: google # Use Google-style docstring parser
|
||||
- type: filter
|
||||
- type: crossref
|
||||
|
||||
renderer:
|
||||
type: markdown
|
||||
escape_html_in_docstring: false
|
||||
@ -1,12 +1,26 @@
|
||||
[project]
|
||||
name = "wrenn"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
version = "0.1.4"
|
||||
description = "Python SDK for Wrenn"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ["LICENSE"]
|
||||
authors = [
|
||||
{ name = "Tasnim Kabir Sadik", email = "tksadik92@gmail.com" }
|
||||
{ name = "Rafeed M. Bhuiyan", email = "rafeed@omukk.dev" },
|
||||
{ name = "Tasnim Kabir Sadik", email = "tksadik@omukk.dev" },
|
||||
]
|
||||
requires-python = ">=3.13"
|
||||
keywords = ["wrenn"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"email-validator>=2.3.0",
|
||||
"httpx>=0.28.1",
|
||||
@ -20,14 +34,20 @@ build-backend = "hatchling.build"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"datamodel-code-generator>=0.56.0",
|
||||
"datamodel-code-generator[ruff]>=0.56.0",
|
||||
"mypy>=1.20.0",
|
||||
"pre-commit>=4.6.0",
|
||||
"pydoc-markdown>=4.8.2",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"respx>=0.23.1",
|
||||
"ruff>=0.15.10",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://wrenn.dev"
|
||||
Repository = "https://github.com/wrennhq/python-sdk"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"integration: integration tests (require live server)",
|
||||
|
||||
@ -1,7 +1,20 @@
|
||||
from wrenn.capsule import (
|
||||
Capsule,
|
||||
CodeResult,
|
||||
ExecResult,
|
||||
from wrenn._git import (
|
||||
AsyncGit,
|
||||
FileStatus,
|
||||
Git,
|
||||
GitAuthError,
|
||||
GitBranch,
|
||||
GitCommandError,
|
||||
GitError,
|
||||
GitStatus,
|
||||
)
|
||||
from wrenn.async_capsule import AsyncCapsule
|
||||
from wrenn.capsule import Capsule
|
||||
from wrenn.client import AsyncWrennClient, WrennClient
|
||||
from wrenn.commands import (
|
||||
CommandHandle,
|
||||
CommandResult,
|
||||
ProcessInfo,
|
||||
StreamErrorEvent,
|
||||
StreamEvent,
|
||||
StreamExitEvent,
|
||||
@ -9,7 +22,6 @@ from wrenn.capsule import (
|
||||
StreamStderrEvent,
|
||||
StreamStdoutEvent,
|
||||
)
|
||||
from wrenn.client import AsyncWrennClient, WrennClient
|
||||
from wrenn.exceptions import (
|
||||
WrennAgentError,
|
||||
WrennAuthenticationError,
|
||||
@ -25,16 +37,26 @@ from wrenn.exceptions import (
|
||||
from wrenn.models import FileEntry
|
||||
from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.4"
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"AsyncCapsule",
|
||||
"AsyncGit",
|
||||
"AsyncPtySession",
|
||||
"AsyncWrennClient",
|
||||
"Capsule",
|
||||
"CodeResult",
|
||||
"ExecResult",
|
||||
"CommandHandle",
|
||||
"CommandResult",
|
||||
"FileEntry",
|
||||
"FileStatus",
|
||||
"Git",
|
||||
"GitAuthError",
|
||||
"GitBranch",
|
||||
"GitCommandError",
|
||||
"GitError",
|
||||
"GitStatus",
|
||||
"ProcessInfo",
|
||||
"PtyEvent",
|
||||
"PtyEventType",
|
||||
"PtySession",
|
||||
@ -61,22 +83,25 @@ __all__ = [
|
||||
|
||||
|
||||
def __getattr__(name: str) -> type:
|
||||
if name == "Sandbox":
|
||||
import warnings
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
_module = sys.modules[__name__]
|
||||
|
||||
if name == "Sandbox":
|
||||
warnings.warn(
|
||||
"'Sandbox' is deprecated, use 'Capsule' instead",
|
||||
DeprecationWarning,
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
setattr(_module, name, Capsule)
|
||||
return Capsule
|
||||
if name == "WrennHostHasSandboxesError":
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"'WrennHostHasSandboxesError' is deprecated, use 'WrennHostHasCapsulesError' instead",
|
||||
DeprecationWarning,
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
setattr(_module, name, WrennHostHasCapsulesError)
|
||||
return WrennHostHasCapsulesError
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
5
src/wrenn/_config.py
Normal file
5
src/wrenn/_config.py
Normal file
@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
DEFAULT_BASE_URL = "https://app.wrenn.dev/api"
|
||||
ENV_API_KEY = "WRENN_API_KEY"
|
||||
ENV_BASE_URL = "WRENN_BASE_URL"
|
||||
1435
src/wrenn/_git/__init__.py
Normal file
1435
src/wrenn/_git/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
104
src/wrenn/_git/_auth.py
Normal file
104
src/wrenn/_git/_auth.py
Normal file
@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
|
||||
def embed_credentials(url: str, username: str, password: str) -> str:
|
||||
"""Embed HTTP(S) credentials into a git URL.
|
||||
|
||||
Args:
|
||||
url: Git repository URL.
|
||||
username: Username for authentication.
|
||||
password: Password or personal access token.
|
||||
|
||||
Returns:
|
||||
URL with ``username:password@`` embedded in the netloc.
|
||||
|
||||
Raises:
|
||||
ValueError: If the URL scheme is not ``http`` or ``https``.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("Only http(s) URLs support embedded credentials.")
|
||||
netloc = f"{username}:{password}@{parsed.hostname}"
|
||||
if parsed.port:
|
||||
netloc = f"{netloc}:{parsed.port}"
|
||||
return urlunparse(parsed._replace(netloc=netloc))
|
||||
|
||||
|
||||
def strip_credentials(url: str) -> str:
|
||||
"""Remove embedded credentials from a git URL.
|
||||
|
||||
Args:
|
||||
url: Git repository URL, possibly with credentials.
|
||||
|
||||
Returns:
|
||||
URL with credentials removed. Non-HTTP(S) URLs are returned
|
||||
unchanged.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return url
|
||||
if not parsed.username and not parsed.password:
|
||||
return url
|
||||
host = parsed.hostname or ""
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
return urlunparse(parsed._replace(netloc=host))
|
||||
|
||||
|
||||
def is_auth_error(stderr: str) -> bool:
|
||||
"""Check whether git stderr indicates an authentication failure.
|
||||
|
||||
Args:
|
||||
stderr: Combined stderr output from a git command.
|
||||
|
||||
Returns:
|
||||
``True`` if any known auth-failure pattern is found.
|
||||
"""
|
||||
lower = stderr.lower()
|
||||
patterns = (
|
||||
"authentication failed",
|
||||
"terminal prompts disabled",
|
||||
"could not read username",
|
||||
"invalid username or password",
|
||||
"access denied",
|
||||
"permission denied",
|
||||
"not authorized",
|
||||
)
|
||||
return any(p in lower for p in patterns)
|
||||
|
||||
|
||||
def build_credential_approve_cmd(
|
||||
username: str,
|
||||
password: str,
|
||||
host: str = "github.com",
|
||||
protocol: str = "https",
|
||||
) -> str:
|
||||
"""Build a shell command that pipes credentials into ``git credential approve``.
|
||||
|
||||
Args:
|
||||
username: Git username.
|
||||
password: Password or personal access token.
|
||||
host: Target host. Defaults to ``"github.com"``.
|
||||
protocol: Protocol. Defaults to ``"https"``.
|
||||
|
||||
Returns:
|
||||
A shell command string safe to pass to ``commands.run()``.
|
||||
"""
|
||||
if "\n" in username or "\n" in password:
|
||||
raise ValueError("Credentials must not contain newline characters.")
|
||||
target_host = host.strip() or "github.com"
|
||||
target_protocol = protocol.strip() or "https"
|
||||
credential_input = "\n".join(
|
||||
[
|
||||
f"protocol={target_protocol}",
|
||||
f"host={target_host}",
|
||||
f"username={username}",
|
||||
f"password={password}",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return f"printf %s {shlex.quote(credential_input)} | git credential approve"
|
||||
499
src/wrenn/_git/_cmd.py
Normal file
499
src/wrenn/_git/_cmd.py
Normal file
@ -0,0 +1,499 @@
|
||||
"""Pure functions that build git argument lists and parse git output.
|
||||
|
||||
No I/O, no network, no imports from ``wrenn``. Every ``build_*`` function
|
||||
returns a ``list[str]`` suitable for ``shlex.join()``. Every ``parse_*``
|
||||
function takes raw stdout and returns a typed structure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
# ── Data types ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileStatus:
|
||||
"""A single entry from ``git status --porcelain=v1``.
|
||||
|
||||
Attributes:
|
||||
path (str): File path relative to the repository root.
|
||||
index_status (str): Index (staged) status character.
|
||||
work_tree_status (str): Working-tree status character.
|
||||
renamed_from (str | None): Original path when status is a rename.
|
||||
"""
|
||||
|
||||
path: str
|
||||
index_status: str
|
||||
work_tree_status: str
|
||||
renamed_from: str | None = None
|
||||
|
||||
@property
|
||||
def staged(self) -> bool:
|
||||
"""Whether the change is staged in the index."""
|
||||
return self.index_status not in (" ", "?")
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""Normalized human-readable status label."""
|
||||
return _derive_status(self.index_status, self.work_tree_status)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitStatus:
|
||||
"""Parsed output of ``git status --porcelain=v1 --branch``.
|
||||
|
||||
Attributes:
|
||||
branch (str | None): Current branch name, or ``None`` if detached.
|
||||
upstream (str | None): Upstream tracking branch.
|
||||
ahead (int): Commits ahead of upstream.
|
||||
behind (int): Commits behind upstream.
|
||||
detached (bool): Whether HEAD is detached.
|
||||
files (list[FileStatus]): Per-file status entries.
|
||||
"""
|
||||
|
||||
branch: str | None = None
|
||||
upstream: str | None = None
|
||||
ahead: int = 0
|
||||
behind: int = 0
|
||||
detached: bool = False
|
||||
files: list[FileStatus] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_clean(self) -> bool:
|
||||
"""``True`` when there are no changed or untracked files."""
|
||||
return len(self.files) == 0
|
||||
|
||||
@property
|
||||
def has_staged(self) -> bool:
|
||||
"""``True`` when at least one file has staged changes."""
|
||||
return any(f.staged for f in self.files)
|
||||
|
||||
@property
|
||||
def has_untracked(self) -> bool:
|
||||
"""``True`` when at least one file is untracked."""
|
||||
return any(f.status == "untracked" for f in self.files)
|
||||
|
||||
@property
|
||||
def has_conflicts(self) -> bool:
|
||||
"""``True`` when at least one file has merge conflicts."""
|
||||
return any(f.status == "conflict" for f in self.files)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitBranch:
|
||||
"""A single branch entry.
|
||||
|
||||
Attributes:
|
||||
name (str): Branch name (short ref).
|
||||
is_current (bool): Whether this is the checked-out branch.
|
||||
"""
|
||||
|
||||
name: str
|
||||
is_current: bool = False
|
||||
|
||||
|
||||
# ── Argument builders ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_clone(
|
||||
url: str,
|
||||
dest: str | None = None,
|
||||
*,
|
||||
branch: str | None = None,
|
||||
depth: int | None = None,
|
||||
) -> list[str]:
|
||||
"""Build ``git clone`` arguments."""
|
||||
args = ["git", "clone"]
|
||||
if branch:
|
||||
args.extend(["--branch", branch, "--single-branch"])
|
||||
if depth is not None:
|
||||
args.extend(["--depth", str(depth)])
|
||||
args.append(url)
|
||||
if dest:
|
||||
args.append(dest)
|
||||
return args
|
||||
|
||||
|
||||
def build_init(
|
||||
path: str = ".",
|
||||
*,
|
||||
bare: bool = False,
|
||||
initial_branch: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Build ``git init`` arguments."""
|
||||
args = ["git", "init"]
|
||||
if initial_branch:
|
||||
args.extend(["--initial-branch", initial_branch])
|
||||
if bare:
|
||||
args.append("--bare")
|
||||
args.append(path)
|
||||
return args
|
||||
|
||||
|
||||
def build_add(
|
||||
paths: list[str] | None = None,
|
||||
*,
|
||||
all: bool = False,
|
||||
) -> list[str]:
|
||||
"""Build ``git add`` arguments."""
|
||||
args = ["git", "add"]
|
||||
if not paths:
|
||||
args.append("-A" if all else ".")
|
||||
else:
|
||||
args.append("--")
|
||||
args.extend(paths)
|
||||
return args
|
||||
|
||||
|
||||
def build_commit(
|
||||
message: str,
|
||||
*,
|
||||
allow_empty: bool = False,
|
||||
author_name: str | None = None,
|
||||
author_email: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Build ``git commit`` arguments."""
|
||||
args = ["git"]
|
||||
if author_name:
|
||||
args.extend(["-c", f"user.name={author_name}"])
|
||||
if author_email:
|
||||
args.extend(["-c", f"user.email={author_email}"])
|
||||
args.extend(["commit", "-m", message])
|
||||
if allow_empty:
|
||||
args.append("--allow-empty")
|
||||
return args
|
||||
|
||||
|
||||
def build_push(
|
||||
remote: str = "origin",
|
||||
branch: str | None = None,
|
||||
*,
|
||||
force: bool = False,
|
||||
set_upstream: bool = False,
|
||||
) -> list[str]:
|
||||
"""Build ``git push`` arguments."""
|
||||
args = ["git", "push"]
|
||||
if force:
|
||||
args.append("--force")
|
||||
if set_upstream:
|
||||
args.append("--set-upstream")
|
||||
args.append(remote)
|
||||
if branch:
|
||||
args.append(branch)
|
||||
return args
|
||||
|
||||
|
||||
def build_pull(
|
||||
remote: str = "origin",
|
||||
branch: str | None = None,
|
||||
*,
|
||||
rebase: bool = False,
|
||||
ff_only: bool = False,
|
||||
) -> list[str]:
|
||||
"""Build ``git pull`` arguments."""
|
||||
args = ["git", "pull"]
|
||||
if rebase:
|
||||
args.append("--rebase")
|
||||
if ff_only:
|
||||
args.append("--ff-only")
|
||||
args.append(remote)
|
||||
if branch:
|
||||
args.append(branch)
|
||||
return args
|
||||
|
||||
|
||||
def build_status() -> list[str]:
|
||||
"""Build ``git status`` arguments for porcelain parsing."""
|
||||
return ["git", "status", "--porcelain=v1", "--branch"]
|
||||
|
||||
|
||||
def build_branches() -> list[str]:
|
||||
"""Build ``git branch`` arguments for structured parsing."""
|
||||
return ["git", "branch", "--format=%(refname:short)\t%(HEAD)"]
|
||||
|
||||
|
||||
def build_create_branch(
|
||||
name: str,
|
||||
*,
|
||||
start_point: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Build ``git checkout -b`` arguments."""
|
||||
args = ["git", "checkout", "-b", name]
|
||||
if start_point:
|
||||
args.append(start_point)
|
||||
return args
|
||||
|
||||
|
||||
def build_checkout(name: str) -> list[str]:
|
||||
"""Build ``git checkout`` arguments."""
|
||||
return ["git", "checkout", name]
|
||||
|
||||
|
||||
def build_delete_branch(
|
||||
name: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> list[str]:
|
||||
"""Build ``git branch -d/-D`` arguments."""
|
||||
return ["git", "branch", "-D" if force else "-d", name]
|
||||
|
||||
|
||||
def build_remote_add(name: str, url: str, *, fetch: bool = False) -> list[str]:
|
||||
"""Build ``git remote add`` arguments."""
|
||||
args = ["git", "remote", "add"]
|
||||
if fetch:
|
||||
args.append("-f")
|
||||
args.extend([name, url])
|
||||
return args
|
||||
|
||||
|
||||
def build_remote_get_url(name: str = "origin") -> list[str]:
|
||||
"""Build ``git remote get-url`` arguments."""
|
||||
return ["git", "remote", "get-url", name]
|
||||
|
||||
|
||||
def build_remote_set_url(name: str, url: str) -> list[str]:
|
||||
"""Build ``git remote set-url`` arguments."""
|
||||
return ["git", "remote", "set-url", name, url]
|
||||
|
||||
|
||||
def build_reset(
|
||||
*,
|
||||
mode: str | None = None,
|
||||
ref: str | None = None,
|
||||
paths: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Build ``git reset`` arguments.
|
||||
|
||||
Args:
|
||||
mode: Reset mode (``soft``, ``mixed``, ``hard``, ``merge``, ``keep``).
|
||||
ref: Commit, branch, or ref to reset to.
|
||||
paths: Paths to reset (mutually exclusive with ``mode``).
|
||||
"""
|
||||
_ALLOWED_MODES = {"soft", "mixed", "hard", "merge", "keep"}
|
||||
if mode and mode not in _ALLOWED_MODES:
|
||||
raise ValueError(
|
||||
f"Reset mode must be one of {', '.join(sorted(_ALLOWED_MODES))}."
|
||||
)
|
||||
args = ["git", "reset"]
|
||||
if mode:
|
||||
args.append(f"--{mode}")
|
||||
if ref:
|
||||
args.append(ref)
|
||||
if paths:
|
||||
args.append("--")
|
||||
args.extend(paths)
|
||||
return args
|
||||
|
||||
|
||||
def build_restore(
|
||||
paths: list[str],
|
||||
*,
|
||||
staged: bool = False,
|
||||
worktree: bool = False,
|
||||
source: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Build ``git restore`` arguments.
|
||||
|
||||
Args:
|
||||
paths: Paths to restore.
|
||||
staged: Restore the index (unstage).
|
||||
worktree: Restore working-tree files.
|
||||
source: Commit or ref to restore from.
|
||||
"""
|
||||
if not paths:
|
||||
raise ValueError("At least one path is required.")
|
||||
if not staged and not worktree:
|
||||
worktree = True
|
||||
args = ["git", "restore"]
|
||||
if worktree:
|
||||
args.append("--worktree")
|
||||
if staged:
|
||||
args.append("--staged")
|
||||
if source:
|
||||
args.extend(["--source", source])
|
||||
args.append("--")
|
||||
args.extend(paths)
|
||||
return args
|
||||
|
||||
|
||||
def build_config_set(
|
||||
key: str,
|
||||
value: str,
|
||||
*,
|
||||
scope: str = "local",
|
||||
repo_path: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Build ``git config`` set arguments."""
|
||||
scope_flag = _resolve_scope_flag(scope)
|
||||
args = ["git"]
|
||||
if scope == "local" and repo_path:
|
||||
args.extend(["-C", repo_path])
|
||||
args.extend(["config", scope_flag, key, value])
|
||||
return args
|
||||
|
||||
|
||||
def build_config_get(
|
||||
key: str,
|
||||
*,
|
||||
scope: str = "local",
|
||||
repo_path: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Build ``git config --get`` arguments."""
|
||||
scope_flag = _resolve_scope_flag(scope)
|
||||
args = ["git"]
|
||||
if scope == "local" and repo_path:
|
||||
args.extend(["-C", repo_path])
|
||||
args.extend(["config", scope_flag, "--get", key])
|
||||
return args
|
||||
|
||||
|
||||
def build_has_upstream() -> list[str]:
|
||||
"""Build arguments to check if current branch has upstream tracking."""
|
||||
return ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
|
||||
|
||||
|
||||
# ── Parsers ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_status(stdout: str) -> GitStatus:
|
||||
"""Parse ``git status --porcelain=v1 --branch`` output.
|
||||
|
||||
Args:
|
||||
stdout: Raw stdout from the git status command.
|
||||
|
||||
Returns:
|
||||
Parsed :class:`GitStatus`.
|
||||
"""
|
||||
lines = [line for line in stdout.split("\n") if line.rstrip()]
|
||||
if not lines:
|
||||
return GitStatus()
|
||||
|
||||
status = GitStatus()
|
||||
|
||||
branch_line = lines[0]
|
||||
if branch_line.startswith("## "):
|
||||
_parse_branch_line(branch_line[3:], status)
|
||||
|
||||
for line in lines[1:]:
|
||||
if line.startswith("?? "):
|
||||
status.files.append(
|
||||
FileStatus(
|
||||
path=line[3:],
|
||||
index_status="?",
|
||||
work_tree_status="?",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if len(line) < 4:
|
||||
continue
|
||||
|
||||
idx = line[0]
|
||||
wt = line[1]
|
||||
path = line[3:]
|
||||
renamed_from = None
|
||||
if " -> " in path:
|
||||
renamed_from, path = path.split(" -> ", 1)
|
||||
|
||||
status.files.append(
|
||||
FileStatus(
|
||||
path=path,
|
||||
index_status=idx,
|
||||
work_tree_status=wt,
|
||||
renamed_from=renamed_from,
|
||||
)
|
||||
)
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def parse_branches(stdout: str) -> list[GitBranch]:
|
||||
"""Parse ``git branch --format=%(refname:short)\\t%(HEAD)`` output.
|
||||
|
||||
Args:
|
||||
stdout: Raw stdout from the git branch command.
|
||||
|
||||
Returns:
|
||||
List of :class:`GitBranch`.
|
||||
"""
|
||||
branches: list[GitBranch] = []
|
||||
for line in stdout.split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
name = parts[0]
|
||||
is_current = len(parts) > 1 and parts[1] == "*"
|
||||
branches.append(GitBranch(name=name, is_current=is_current))
|
||||
return branches
|
||||
|
||||
|
||||
# ── Internal helpers ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_scope_flag(scope: str) -> str:
|
||||
"""Convert a scope name to a git config flag."""
|
||||
scope = scope.strip().lower()
|
||||
if scope == "local":
|
||||
return "--local"
|
||||
if scope == "global":
|
||||
return "--global"
|
||||
if scope == "system":
|
||||
return "--system"
|
||||
raise ValueError("Git config scope must be one of: local, global, system.")
|
||||
|
||||
|
||||
def _parse_branch_line(info: str, status: GitStatus) -> None:
|
||||
"""Parse the ``## branch...upstream [ahead N, behind M]`` header."""
|
||||
ahead_start = info.find(" [")
|
||||
branch_part = info if ahead_start == -1 else info[:ahead_start]
|
||||
ahead_part = None if ahead_start == -1 else info[ahead_start + 2 : -1]
|
||||
|
||||
if branch_part.startswith("HEAD (detached at "):
|
||||
status.detached = True
|
||||
status.branch = branch_part[18:].rstrip(")")
|
||||
elif "detached" in branch_part or branch_part.startswith("HEAD"):
|
||||
status.detached = True
|
||||
elif "..." in branch_part:
|
||||
local, remote = branch_part.split("...", 1)
|
||||
status.branch = local or None
|
||||
status.upstream = remote or None
|
||||
else:
|
||||
name = branch_part.replace("No commits yet on ", "").replace(
|
||||
"Initial commit on ", ""
|
||||
)
|
||||
status.branch = name or None
|
||||
|
||||
if ahead_part:
|
||||
m = re.search(r"ahead (\d+)", ahead_part)
|
||||
if m:
|
||||
status.ahead = int(m.group(1))
|
||||
m = re.search(r"behind (\d+)", ahead_part)
|
||||
if m:
|
||||
status.behind = int(m.group(1))
|
||||
|
||||
|
||||
def _derive_status(index_status: str, work_tree_status: str) -> str:
|
||||
"""Derive a normalized status label from porcelain XY characters."""
|
||||
chars = {index_status, work_tree_status}
|
||||
if "U" in chars:
|
||||
return "conflict"
|
||||
if "R" in chars:
|
||||
return "renamed"
|
||||
if "C" in chars:
|
||||
return "copied"
|
||||
if "D" in chars:
|
||||
return "deleted"
|
||||
if "A" in chars:
|
||||
return "added"
|
||||
if "M" in chars:
|
||||
return "modified"
|
||||
if "T" in chars:
|
||||
return "typechange"
|
||||
if "?" in chars:
|
||||
return "untracked"
|
||||
return "unknown"
|
||||
28
src/wrenn/_git/exceptions.py
Normal file
28
src/wrenn/_git/exceptions.py
Normal file
@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class GitError(Exception):
|
||||
"""Base exception for all git operations inside a capsule.
|
||||
|
||||
Not a subclass of :class:`WrennError` because git errors originate
|
||||
from a process exit code, not an HTTP response.
|
||||
|
||||
Attributes:
|
||||
message (str): Human-readable error description.
|
||||
stderr (str): Raw stderr output from the git process.
|
||||
exit_code (int): Process exit code.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, stderr: str = "", exit_code: int = -1) -> None:
|
||||
self.message = message
|
||||
self.stderr = stderr
|
||||
self.exit_code = exit_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class GitCommandError(GitError):
|
||||
"""A git command exited with a non-zero exit code."""
|
||||
|
||||
|
||||
class GitAuthError(GitError):
|
||||
"""Authentication failed when communicating with a remote."""
|
||||
475
src/wrenn/async_capsule.py
Normal file
475
src/wrenn/async_capsule.py
Normal file
@ -0,0 +1,475 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import builtins
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import httpx_ws
|
||||
|
||||
from wrenn._git import AsyncGit
|
||||
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.commands import AsyncCommands
|
||||
from wrenn.exceptions import WrennNotFoundError
|
||||
from wrenn.files import AsyncFiles
|
||||
from wrenn.models import Capsule as CapsuleModel
|
||||
from wrenn.models import Status, Template
|
||||
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:
|
||||
"""Async Wrenn capsule with e2b-compatible interface.
|
||||
|
||||
Create via classmethod::
|
||||
|
||||
capsule = await AsyncCapsule.create(template="minimal")
|
||||
|
||||
Use as async context manager::
|
||||
|
||||
async with await AsyncCapsule.create() as capsule:
|
||||
await capsule.commands.run("echo hello")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
_capsule_id: str,
|
||||
_client: AsyncWrennClient,
|
||||
_info: CapsuleModel | None = None,
|
||||
) -> None:
|
||||
self._id = _capsule_id
|
||||
self._client = _client
|
||||
self._info = _info
|
||||
|
||||
self.commands = AsyncCommands(_capsule_id, _client.http)
|
||||
self.files = AsyncFiles(_capsule_id, _client.http)
|
||||
self.git = AsyncGit(_capsule_id, _client.http)
|
||||
|
||||
# ── Properties ──────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def capsule_id(self) -> str:
|
||||
"""The capsule's unique identifier.
|
||||
|
||||
Returns:
|
||||
str: Capsule ID assigned by the Wrenn API.
|
||||
"""
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def info(self) -> CapsuleModel | None:
|
||||
"""Cached capsule metadata from the last API call.
|
||||
|
||||
Returns:
|
||||
CapsuleModel | None: The last-fetched capsule model, or ``None``
|
||||
if the capsule was connected without an initial fetch.
|
||||
"""
|
||||
return self._info
|
||||
|
||||
# ── Factory classmethods ────────────────────────────────────
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
template: str | None = None,
|
||||
vcpus: int | None = None,
|
||||
memory_mb: int | None = None,
|
||||
timeout: int | None = None,
|
||||
*,
|
||||
wait: bool = False,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> AsyncCapsule:
|
||||
"""Create a new capsule.
|
||||
|
||||
Args:
|
||||
template (str | None): Template name to boot from.
|
||||
vcpus (int | None): Number of virtual CPUs.
|
||||
memory_mb (int | None): Memory in MiB.
|
||||
timeout (int | None): Inactivity TTL in seconds before auto-pause.
|
||||
wait (bool): Await until the capsule reaches ``running`` status.
|
||||
api_key (str | None): Wrenn API key. Falls back to
|
||||
``WRENN_API_KEY`` env var.
|
||||
base_url (str | None): API base URL override.
|
||||
|
||||
Returns:
|
||||
AsyncCapsule: A new capsule instance.
|
||||
"""
|
||||
client = AsyncWrennClient(api_key=api_key, base_url=base_url)
|
||||
info = await client.capsules.create(
|
||||
template=template,
|
||||
vcpus=vcpus,
|
||||
memory_mb=memory_mb,
|
||||
timeout_sec=timeout,
|
||||
)
|
||||
assert info.id is not None
|
||||
capsule = cls(
|
||||
_capsule_id=info.id,
|
||||
_client=client,
|
||||
_info=info,
|
||||
)
|
||||
if wait:
|
||||
await capsule.wait_ready()
|
||||
return capsule
|
||||
|
||||
@classmethod
|
||||
async def connect(
|
||||
cls,
|
||||
capsule_id: str,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> AsyncCapsule:
|
||||
"""Connect to an existing capsule, resuming it if paused.
|
||||
|
||||
Args:
|
||||
capsule_id (str): ID of the capsule to connect to.
|
||||
api_key (str | None): Wrenn API key. Falls back to
|
||||
``WRENN_API_KEY`` env var.
|
||||
base_url (str | None): API base URL override.
|
||||
|
||||
Returns:
|
||||
AsyncCapsule: A capsule instance bound to the existing capsule.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If no capsule with the given ID exists.
|
||||
"""
|
||||
client = AsyncWrennClient(api_key=api_key, base_url=base_url)
|
||||
info = await client.capsules.get(capsule_id)
|
||||
|
||||
capsule = cls(
|
||||
_capsule_id=capsule_id,
|
||||
_client=client,
|
||||
_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 ──────────────────────────
|
||||
|
||||
destroy = _DualMethod("_instance_destroy", "_static_destroy")
|
||||
pause = _DualMethod("_instance_pause", "_static_pause")
|
||||
resume = _DualMethod("_instance_resume", "_static_resume")
|
||||
get_info = _DualMethod("_instance_get_info", "_static_get_info")
|
||||
|
||||
async def _instance_destroy(self, wait: bool = False) -> None:
|
||||
await self._client.capsules.destroy(self._id)
|
||||
if wait:
|
||||
await self._wait_for_status(
|
||||
{Status.stopped, Status.missing}, _DESTROY_INTERVAL
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _static_destroy(
|
||||
cls,
|
||||
capsule_id: str,
|
||||
*,
|
||||
wait: bool = False,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> None:
|
||||
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
||||
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, wait: bool = False) -> CapsuleModel:
|
||||
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
|
||||
|
||||
@classmethod
|
||||
async def _static_pause(
|
||||
cls,
|
||||
capsule_id: str,
|
||||
*,
|
||||
wait: bool = False,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> CapsuleModel:
|
||||
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
||||
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, wait: bool = False) -> CapsuleModel:
|
||||
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
|
||||
|
||||
@classmethod
|
||||
async def _static_resume(
|
||||
cls,
|
||||
capsule_id: str,
|
||||
*,
|
||||
wait: bool = False,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> CapsuleModel:
|
||||
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
||||
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:
|
||||
self._info = await self._client.capsules.get(self._id)
|
||||
return self._info
|
||||
|
||||
@classmethod
|
||||
async def _static_get_info(
|
||||
cls,
|
||||
capsule_id: str,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> CapsuleModel:
|
||||
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
||||
return await client.capsules.get(capsule_id)
|
||||
|
||||
# ── Instance-only methods ───────────────────────────────────
|
||||
|
||||
async def ping(self) -> None:
|
||||
"""Reset the capsule inactivity timer.
|
||||
|
||||
Call this to prevent the capsule from being auto-paused when the
|
||||
inactivity TTL is set.
|
||||
"""
|
||||
await self._client.capsules.ping(self._id)
|
||||
|
||||
async def _wait_for_status(
|
||||
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
|
||||
|
||||
async def wait_ready(self, timeout: float = _DEFAULT_WAIT_TIMEOUT) -> None:
|
||||
"""Await until capsule status is ``running``.
|
||||
|
||||
Raises:
|
||||
TimeoutError: If capsule does not reach ``running`` within ``timeout``.
|
||||
RuntimeError: If capsule enters error/stopped/missing while waiting.
|
||||
"""
|
||||
await self._wait_for_status({Status.running}, _START_INTERVAL, timeout)
|
||||
|
||||
async def is_running(self) -> bool:
|
||||
"""Check whether the capsule is currently running.
|
||||
|
||||
Makes a live API call to fetch current status.
|
||||
|
||||
Returns:
|
||||
bool: ``True`` if the capsule status is ``running``.
|
||||
"""
|
||||
info = await self._instance_get_info()
|
||||
return info.status == Status.running
|
||||
|
||||
# ── Static list ─────────────────────────────────────────────
|
||||
|
||||
@classmethod
|
||||
async def list(
|
||||
cls,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> list[CapsuleModel]:
|
||||
"""List all capsules belonging to the team.
|
||||
|
||||
Args:
|
||||
api_key (str | None): Wrenn API key. Falls back to
|
||||
``WRENN_API_KEY`` env var.
|
||||
base_url (str | None): API base URL override.
|
||||
|
||||
Returns:
|
||||
list[CapsuleModel]: All capsules for the authenticated team.
|
||||
"""
|
||||
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
|
||||
return await client.capsules.list()
|
||||
|
||||
# ── PTY ─────────────────────────────────────────────────────
|
||||
|
||||
@asynccontextmanager
|
||||
async def pty(
|
||||
self,
|
||||
cmd: str = "/bin/bash",
|
||||
args: builtins.list[str] | None = None,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
envs: dict[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
) -> AsyncIterator[AsyncPtySession]:
|
||||
"""Open an async interactive PTY session backed by a WebSocket.
|
||||
|
||||
Use as an async context manager and async iterate over
|
||||
:class:`PtyEvent` objects::
|
||||
|
||||
async with capsule.pty() as term:
|
||||
await term.write(b"echo hello\\n")
|
||||
async for event in term:
|
||||
if event.type == "output":
|
||||
print(event.data.decode())
|
||||
|
||||
Args:
|
||||
cmd (str): Command to run inside the PTY. Defaults to
|
||||
``"/bin/bash"``.
|
||||
args (list[str] | None): Additional arguments for ``cmd``.
|
||||
cols (int): Initial terminal column count. Defaults to ``80``.
|
||||
rows (int): Initial terminal row count. Defaults to ``24``.
|
||||
envs (dict[str, str] | None): Additional environment variables
|
||||
to inject into the process.
|
||||
cwd (str | None): Working directory for the process.
|
||||
|
||||
Yields:
|
||||
AsyncPtySession: An interactive async PTY session.
|
||||
"""
|
||||
async with httpx_ws.aconnect_ws(
|
||||
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
||||
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||
session = AsyncPtySession(ws, self._id)
|
||||
await session._send_start(
|
||||
cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd
|
||||
)
|
||||
yield session
|
||||
|
||||
@asynccontextmanager
|
||||
async def pty_connect(self, tag: str) -> AsyncIterator[AsyncPtySession]:
|
||||
"""Reconnect to an existing PTY session by tag.
|
||||
|
||||
Args:
|
||||
tag (str): Session tag returned in the ``started`` PTY event.
|
||||
|
||||
Yields:
|
||||
AsyncPtySession: The reconnected async PTY session.
|
||||
"""
|
||||
async with httpx_ws.aconnect_ws(
|
||||
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
||||
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||
session = AsyncPtySession(ws, self._id)
|
||||
await session._send_connect(tag)
|
||||
yield session
|
||||
|
||||
# ── Proxy helpers ───────────────────────────────────────────
|
||||
|
||||
def get_url(self, port: int) -> str:
|
||||
"""Get the proxy URL for a port exposed inside this capsule.
|
||||
|
||||
Args:
|
||||
port (int): Port number to proxy.
|
||||
|
||||
Returns:
|
||||
str: A ``wss://`` (or ``ws://``) URL that proxies to the given
|
||||
port inside the capsule.
|
||||
"""
|
||||
return _build_proxy_url(self._client._base_url, self._id, port)
|
||||
|
||||
# ── Snapshots ───────────────────────────────────────────────
|
||||
|
||||
async def create_snapshot(
|
||||
self, name: str | None = None, overwrite: bool = False
|
||||
) -> Template:
|
||||
"""Create a snapshot template from this capsule's current state.
|
||||
|
||||
Args:
|
||||
name (str | None): Name for the snapshot template. Auto-generated
|
||||
if not provided.
|
||||
overwrite (bool): If ``True``, overwrite an existing template with
|
||||
the same name. Defaults to ``False``.
|
||||
|
||||
Returns:
|
||||
Template: The created snapshot template.
|
||||
"""
|
||||
return await self._client.snapshots.create(
|
||||
capsule_id=self._id, name=name, overwrite=overwrite
|
||||
)
|
||||
|
||||
# ── Context manager ─────────────────────────────────────────
|
||||
|
||||
async def __aenter__(self) -> AsyncCapsule:
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: object,
|
||||
) -> None:
|
||||
try:
|
||||
await self._instance_destroy()
|
||||
except Exception as exc:
|
||||
logging.warning("Failed to destroy capsule %s: %s", self._id, exc)
|
||||
try:
|
||||
await self._client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
1639
src/wrenn/capsule.py
1639
src/wrenn/capsule.py
File diff suppressed because it is too large
Load Diff
1105
src/wrenn/client.py
1105
src/wrenn/client.py
File diff suppressed because it is too large
Load Diff
35
src/wrenn/code_interpreter/__init__.py
Normal file
35
src/wrenn/code_interpreter/__init__.py
Normal file
@ -0,0 +1,35 @@
|
||||
from wrenn.code_interpreter.async_capsule import AsyncCapsule
|
||||
from wrenn.code_interpreter.capsule import Capsule
|
||||
from wrenn.code_interpreter.models import (
|
||||
Execution,
|
||||
ExecutionError,
|
||||
Logs,
|
||||
Result,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AsyncCapsule",
|
||||
"Capsule",
|
||||
"Execution",
|
||||
"ExecutionError",
|
||||
"Logs",
|
||||
"Result",
|
||||
"Sandbox",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> type:
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
_module = sys.modules[__name__]
|
||||
|
||||
if name == "Sandbox":
|
||||
warnings.warn(
|
||||
"'Sandbox' is deprecated, use 'Capsule' instead",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
setattr(_module, name, Capsule)
|
||||
return Capsule
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
292
src/wrenn/code_interpreter/async_capsule.py
Normal file
292
src/wrenn/code_interpreter/async_capsule.py
Normal file
@ -0,0 +1,292 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import httpx_ws
|
||||
|
||||
from wrenn.async_capsule import AsyncCapsule as BaseAsyncCapsule
|
||||
from wrenn.capsule import _build_proxy_url
|
||||
from wrenn.client import AsyncWrennClient
|
||||
from wrenn.code_interpreter.capsule import DEFAULT_TEMPLATE
|
||||
from wrenn.code_interpreter.models import (
|
||||
Execution,
|
||||
ExecutionError,
|
||||
Result,
|
||||
)
|
||||
|
||||
|
||||
class AsyncCapsule(BaseAsyncCapsule):
|
||||
"""Async code interpreter capsule with ``run_code`` support.
|
||||
|
||||
Uses ``code-runner-beta`` template by default::
|
||||
|
||||
from wrenn.code_interpreter import AsyncCapsule
|
||||
|
||||
capsule = await AsyncCapsule.create()
|
||||
result = await capsule.run_code("print('hello')")
|
||||
"""
|
||||
|
||||
_kernel_id: str | None
|
||||
_proxy_client: httpx.AsyncClient | None
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._kernel_id = None
|
||||
self._proxy_client = None
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._proxy_client is not None:
|
||||
try:
|
||||
await self._proxy_client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
self._proxy_client = None
|
||||
|
||||
def __del__(self) -> None:
|
||||
if self._proxy_client is not None:
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
loop.create_task(self._proxy_client.aclose())
|
||||
else:
|
||||
loop.run_until_complete(self._proxy_client.aclose())
|
||||
except Exception:
|
||||
pass
|
||||
self._proxy_client = None
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
template: str | None = None,
|
||||
vcpus: int | None = None,
|
||||
memory_mb: int | None = None,
|
||||
timeout: int | None = None,
|
||||
*,
|
||||
wait: bool = False,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> AsyncCapsule:
|
||||
"""Create a new async code interpreter capsule.
|
||||
|
||||
Args:
|
||||
template (str | None): Template to boot from. Defaults to
|
||||
``"code-runner-beta"``.
|
||||
vcpus (int | None): Number of virtual CPUs.
|
||||
memory_mb (int | None): Memory in MiB.
|
||||
timeout (int | None): Inactivity TTL in seconds before auto-pause.
|
||||
wait (bool): Await until the capsule reaches ``running`` status.
|
||||
api_key (str | None): Wrenn API key. Falls back to
|
||||
``WRENN_API_KEY`` env var.
|
||||
base_url (str | None): API base URL override.
|
||||
|
||||
Returns:
|
||||
AsyncCapsule: A new async code interpreter capsule instance.
|
||||
"""
|
||||
client = AsyncWrennClient(api_key=api_key, base_url=base_url)
|
||||
info = await client.capsules.create(
|
||||
template=template or DEFAULT_TEMPLATE,
|
||||
vcpus=vcpus,
|
||||
memory_mb=memory_mb,
|
||||
timeout_sec=timeout,
|
||||
)
|
||||
capsule = cls(
|
||||
_capsule_id=info.id,
|
||||
_client=client,
|
||||
_info=info,
|
||||
)
|
||||
if wait:
|
||||
await capsule.wait_ready()
|
||||
return capsule
|
||||
|
||||
def _get_proxy_client(self) -> httpx.AsyncClient:
|
||||
if self._proxy_client is None:
|
||||
url = (
|
||||
_build_proxy_url(self._client._base_url, self._id, 8888)
|
||||
.replace("ws://", "http://")
|
||||
.replace("wss://", "https://")
|
||||
)
|
||||
self._proxy_client = httpx.AsyncClient(
|
||||
base_url=url,
|
||||
headers={"X-API-Key": self._client._api_key},
|
||||
)
|
||||
return self._proxy_client
|
||||
|
||||
async def _ensure_kernel(self, jupyter_timeout: float = 30) -> str:
|
||||
if self._kernel_id is not None:
|
||||
return self._kernel_id
|
||||
|
||||
client = self._get_proxy_client()
|
||||
deadline = time.monotonic() + jupyter_timeout
|
||||
last_exc: Exception | None = None
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
# Try to reuse an existing kernel
|
||||
resp = await client.get("/api/kernels")
|
||||
if resp.status_code < 500:
|
||||
resp.raise_for_status()
|
||||
kernels = resp.json()
|
||||
if kernels:
|
||||
self._kernel_id = kernels[0]["id"]
|
||||
return self._kernel_id
|
||||
# No existing kernels, create a new one
|
||||
resp = await client.post("/api/kernels")
|
||||
if resp.status_code < 500:
|
||||
resp.raise_for_status()
|
||||
self._kernel_id = resp.json()["id"]
|
||||
return self._kernel_id
|
||||
last_exc = httpx.HTTPStatusError(
|
||||
f"Jupyter returned {resp.status_code}",
|
||||
request=resp.request,
|
||||
response=resp,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code < 500:
|
||||
raise
|
||||
last_exc = exc
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
raise TimeoutError(
|
||||
f"Jupyter not available within {jupyter_timeout}s: {last_exc}"
|
||||
)
|
||||
|
||||
def _jupyter_ws_url(self, kernel_id: str) -> str:
|
||||
proxy = _build_proxy_url(self._client._base_url, self._id, 8888)
|
||||
return f"{proxy}/api/kernels/{kernel_id}/channels"
|
||||
|
||||
@staticmethod
|
||||
def _jupyter_execute_request(code: str) -> dict:
|
||||
msg_id = str(uuid.uuid4())
|
||||
return {
|
||||
"header": {
|
||||
"msg_id": msg_id,
|
||||
"msg_type": "execute_request",
|
||||
"username": "wrenn-sdk",
|
||||
"session": str(uuid.uuid4()),
|
||||
"date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()),
|
||||
"version": "5.3",
|
||||
},
|
||||
"parent_header": {},
|
||||
"metadata": {},
|
||||
"content": {
|
||||
"code": code,
|
||||
"silent": False,
|
||||
"store_history": True,
|
||||
"user_expressions": {},
|
||||
"allow_stdin": False,
|
||||
"stop_on_error": True,
|
||||
},
|
||||
"buffers": [],
|
||||
"channel": "shell",
|
||||
}
|
||||
|
||||
async def run_code(
|
||||
self,
|
||||
code: str,
|
||||
language: str = "python",
|
||||
timeout: float = 30,
|
||||
jupyter_timeout: float = 30,
|
||||
on_result: Callable[[Result], Any] | None = None,
|
||||
on_stdout: Callable[[str], Any] | None = None,
|
||||
on_stderr: Callable[[str], Any] | None = None,
|
||||
on_error: Callable[[ExecutionError], Any] | None = None,
|
||||
) -> Execution:
|
||||
"""Execute code in a persistent Jupyter kernel (async).
|
||||
|
||||
Args:
|
||||
code: Code string to execute.
|
||||
language: Execution backend language. Currently only ``"python"``.
|
||||
timeout: Maximum seconds to wait for execution to complete.
|
||||
jupyter_timeout: Maximum seconds to wait for Jupyter to become
|
||||
available.
|
||||
on_result: Called for each rich output (charts, images, expression
|
||||
values).
|
||||
on_stdout: Called for each stdout chunk.
|
||||
on_stderr: Called for each stderr chunk.
|
||||
on_error: Called when the cell raises an exception.
|
||||
|
||||
Returns:
|
||||
An :class:`Execution` with ``.results``, ``.logs``, ``.error``,
|
||||
and a convenience ``.text`` property.
|
||||
"""
|
||||
kernel_id = await self._ensure_kernel(jupyter_timeout=jupyter_timeout)
|
||||
ws_url = self._jupyter_ws_url(kernel_id)
|
||||
|
||||
msg = self._jupyter_execute_request(code)
|
||||
msg_id = msg["header"]["msg_id"]
|
||||
|
||||
execution = Execution()
|
||||
deadline = time.monotonic() + timeout
|
||||
headers = {"X-API-Key": self._client._api_key}
|
||||
|
||||
async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||
await ws.send_text(json.dumps(msg))
|
||||
while time.monotonic() < deadline:
|
||||
time_left = deadline - time.monotonic()
|
||||
if time_left <= 0:
|
||||
break
|
||||
try:
|
||||
data = await asyncio.wait_for(ws.receive_json(), timeout=time_left)
|
||||
except Exception:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
parent = data.get("parent_header", {}).get("msg_id")
|
||||
if parent != msg_id:
|
||||
continue
|
||||
msg_type = data.get("msg_type") or data.get("header", {}).get(
|
||||
"msg_type"
|
||||
)
|
||||
content = data.get("content", {})
|
||||
|
||||
if msg_type == "stream":
|
||||
text = content.get("text", "")
|
||||
name = content.get("name", "stdout")
|
||||
if name == "stderr":
|
||||
execution.logs.stderr.append(text)
|
||||
if on_stderr is not None:
|
||||
on_stderr(text)
|
||||
else:
|
||||
execution.logs.stdout.append(text)
|
||||
if on_stdout is not None:
|
||||
on_stdout(text)
|
||||
elif msg_type in ("execute_result", "display_data"):
|
||||
bundle = content.get("data", {})
|
||||
is_main = msg_type == "execute_result"
|
||||
result = Result.from_bundle(bundle, is_main_result=is_main)
|
||||
execution.results.append(result)
|
||||
if is_main:
|
||||
execution.execution_count = content.get("execution_count")
|
||||
if on_result is not None:
|
||||
on_result(result)
|
||||
elif msg_type == "error":
|
||||
err = ExecutionError(
|
||||
name=content.get("ename", ""),
|
||||
value=content.get("evalue", ""),
|
||||
traceback="\n".join(content.get("traceback", [])),
|
||||
)
|
||||
execution.error = err
|
||||
if on_error is not None:
|
||||
on_error(err)
|
||||
elif msg_type == "status" and content.get("execution_state") == "idle":
|
||||
break
|
||||
|
||||
return execution
|
||||
|
||||
async def __aexit__(self, *args) -> None:
|
||||
if self._proxy_client is not None:
|
||||
try:
|
||||
await self._proxy_client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
await super().__aexit__(*args)
|
||||
307
src/wrenn/code_interpreter/capsule.py
Normal file
307
src/wrenn/code_interpreter/capsule.py
Normal file
@ -0,0 +1,307 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import httpx_ws
|
||||
|
||||
from wrenn.capsule import Capsule as BaseCapsule
|
||||
from wrenn.capsule import _build_proxy_url
|
||||
from wrenn.code_interpreter.models import (
|
||||
Execution,
|
||||
ExecutionError,
|
||||
Result,
|
||||
)
|
||||
|
||||
DEFAULT_TEMPLATE = "code-runner-beta"
|
||||
|
||||
|
||||
class Capsule(BaseCapsule):
|
||||
"""Code interpreter capsule with ``run_code`` support.
|
||||
|
||||
Uses ``code-runner-beta`` template by default::
|
||||
|
||||
from wrenn.code_interpreter import Capsule
|
||||
|
||||
capsule = Capsule()
|
||||
result = capsule.run_code("print('hello')")
|
||||
print(result.logs.stdout) # ["hello\\n"]
|
||||
"""
|
||||
|
||||
_kernel_id: str | None
|
||||
_proxy_client: httpx.Client | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
template: str | None = None,
|
||||
vcpus: int | None = None,
|
||||
memory_mb: int | None = None,
|
||||
timeout: int | None = None,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Create a code interpreter capsule.
|
||||
|
||||
Args:
|
||||
template (str | None): Template to boot from. Defaults to
|
||||
``"code-runner-beta"``.
|
||||
vcpus (int | None): Number of virtual CPUs.
|
||||
memory_mb (int | None): Memory in MiB.
|
||||
timeout (int | None): Inactivity TTL in seconds before auto-pause.
|
||||
api_key (str | None): Wrenn API key. Falls back to
|
||||
``WRENN_API_KEY`` env var.
|
||||
base_url (str | None): API base URL override.
|
||||
"""
|
||||
super().__init__(
|
||||
template=template or DEFAULT_TEMPLATE,
|
||||
vcpus=vcpus,
|
||||
memory_mb=memory_mb,
|
||||
timeout=timeout,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
**kwargs,
|
||||
)
|
||||
self._kernel_id = None
|
||||
self._proxy_client = None
|
||||
|
||||
def close(self) -> None:
|
||||
if self._proxy_client is not None:
|
||||
try:
|
||||
self._proxy_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._proxy_client = None
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.close()
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
template: str | None = None,
|
||||
vcpus: int | None = None,
|
||||
memory_mb: int | None = None,
|
||||
timeout: int | None = None,
|
||||
*,
|
||||
wait: bool = False,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> Capsule:
|
||||
"""Create a new code interpreter capsule.
|
||||
|
||||
Args:
|
||||
template (str | None): Template to boot from. Defaults to
|
||||
``"code-runner-beta"``.
|
||||
vcpus (int | None): Number of virtual CPUs.
|
||||
memory_mb (int | None): Memory in MiB.
|
||||
timeout (int | None): Inactivity TTL in seconds before auto-pause.
|
||||
wait (bool): Block until the capsule reaches ``running`` status.
|
||||
api_key (str | None): Wrenn API key. Falls back to
|
||||
``WRENN_API_KEY`` env var.
|
||||
base_url (str | None): API base URL override.
|
||||
|
||||
Returns:
|
||||
Capsule: A new code interpreter capsule instance.
|
||||
"""
|
||||
return cls(
|
||||
template=template or DEFAULT_TEMPLATE,
|
||||
vcpus=vcpus,
|
||||
memory_mb=memory_mb,
|
||||
timeout=timeout,
|
||||
wait=wait,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
def _get_proxy_client(self) -> httpx.Client:
|
||||
if self._proxy_client is None:
|
||||
url = (
|
||||
_build_proxy_url(self._client._base_url, self._id, 8888)
|
||||
.replace("ws://", "http://")
|
||||
.replace("wss://", "https://")
|
||||
)
|
||||
self._proxy_client = httpx.Client(
|
||||
base_url=url,
|
||||
headers={"X-API-Key": self._client._api_key},
|
||||
)
|
||||
return self._proxy_client
|
||||
|
||||
def _ensure_kernel(self, jupyter_timeout: float = 30) -> str:
|
||||
if self._kernel_id is not None:
|
||||
return self._kernel_id
|
||||
|
||||
client = self._get_proxy_client()
|
||||
deadline = time.monotonic() + jupyter_timeout
|
||||
last_exc: Exception | None = None
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
# Try to reuse an existing kernel
|
||||
resp = client.get("/api/kernels")
|
||||
if resp.status_code < 500:
|
||||
resp.raise_for_status()
|
||||
kernels = resp.json()
|
||||
if kernels:
|
||||
self._kernel_id = kernels[0]["id"]
|
||||
return self._kernel_id
|
||||
# No existing kernels, create a new one
|
||||
resp = client.post("/api/kernels")
|
||||
if resp.status_code < 500:
|
||||
resp.raise_for_status()
|
||||
self._kernel_id = resp.json()["id"]
|
||||
return self._kernel_id
|
||||
last_exc = httpx.HTTPStatusError(
|
||||
f"Jupyter returned {resp.status_code}",
|
||||
request=resp.request,
|
||||
response=resp,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code < 500:
|
||||
raise
|
||||
last_exc = exc
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
time.sleep(0.5)
|
||||
|
||||
raise TimeoutError(
|
||||
f"Jupyter not available within {jupyter_timeout}s: {last_exc}"
|
||||
)
|
||||
|
||||
def _jupyter_ws_url(self, kernel_id: str) -> str:
|
||||
proxy = _build_proxy_url(self._client._base_url, self._id, 8888)
|
||||
return f"{proxy}/api/kernels/{kernel_id}/channels"
|
||||
|
||||
@staticmethod
|
||||
def _jupyter_execute_request(code: str) -> dict:
|
||||
msg_id = str(uuid.uuid4())
|
||||
return {
|
||||
"header": {
|
||||
"msg_id": msg_id,
|
||||
"msg_type": "execute_request",
|
||||
"username": "wrenn-sdk",
|
||||
"session": str(uuid.uuid4()),
|
||||
"date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()),
|
||||
"version": "5.3",
|
||||
},
|
||||
"parent_header": {},
|
||||
"metadata": {},
|
||||
"content": {
|
||||
"code": code,
|
||||
"silent": False,
|
||||
"store_history": True,
|
||||
"user_expressions": {},
|
||||
"allow_stdin": False,
|
||||
"stop_on_error": True,
|
||||
},
|
||||
"buffers": [],
|
||||
"channel": "shell",
|
||||
}
|
||||
|
||||
def run_code(
|
||||
self,
|
||||
code: str,
|
||||
language: str = "python",
|
||||
timeout: float = 30,
|
||||
jupyter_timeout: float = 30,
|
||||
on_result: Callable[[Result], Any] | None = None,
|
||||
on_stdout: Callable[[str], Any] | None = None,
|
||||
on_stderr: Callable[[str], Any] | None = None,
|
||||
on_error: Callable[[ExecutionError], Any] | None = None,
|
||||
) -> Execution:
|
||||
"""Execute code in a persistent Jupyter kernel.
|
||||
|
||||
Variables, imports, and function definitions survive across calls.
|
||||
|
||||
Args:
|
||||
code: Code string to execute.
|
||||
language: Execution backend language. Currently only ``"python"``.
|
||||
timeout: Maximum seconds to wait for execution to complete.
|
||||
jupyter_timeout: Maximum seconds to wait for Jupyter to become
|
||||
available.
|
||||
on_result: Called for each rich output (charts, images, expression
|
||||
values).
|
||||
on_stdout: Called for each stdout chunk.
|
||||
on_stderr: Called for each stderr chunk.
|
||||
on_error: Called when the cell raises an exception.
|
||||
|
||||
Returns:
|
||||
An :class:`Execution` with ``.results``, ``.logs``, ``.error``,
|
||||
and a convenience ``.text`` property.
|
||||
"""
|
||||
kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout)
|
||||
ws_url = self._jupyter_ws_url(kernel_id)
|
||||
|
||||
msg = self._jupyter_execute_request(code)
|
||||
msg_id = msg["header"]["msg_id"]
|
||||
|
||||
execution = Execution()
|
||||
deadline = time.monotonic() + timeout
|
||||
headers = {"X-API-Key": self._client._api_key}
|
||||
|
||||
with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.WebSocketSession
|
||||
ws.send_text(json.dumps(msg))
|
||||
while time.monotonic() < deadline:
|
||||
time_left = deadline - time.monotonic()
|
||||
if time_left <= 0:
|
||||
break
|
||||
try:
|
||||
data = ws.receive_json(timeout=time_left)
|
||||
except Exception:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
parent = data.get("parent_header", {}).get("msg_id")
|
||||
if parent != msg_id:
|
||||
continue
|
||||
msg_type = data.get("msg_type") or data.get("header", {}).get(
|
||||
"msg_type"
|
||||
)
|
||||
content = data.get("content", {})
|
||||
|
||||
if msg_type == "stream":
|
||||
text = content.get("text", "")
|
||||
name = content.get("name", "stdout")
|
||||
if name == "stderr":
|
||||
execution.logs.stderr.append(text)
|
||||
if on_stderr is not None:
|
||||
on_stderr(text)
|
||||
else:
|
||||
execution.logs.stdout.append(text)
|
||||
if on_stdout is not None:
|
||||
on_stdout(text)
|
||||
elif msg_type in ("execute_result", "display_data"):
|
||||
bundle = content.get("data", {})
|
||||
is_main = msg_type == "execute_result"
|
||||
result = Result.from_bundle(bundle, is_main_result=is_main)
|
||||
execution.results.append(result)
|
||||
if is_main:
|
||||
execution.execution_count = content.get("execution_count")
|
||||
if on_result is not None:
|
||||
on_result(result)
|
||||
elif msg_type == "error":
|
||||
err = ExecutionError(
|
||||
name=content.get("ename", ""),
|
||||
value=content.get("evalue", ""),
|
||||
traceback="\n".join(content.get("traceback", [])),
|
||||
)
|
||||
execution.error = err
|
||||
if on_error is not None:
|
||||
on_error(err)
|
||||
elif msg_type == "status" and content.get("execution_state") == "idle":
|
||||
break
|
||||
|
||||
return execution
|
||||
|
||||
def __exit__(self, *args) -> None:
|
||||
if self._proxy_client is not None:
|
||||
try:
|
||||
self._proxy_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
super().__exit__(*args)
|
||||
156
src/wrenn/code_interpreter/models.py
Normal file
156
src/wrenn/code_interpreter/models.py
Normal file
@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
_MIME_MAP: dict[str, str] = {
|
||||
"text/plain": "text",
|
||||
"text/html": "html",
|
||||
"text/markdown": "markdown",
|
||||
"image/svg+xml": "svg",
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpeg",
|
||||
"application/pdf": "pdf",
|
||||
"text/latex": "latex",
|
||||
"application/json": "json",
|
||||
"application/javascript": "javascript",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionError:
|
||||
"""Error raised during code execution.
|
||||
|
||||
Attributes:
|
||||
name: Exception class name (e.g. ``"NameError"``).
|
||||
value: Exception message.
|
||||
traceback: Full traceback string.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
value: str = ""
|
||||
traceback: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Logs:
|
||||
"""Captured stdout/stderr streams.
|
||||
|
||||
Each element in the list is one chunk of text as it arrived from
|
||||
the kernel.
|
||||
"""
|
||||
|
||||
stdout: list[str] = field(default_factory=list)
|
||||
stderr: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
"""A single rich output from code execution.
|
||||
|
||||
Jupyter cells can produce multiple outputs — one ``execute_result``
|
||||
(the expression value) and zero or more ``display_data`` messages
|
||||
(from ``plt.show()``, ``display()``, etc.). Each becomes a
|
||||
``Result``.
|
||||
|
||||
Known MIME types are unpacked into named attributes; anything else
|
||||
lands in :pyattr:`extra`.
|
||||
"""
|
||||
|
||||
# --- MIME type fields ---
|
||||
text: str | None = None
|
||||
"""``text/plain`` representation."""
|
||||
html: str | None = None
|
||||
"""``text/html`` representation."""
|
||||
markdown: str | None = None
|
||||
"""``text/markdown`` representation."""
|
||||
svg: str | None = None
|
||||
"""``image/svg+xml`` representation."""
|
||||
png: str | None = None
|
||||
"""``image/png`` — base64-encoded."""
|
||||
jpeg: str | None = None
|
||||
"""``image/jpeg`` — base64-encoded."""
|
||||
pdf: str | None = None
|
||||
"""``application/pdf`` — base64-encoded."""
|
||||
latex: str | None = None
|
||||
"""``text/latex`` representation."""
|
||||
json: dict | None = None
|
||||
"""``application/json`` representation."""
|
||||
javascript: str | None = None
|
||||
"""``application/javascript`` representation."""
|
||||
extra: dict[str, str] | None = None
|
||||
"""MIME types not covered by the named fields above."""
|
||||
|
||||
is_main_result: bool = False
|
||||
"""``True`` when this came from an ``execute_result`` message
|
||||
(i.e. the value of the last expression in the cell). ``False``
|
||||
for ``display_data`` outputs."""
|
||||
|
||||
@classmethod
|
||||
def from_bundle(
|
||||
cls, bundle: dict[str, str], *, is_main_result: bool = False
|
||||
) -> Result:
|
||||
"""Build a ``Result`` from a Jupyter MIME bundle dict."""
|
||||
kwargs: dict = {"is_main_result": is_main_result}
|
||||
extra: dict[str, str] = {}
|
||||
for mime, value in bundle.items():
|
||||
attr = _MIME_MAP.get(mime)
|
||||
if attr is not None:
|
||||
kwargs[attr] = value
|
||||
else:
|
||||
extra[mime] = value
|
||||
if extra:
|
||||
kwargs["extra"] = extra
|
||||
# Strip surrounding quotes from text/plain (Jupyter repr artefact)
|
||||
text = kwargs.get("text")
|
||||
if isinstance(text, str) and len(text) >= 2:
|
||||
if (text[0] == text[-1]) and text[0] in ("'", '"'):
|
||||
kwargs["text"] = text[1:-1]
|
||||
return cls(**kwargs)
|
||||
|
||||
def formats(self) -> list[str]:
|
||||
"""Return names of non-``None`` MIME-type fields."""
|
||||
out: list[str] = []
|
||||
for attr in (
|
||||
"text",
|
||||
"html",
|
||||
"markdown",
|
||||
"svg",
|
||||
"png",
|
||||
"jpeg",
|
||||
"pdf",
|
||||
"latex",
|
||||
"json",
|
||||
"javascript",
|
||||
):
|
||||
if getattr(self, attr) is not None:
|
||||
out.append(attr)
|
||||
if self.extra:
|
||||
out.extend(self.extra)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class Execution:
|
||||
"""Complete result of a ``run_code`` call.
|
||||
|
||||
Attributes:
|
||||
results: All rich outputs produced by the cell — charts, tables,
|
||||
images, expression values, etc.
|
||||
logs: Captured stdout/stderr text.
|
||||
error: Populated when the cell raised an exception.
|
||||
execution_count: Jupyter execution counter (the ``[N]`` number).
|
||||
"""
|
||||
|
||||
results: list[Result] = field(default_factory=list)
|
||||
logs: Logs = field(default_factory=Logs)
|
||||
error: ExecutionError | None = None
|
||||
execution_count: int | None = None
|
||||
|
||||
@property
|
||||
def text(self) -> str | None:
|
||||
"""Convenience — ``text/plain`` of the main ``execute_result``,
|
||||
or ``None`` if the cell had no expression value."""
|
||||
for r in self.results:
|
||||
if r.is_main_result:
|
||||
return r.text
|
||||
return None
|
||||
506
src/wrenn/commands.py
Normal file
506
src/wrenn/commands.py
Normal file
@ -0,0 +1,506 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import builtins
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, overload
|
||||
|
||||
import httpx
|
||||
import httpx_ws
|
||||
|
||||
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
|
||||
class CommandResult:
|
||||
"""Result from a foreground command execution."""
|
||||
|
||||
stdout: str
|
||||
stderr: str
|
||||
exit_code: int
|
||||
duration_ms: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandHandle:
|
||||
"""Handle for a background process."""
|
||||
|
||||
pid: int
|
||||
tag: str
|
||||
capsule_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessInfo:
|
||||
"""Information about a running process."""
|
||||
|
||||
pid: int
|
||||
tag: str | None = None
|
||||
cmd: str | None = None
|
||||
args: list[str] | None = None
|
||||
|
||||
|
||||
class StreamEvent:
|
||||
"""Base class for streaming exec events."""
|
||||
|
||||
__slots__ = ("type",)
|
||||
|
||||
def __init__(self, type: str) -> None:
|
||||
self.type = type
|
||||
|
||||
|
||||
class StreamStartEvent(StreamEvent):
|
||||
__slots__ = ("pid",)
|
||||
|
||||
def __init__(self, pid: int) -> None:
|
||||
super().__init__("start")
|
||||
self.pid = pid
|
||||
|
||||
|
||||
class StreamStdoutEvent(StreamEvent):
|
||||
__slots__ = ("data",)
|
||||
|
||||
def __init__(self, data: str) -> None:
|
||||
super().__init__("stdout")
|
||||
self.data = data
|
||||
|
||||
|
||||
class StreamStderrEvent(StreamEvent):
|
||||
__slots__ = ("data",)
|
||||
|
||||
def __init__(self, data: str) -> None:
|
||||
super().__init__("stderr")
|
||||
self.data = data
|
||||
|
||||
|
||||
class StreamExitEvent(StreamEvent):
|
||||
__slots__ = ("exit_code",)
|
||||
|
||||
def __init__(self, exit_code: int) -> None:
|
||||
super().__init__("exit")
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
class StreamErrorEvent(StreamEvent):
|
||||
__slots__ = ("data",)
|
||||
|
||||
def __init__(self, data: str) -> None:
|
||||
super().__init__("error")
|
||||
self.data = data
|
||||
|
||||
|
||||
def _parse_stream_event(raw: dict) -> StreamEvent:
|
||||
t = raw.get("type")
|
||||
if t == "start":
|
||||
return StreamStartEvent(pid=raw.get("pid", 0))
|
||||
if t == "stdout":
|
||||
return StreamStdoutEvent(data=raw.get("data", ""))
|
||||
if t == "stderr":
|
||||
return StreamStderrEvent(data=raw.get("data", ""))
|
||||
if t == "exit":
|
||||
return StreamExitEvent(exit_code=raw.get("exit_code", -1))
|
||||
if t == "error":
|
||||
return StreamErrorEvent(data=raw.get("data", ""))
|
||||
return StreamEvent(type=t or "unknown")
|
||||
|
||||
|
||||
def _decode_exec_response(data: dict) -> CommandResult:
|
||||
stdout = data.get("stdout") or ""
|
||||
stderr = data.get("stderr") or ""
|
||||
if data.get("encoding") == "base64":
|
||||
stdout = base64.b64decode(stdout).decode("utf-8", errors="replace")
|
||||
if stderr:
|
||||
stderr = base64.b64decode(stderr).decode("utf-8", errors="replace")
|
||||
return CommandResult(
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
exit_code=data.get("exit_code", -1),
|
||||
duration_ms=data.get("duration_ms"),
|
||||
)
|
||||
|
||||
|
||||
class Commands:
|
||||
"""Sync command execution interface. Accessed via ``capsule.commands``."""
|
||||
|
||||
def __init__(self, capsule_id: str, http: httpx.Client) -> None:
|
||||
self._capsule_id = capsule_id
|
||||
self._http = http
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
cmd: str,
|
||||
*,
|
||||
background: Literal[False] = ...,
|
||||
timeout: int | None = 30,
|
||||
envs: dict[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
tag: str | None = None,
|
||||
) -> CommandResult: ...
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
cmd: str,
|
||||
*,
|
||||
background: Literal[True],
|
||||
timeout: int | None = 30,
|
||||
envs: dict[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
tag: str | None = None,
|
||||
) -> CommandHandle: ...
|
||||
|
||||
def run(
|
||||
self,
|
||||
cmd: str,
|
||||
*,
|
||||
background: bool = False,
|
||||
timeout: int | None = 30,
|
||||
envs: dict[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
tag: str | None = None,
|
||||
) -> CommandResult | CommandHandle:
|
||||
"""Execute a shell command inside the capsule.
|
||||
|
||||
Args:
|
||||
cmd (str): Shell command string to execute.
|
||||
background (bool): If ``True``, launch the process in the
|
||||
background and return a :class:`CommandHandle` immediately.
|
||||
Defaults to ``False``.
|
||||
timeout (int | None): Seconds before the foreground command times
|
||||
out. Ignored for background commands. Defaults to ``30``.
|
||||
envs (dict[str, str] | None): Additional environment variables
|
||||
to set for the process.
|
||||
cwd (str | None): Working directory for the process.
|
||||
tag (str | None): Optional label attached to background processes
|
||||
for later retrieval via :meth:`connect`.
|
||||
|
||||
Returns:
|
||||
CommandResult: stdout, stderr, exit code, and duration for
|
||||
foreground commands (``background=False``).
|
||||
|
||||
CommandHandle: PID and tag for background commands
|
||||
(``background=True``).
|
||||
"""
|
||||
payload: dict = {
|
||||
"cmd": "/bin/sh",
|
||||
"args": ["-c", cmd],
|
||||
"background": background,
|
||||
}
|
||||
if timeout is not None and not background:
|
||||
payload["timeout_sec"] = timeout
|
||||
if envs is not None:
|
||||
payload["envs"] = envs
|
||||
if cwd is not None:
|
||||
payload["cwd"] = cwd
|
||||
if tag is not None:
|
||||
payload["tag"] = tag
|
||||
|
||||
http_timeout: httpx.Timeout | None = None
|
||||
if not background and timeout is not None:
|
||||
http_timeout = httpx.Timeout(timeout + 10, connect=5.0)
|
||||
|
||||
resp = self._http.post(
|
||||
f"/v1/capsules/{self._capsule_id}/exec",
|
||||
json=payload,
|
||||
timeout=http_timeout,
|
||||
)
|
||||
data = handle_response(resp)
|
||||
assert isinstance(data, dict)
|
||||
|
||||
if background:
|
||||
return CommandHandle(
|
||||
pid=data.get("pid", 0),
|
||||
tag=data.get("tag", ""),
|
||||
capsule_id=self._capsule_id,
|
||||
)
|
||||
return _decode_exec_response(data)
|
||||
|
||||
def list(self) -> list[ProcessInfo]:
|
||||
"""List all running background processes in the capsule.
|
||||
|
||||
Returns:
|
||||
list[ProcessInfo]: Running processes with their PID, tag, and
|
||||
command information.
|
||||
"""
|
||||
resp = self._http.get(f"/v1/capsules/{self._capsule_id}/processes")
|
||||
data = handle_response(resp)
|
||||
assert isinstance(data, dict)
|
||||
return [
|
||||
ProcessInfo(
|
||||
pid=p.get("pid", 0),
|
||||
tag=p.get("tag"),
|
||||
cmd=p.get("cmd"),
|
||||
args=p.get("args"),
|
||||
)
|
||||
for p in data.get("processes", [])
|
||||
]
|
||||
|
||||
def kill(self, pid: int) -> None:
|
||||
"""Send SIGKILL to a background process.
|
||||
|
||||
Args:
|
||||
pid (int): PID of the process to kill.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If no process with the given PID exists.
|
||||
"""
|
||||
resp = self._http.delete(f"/v1/capsules/{self._capsule_id}/processes/{pid}")
|
||||
handle_response(resp)
|
||||
|
||||
def connect(self, pid: int) -> Iterator[StreamEvent]:
|
||||
"""Connect to a running background process and stream its output.
|
||||
|
||||
Args:
|
||||
pid (int): PID of the background process to attach to.
|
||||
|
||||
Yields:
|
||||
StreamEvent: Successive output events. Stops on
|
||||
:class:`StreamExitEvent` or :class:`StreamErrorEvent`.
|
||||
"""
|
||||
with httpx_ws.connect_ws(
|
||||
f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream",
|
||||
self._http,
|
||||
) as ws: # type: httpx_ws.WebSocketSession
|
||||
while True:
|
||||
try:
|
||||
raw = ws.receive_json()
|
||||
event = _parse_stream_event(raw)
|
||||
yield event
|
||||
if event.type in ("exit", "error"):
|
||||
break
|
||||
except _WS_CLOSED:
|
||||
break
|
||||
|
||||
def stream(
|
||||
self, cmd: str, args: builtins.list[str] | None = None
|
||||
) -> Iterator[StreamEvent]:
|
||||
"""Execute a command via WebSocket, streaming output as events.
|
||||
|
||||
Args:
|
||||
cmd (str): Command to execute.
|
||||
args (list[str] | None): Additional arguments for the command.
|
||||
When omitted, *cmd* is interpreted as a shell command
|
||||
string and executed via ``/bin/sh -c``.
|
||||
|
||||
Yields:
|
||||
StreamEvent: Successive events including :class:`StreamStartEvent`,
|
||||
:class:`StreamStdoutEvent`, :class:`StreamStderrEvent`,
|
||||
:class:`StreamExitEvent`, and :class:`StreamErrorEvent`.
|
||||
"""
|
||||
with httpx_ws.connect_ws(
|
||||
f"/v1/capsules/{self._capsule_id}/exec/stream",
|
||||
self._http,
|
||||
) as ws: # type: httpx_ws.WebSocketSession
|
||||
if args:
|
||||
start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
|
||||
else:
|
||||
start_msg = {"type": "start", "cmd": "/bin/sh", "args": ["-c", cmd]}
|
||||
ws.send_text(json.dumps(start_msg))
|
||||
while True:
|
||||
try:
|
||||
raw = ws.receive_json()
|
||||
event = _parse_stream_event(raw)
|
||||
yield event
|
||||
if event.type in ("exit", "error"):
|
||||
break
|
||||
except _WS_CLOSED:
|
||||
break
|
||||
|
||||
|
||||
class AsyncCommands:
|
||||
"""Async command execution interface. Accessed via ``capsule.commands``."""
|
||||
|
||||
def __init__(self, capsule_id: str, http: httpx.AsyncClient) -> None:
|
||||
self._capsule_id = capsule_id
|
||||
self._http = http
|
||||
|
||||
@overload
|
||||
async def run(
|
||||
self,
|
||||
cmd: str,
|
||||
*,
|
||||
background: Literal[False] = ...,
|
||||
timeout: int | None = 30,
|
||||
envs: dict[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
tag: str | None = None,
|
||||
) -> CommandResult: ...
|
||||
|
||||
@overload
|
||||
async def run(
|
||||
self,
|
||||
cmd: str,
|
||||
*,
|
||||
background: Literal[True],
|
||||
timeout: int | None = 30,
|
||||
envs: dict[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
tag: str | None = None,
|
||||
) -> CommandHandle: ...
|
||||
|
||||
async def run(
|
||||
self,
|
||||
cmd: str,
|
||||
*,
|
||||
background: bool = False,
|
||||
timeout: int | None = 30,
|
||||
envs: dict[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
tag: str | None = None,
|
||||
) -> CommandResult | CommandHandle:
|
||||
"""Execute a shell command inside the capsule.
|
||||
|
||||
Args:
|
||||
cmd (str): Shell command string to execute.
|
||||
background (bool): If ``True``, launch the process in the
|
||||
background and return a :class:`CommandHandle` immediately.
|
||||
Defaults to ``False``.
|
||||
timeout (int | None): Seconds before the foreground command times
|
||||
out. Ignored for background commands. Defaults to ``30``.
|
||||
envs (dict[str, str] | None): Additional environment variables
|
||||
to set for the process.
|
||||
cwd (str | None): Working directory for the process.
|
||||
tag (str | None): Optional label attached to background processes
|
||||
for later retrieval via :meth:`connect`.
|
||||
|
||||
Returns:
|
||||
CommandResult: stdout, stderr, exit code, and duration for
|
||||
foreground commands (``background=False``).
|
||||
|
||||
CommandHandle: PID and tag for background commands
|
||||
(``background=True``).
|
||||
"""
|
||||
payload: dict = {
|
||||
"cmd": "/bin/sh",
|
||||
"args": ["-c", cmd],
|
||||
"background": background,
|
||||
}
|
||||
if timeout is not None and not background:
|
||||
payload["timeout_sec"] = timeout
|
||||
if envs is not None:
|
||||
payload["envs"] = envs
|
||||
if cwd is not None:
|
||||
payload["cwd"] = cwd
|
||||
if tag is not None:
|
||||
payload["tag"] = tag
|
||||
|
||||
http_timeout: httpx.Timeout | None = None
|
||||
if not background and timeout is not None:
|
||||
http_timeout = httpx.Timeout(timeout + 10, connect=5.0)
|
||||
|
||||
resp = await self._http.post(
|
||||
f"/v1/capsules/{self._capsule_id}/exec",
|
||||
json=payload,
|
||||
timeout=http_timeout,
|
||||
)
|
||||
data = handle_response(resp)
|
||||
assert isinstance(data, dict)
|
||||
|
||||
if background:
|
||||
return CommandHandle(
|
||||
pid=data.get("pid", 0),
|
||||
tag=data.get("tag", ""),
|
||||
capsule_id=self._capsule_id,
|
||||
)
|
||||
return _decode_exec_response(data)
|
||||
|
||||
async def list(self) -> list[ProcessInfo]:
|
||||
"""List all running background processes in the capsule.
|
||||
|
||||
Returns:
|
||||
list[ProcessInfo]: Running processes with their PID, tag, and
|
||||
command information.
|
||||
"""
|
||||
resp = await self._http.get(f"/v1/capsules/{self._capsule_id}/processes")
|
||||
data = handle_response(resp)
|
||||
assert isinstance(data, dict)
|
||||
return [
|
||||
ProcessInfo(
|
||||
pid=p.get("pid", 0),
|
||||
tag=p.get("tag"),
|
||||
cmd=p.get("cmd"),
|
||||
args=p.get("args"),
|
||||
)
|
||||
for p in data.get("processes", [])
|
||||
]
|
||||
|
||||
async def kill(self, pid: int) -> None:
|
||||
"""Send SIGKILL to a background process.
|
||||
|
||||
Args:
|
||||
pid (int): PID of the process to kill.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If no process with the given PID exists.
|
||||
"""
|
||||
resp = await self._http.delete(
|
||||
f"/v1/capsules/{self._capsule_id}/processes/{pid}"
|
||||
)
|
||||
handle_response(resp)
|
||||
|
||||
async def connect(self, pid: int) -> AsyncIterator[StreamEvent]:
|
||||
"""Connect to a running background process and stream its output.
|
||||
|
||||
Args:
|
||||
pid (int): PID of the background process to attach to.
|
||||
|
||||
Yields:
|
||||
StreamEvent: Successive output events. Stops on
|
||||
:class:`StreamExitEvent` or :class:`StreamErrorEvent`.
|
||||
"""
|
||||
async with httpx_ws.aconnect_ws(
|
||||
f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream",
|
||||
self._http,
|
||||
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||
try:
|
||||
while True:
|
||||
raw = await ws.receive_json()
|
||||
event = _parse_stream_event(raw)
|
||||
yield event
|
||||
if event.type in ("exit", "error"):
|
||||
break
|
||||
except _WS_CLOSED:
|
||||
pass
|
||||
|
||||
async def stream(
|
||||
self, cmd: str, args: builtins.list[str] | None = None
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""Execute a command via WebSocket, streaming output as events.
|
||||
|
||||
Args:
|
||||
cmd (str): Command to execute.
|
||||
args (list[str] | None): Additional arguments for the command.
|
||||
When omitted, *cmd* is interpreted as a shell command
|
||||
string and executed via ``/bin/sh -c``.
|
||||
|
||||
Yields:
|
||||
StreamEvent: Successive events including :class:`StreamStartEvent`,
|
||||
:class:`StreamStdoutEvent`, :class:`StreamStderrEvent`,
|
||||
:class:`StreamExitEvent`, and :class:`StreamErrorEvent`.
|
||||
"""
|
||||
async with httpx_ws.aconnect_ws(
|
||||
f"/v1/capsules/{self._capsule_id}/exec/stream",
|
||||
self._http,
|
||||
) as ws: # type: httpx_ws.AsyncWebSocketSession
|
||||
if args:
|
||||
start_msg: dict = {"type": "start", "cmd": cmd, "args": args}
|
||||
else:
|
||||
start_msg = {"type": "start", "cmd": "/bin/sh", "args": ["-c", cmd]}
|
||||
await ws.send_text(json.dumps(start_msg))
|
||||
try:
|
||||
while True:
|
||||
raw = await ws.receive_json()
|
||||
event = _parse_stream_event(raw)
|
||||
yield event
|
||||
if event.type in ("exit", "error"):
|
||||
break
|
||||
except _WS_CLOSED:
|
||||
pass
|
||||
@ -6,9 +6,26 @@ import httpx
|
||||
|
||||
|
||||
class WrennError(Exception):
|
||||
"""Base exception for all Wrenn SDK errors."""
|
||||
"""Base exception for all Wrenn SDK errors.
|
||||
|
||||
All SDK exceptions inherit from this class, so you can catch
|
||||
``WrennError`` to handle any API error generically.
|
||||
|
||||
Attributes:
|
||||
code (str): Machine-readable error code from the API
|
||||
(e.g. ``"not_found"``).
|
||||
message (str): Human-readable error description.
|
||||
status_code (int): HTTP status code of the response.
|
||||
"""
|
||||
|
||||
def __init__(self, code: str, message: str, status_code: int) -> None:
|
||||
"""Initialize a WrennError.
|
||||
|
||||
Args:
|
||||
code (str): Machine-readable error code.
|
||||
message (str): Human-readable error description.
|
||||
status_code (int): HTTP status code of the response.
|
||||
"""
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
@ -36,11 +53,23 @@ class WrennConflictError(WrennError):
|
||||
|
||||
|
||||
class WrennHostHasCapsulesError(WrennConflictError):
|
||||
"""409 — Host still has running capsules."""
|
||||
"""409 — Host still has running capsules.
|
||||
|
||||
Attributes:
|
||||
capsule_ids (list[str]): IDs of the capsules still running on the host.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, code: str, message: str, status_code: int, capsule_ids: list[str]
|
||||
) -> None:
|
||||
"""Initialize a WrennHostHasCapsulesError.
|
||||
|
||||
Args:
|
||||
code (str): Machine-readable error code.
|
||||
message (str): Human-readable error description.
|
||||
status_code (int): HTTP status code of the response.
|
||||
capsule_ids (list[str]): IDs of capsules still on the host.
|
||||
"""
|
||||
self.capsule_ids = capsule_ids
|
||||
super().__init__(code, message, status_code)
|
||||
|
||||
@ -81,37 +110,49 @@ _ERROR_MAP: dict[str, type[WrennError]] = {
|
||||
}
|
||||
|
||||
|
||||
def handle_response(resp: httpx.Response) -> dict | list:
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
resp.raise_for_status()
|
||||
raise
|
||||
def _raise_for_status(resp: httpx.Response) -> None:
|
||||
if resp.status_code < 400:
|
||||
return
|
||||
|
||||
err = body.get("error", {})
|
||||
code = err.get("code", "internal_error")
|
||||
message = err.get("message", resp.text)
|
||||
|
||||
exc_cls = _ERROR_MAP.get(code, WrennError)
|
||||
|
||||
if exc_cls is WrennHostHasCapsulesError:
|
||||
raise WrennHostHasCapsulesError(
|
||||
code=code,
|
||||
message=message,
|
||||
status_code=resp.status_code,
|
||||
capsule_ids=body.get("sandbox_ids", []),
|
||||
)
|
||||
|
||||
raise exc_cls(
|
||||
code=code,
|
||||
message=message,
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
raise WrennInternalError(
|
||||
code="internal_error",
|
||||
message=resp.text or f"HTTP {resp.status_code}",
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
|
||||
err = body.get("error", {})
|
||||
code = err.get("code", "internal_error")
|
||||
message = err.get("message", resp.text)
|
||||
|
||||
exc_cls = _ERROR_MAP.get(code, WrennError)
|
||||
|
||||
if exc_cls is WrennHostHasCapsulesError:
|
||||
raise WrennHostHasCapsulesError(
|
||||
code=code,
|
||||
message=message,
|
||||
status_code=resp.status_code,
|
||||
capsule_ids=body.get("capsule_ids") or body.get("sandbox_ids", []),
|
||||
)
|
||||
|
||||
raise exc_cls(
|
||||
code=code,
|
||||
message=message,
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
|
||||
|
||||
def handle_response(resp: httpx.Response) -> dict | list:
|
||||
_raise_for_status(resp)
|
||||
|
||||
if resp.status_code == 204:
|
||||
return {}
|
||||
|
||||
if not resp.content:
|
||||
return {}
|
||||
|
||||
return resp.json()
|
||||
|
||||
|
||||
|
||||
422
src/wrenn/files.py
Normal file
422
src/wrenn/files.py
Normal file
@ -0,0 +1,422 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
|
||||
import httpx
|
||||
|
||||
from wrenn.exceptions import WrennNotFoundError, _raise_for_status, handle_response
|
||||
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:
|
||||
"""Sync filesystem interface. Accessed via ``capsule.files``."""
|
||||
|
||||
def __init__(self, capsule_id: str, http: httpx.Client) -> None:
|
||||
self._capsule_id = capsule_id
|
||||
self._http = http
|
||||
|
||||
def read(self, path: str) -> str:
|
||||
"""Read a file as a UTF-8 string.
|
||||
|
||||
Args:
|
||||
path (str): Absolute path to the file inside the capsule.
|
||||
|
||||
Returns:
|
||||
str: File contents decoded as UTF-8.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If the path does not exist.
|
||||
"""
|
||||
return self.read_bytes(path).decode("utf-8", errors="replace")
|
||||
|
||||
def read_bytes(self, path: str) -> bytes:
|
||||
"""Read a file as raw bytes.
|
||||
|
||||
Args:
|
||||
path (str): Absolute path to the file inside the capsule.
|
||||
|
||||
Returns:
|
||||
bytes: Raw file contents.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If the path does not exist.
|
||||
"""
|
||||
resp = self._http.post(
|
||||
f"/v1/capsules/{self._capsule_id}/files/read",
|
||||
json={"path": path},
|
||||
)
|
||||
_raise_for_status(resp)
|
||||
return resp.content
|
||||
|
||||
def write(self, path: str, data: str | bytes) -> None:
|
||||
"""Write data to a file inside the capsule.
|
||||
|
||||
Creates parent directories if they do not exist.
|
||||
|
||||
Args:
|
||||
path (str): Absolute destination path inside the capsule.
|
||||
data (str | bytes): Content to write. Strings are UTF-8 encoded.
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8")
|
||||
resp = self._http.post(
|
||||
f"/v1/capsules/{self._capsule_id}/files/write",
|
||||
files={"file": ("upload", data)},
|
||||
data={"path": path},
|
||||
)
|
||||
_raise_for_status(resp)
|
||||
|
||||
def list(self, path: str, depth: int = 1) -> list[FileEntry]:
|
||||
"""List directory contents.
|
||||
|
||||
Args:
|
||||
path (str): Absolute path to the directory inside the capsule.
|
||||
depth (int): Recursion depth. ``1`` lists only immediate children.
|
||||
Defaults to ``1``.
|
||||
|
||||
Returns:
|
||||
list[FileEntry]: Entries in the directory.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If the path does not exist.
|
||||
"""
|
||||
resp = self._http.post(
|
||||
f"/v1/capsules/{self._capsule_id}/files/list",
|
||||
json={"path": path, "depth": depth},
|
||||
)
|
||||
parsed = ListDirResponse.model_validate(handle_response(resp))
|
||||
return parsed.entries or []
|
||||
|
||||
def exists(self, path: str) -> bool:
|
||||
"""Check whether a path exists inside the capsule.
|
||||
|
||||
Args:
|
||||
path (str): Absolute path to check.
|
||||
|
||||
Returns:
|
||||
bool: ``True`` if the path exists.
|
||||
"""
|
||||
parent = os.path.dirname(path)
|
||||
name = os.path.basename(path)
|
||||
try:
|
||||
entries = self.list(parent, depth=1)
|
||||
except WrennNotFoundError:
|
||||
return False
|
||||
return any(e.name == name for e in entries)
|
||||
|
||||
def make_dir(self, path: str) -> FileEntry:
|
||||
"""Create a directory (with parents). Idempotent.
|
||||
|
||||
Args:
|
||||
path (str): Absolute path of the directory to create.
|
||||
|
||||
Returns:
|
||||
FileEntry: The created (or already-existing) directory entry.
|
||||
"""
|
||||
resp = self._http.post(
|
||||
f"/v1/capsules/{self._capsule_id}/files/mkdir",
|
||||
json={"path": path},
|
||||
)
|
||||
if _is_already_exists(resp):
|
||||
existing = _find_entry(self.list, path)
|
||||
if existing is not None:
|
||||
return existing
|
||||
parsed = MakeDirResponse.model_validate(handle_response(resp))
|
||||
if parsed.entry is None:
|
||||
raise RuntimeError("mkdir response missing entry")
|
||||
return parsed.entry
|
||||
|
||||
def remove(self, path: str) -> None:
|
||||
"""Remove a file or directory recursively.
|
||||
|
||||
Args:
|
||||
path (str): Absolute path to remove.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If the path does not exist.
|
||||
"""
|
||||
resp = self._http.post(
|
||||
f"/v1/capsules/{self._capsule_id}/files/remove",
|
||||
json={"path": path},
|
||||
)
|
||||
handle_response(resp)
|
||||
|
||||
def upload_stream(self, path: str, stream: Iterator[bytes]) -> None:
|
||||
"""Stream a large file into the capsule.
|
||||
|
||||
Prefer this over :meth:`write` when the file is too large to hold in
|
||||
memory.
|
||||
|
||||
Args:
|
||||
path (str): Absolute destination path inside the capsule.
|
||||
stream (Iterator[bytes]): Iterable of byte chunks to upload.
|
||||
"""
|
||||
boundary = os.urandom(16).hex().encode("utf-8")
|
||||
|
||||
def _multipart() -> Iterator[bytes]:
|
||||
yield b"--" + boundary + b"\r\n"
|
||||
yield b'Content-Disposition: form-data; name="path"\r\n\r\n'
|
||||
yield path.encode("utf-8") + b"\r\n"
|
||||
yield b"--" + boundary + b"\r\n"
|
||||
yield b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n'
|
||||
yield b"Content-Type: application/octet-stream\r\n\r\n"
|
||||
for chunk in stream:
|
||||
yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8")
|
||||
yield b"\r\n--" + boundary + b"--\r\n"
|
||||
|
||||
resp = self._http.post(
|
||||
f"/v1/capsules/{self._capsule_id}/files/stream/write",
|
||||
content=_multipart(),
|
||||
headers={
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}"
|
||||
},
|
||||
)
|
||||
_raise_for_status(resp)
|
||||
|
||||
def download_stream(self, path: str) -> Iterator[bytes]:
|
||||
"""Stream a large file out of the capsule.
|
||||
|
||||
Prefer this over :meth:`read_bytes` when the file is too large to hold
|
||||
in memory.
|
||||
|
||||
Args:
|
||||
path (str): Absolute path to the file inside the capsule.
|
||||
|
||||
Yields:
|
||||
bytes: Successive byte chunks of the file.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If the path does not exist.
|
||||
"""
|
||||
with self._http.stream(
|
||||
"POST",
|
||||
f"/v1/capsules/{self._capsule_id}/files/stream/read",
|
||||
json={"path": path},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
yield from resp.iter_bytes()
|
||||
|
||||
|
||||
class AsyncFiles:
|
||||
"""Async filesystem interface. Accessed via ``capsule.files``."""
|
||||
|
||||
def __init__(self, capsule_id: str, http: httpx.AsyncClient) -> None:
|
||||
self._capsule_id = capsule_id
|
||||
self._http = http
|
||||
|
||||
async def read(self, path: str) -> str:
|
||||
"""Read a file as a UTF-8 string.
|
||||
|
||||
Args:
|
||||
path (str): Absolute path to the file inside the capsule.
|
||||
|
||||
Returns:
|
||||
str: File contents decoded as UTF-8.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If the path does not exist.
|
||||
"""
|
||||
data = await self.read_bytes(path)
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
async def read_bytes(self, path: str) -> bytes:
|
||||
"""Read a file as raw bytes.
|
||||
|
||||
Args:
|
||||
path (str): Absolute path to the file inside the capsule.
|
||||
|
||||
Returns:
|
||||
bytes: Raw file contents.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If the path does not exist.
|
||||
"""
|
||||
resp = await self._http.post(
|
||||
f"/v1/capsules/{self._capsule_id}/files/read",
|
||||
json={"path": path},
|
||||
)
|
||||
_raise_for_status(resp)
|
||||
return resp.content
|
||||
|
||||
async def write(self, path: str, data: str | bytes) -> None:
|
||||
"""Write data to a file inside the capsule.
|
||||
|
||||
Creates parent directories if they do not exist.
|
||||
|
||||
Args:
|
||||
path (str): Absolute destination path inside the capsule.
|
||||
data (str | bytes): Content to write. Strings are UTF-8 encoded.
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8")
|
||||
resp = await self._http.post(
|
||||
f"/v1/capsules/{self._capsule_id}/files/write",
|
||||
files={"file": ("upload", data)},
|
||||
data={"path": path},
|
||||
)
|
||||
_raise_for_status(resp)
|
||||
|
||||
async def list(self, path: str, depth: int = 1) -> list[FileEntry]:
|
||||
"""List directory contents.
|
||||
|
||||
Args:
|
||||
path (str): Absolute path to the directory inside the capsule.
|
||||
depth (int): Recursion depth. ``1`` lists only immediate children.
|
||||
Defaults to ``1``.
|
||||
|
||||
Returns:
|
||||
list[FileEntry]: Entries in the directory.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If the path does not exist.
|
||||
"""
|
||||
resp = await self._http.post(
|
||||
f"/v1/capsules/{self._capsule_id}/files/list",
|
||||
json={"path": path, "depth": depth},
|
||||
)
|
||||
parsed = ListDirResponse.model_validate(handle_response(resp))
|
||||
return parsed.entries or []
|
||||
|
||||
async def exists(self, path: str) -> bool:
|
||||
"""Check whether a path exists inside the capsule.
|
||||
|
||||
Args:
|
||||
path (str): Absolute path to check.
|
||||
|
||||
Returns:
|
||||
bool: ``True`` if the path exists.
|
||||
"""
|
||||
parent = os.path.dirname(path)
|
||||
name = os.path.basename(path)
|
||||
try:
|
||||
entries = await self.list(parent, depth=1)
|
||||
except WrennNotFoundError:
|
||||
return False
|
||||
return any(e.name == name for e in entries)
|
||||
|
||||
async def make_dir(self, path: str) -> FileEntry:
|
||||
"""Create a directory (with parents). Idempotent.
|
||||
|
||||
Args:
|
||||
path (str): Absolute path of the directory to create.
|
||||
|
||||
Returns:
|
||||
FileEntry: The created (or already-existing) directory entry.
|
||||
"""
|
||||
resp = await self._http.post(
|
||||
f"/v1/capsules/{self._capsule_id}/files/mkdir",
|
||||
json={"path": path},
|
||||
)
|
||||
if _is_already_exists(resp):
|
||||
parent = os.path.dirname(path)
|
||||
name = os.path.basename(path)
|
||||
for entry in await self.list(parent, depth=1):
|
||||
if entry.name == name:
|
||||
return entry
|
||||
parsed = MakeDirResponse.model_validate(handle_response(resp))
|
||||
if parsed.entry is None:
|
||||
raise RuntimeError("mkdir response missing entry")
|
||||
return parsed.entry
|
||||
|
||||
async def remove(self, path: str) -> None:
|
||||
"""Remove a file or directory recursively.
|
||||
|
||||
Args:
|
||||
path (str): Absolute path to remove.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If the path does not exist.
|
||||
"""
|
||||
resp = await self._http.post(
|
||||
f"/v1/capsules/{self._capsule_id}/files/remove",
|
||||
json={"path": path},
|
||||
)
|
||||
handle_response(resp)
|
||||
|
||||
async def upload_stream(self, path: str, stream: AsyncIterator[bytes]) -> None:
|
||||
"""Stream a large file into the capsule.
|
||||
|
||||
Prefer this over :meth:`write` when the file is too large to hold in
|
||||
memory.
|
||||
|
||||
Args:
|
||||
path (str): Absolute destination path inside the capsule.
|
||||
stream (AsyncIterator[bytes]): Async iterable of byte chunks to
|
||||
upload.
|
||||
"""
|
||||
boundary = os.urandom(16).hex().encode("utf-8")
|
||||
|
||||
async def _multipart() -> AsyncIterator[bytes]:
|
||||
yield b"--" + boundary + b"\r\n"
|
||||
yield b'Content-Disposition: form-data; name="path"\r\n\r\n'
|
||||
yield path.encode("utf-8") + b"\r\n"
|
||||
yield b"--" + boundary + b"\r\n"
|
||||
yield b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n'
|
||||
yield b"Content-Type: application/octet-stream\r\n\r\n"
|
||||
async for chunk in stream:
|
||||
yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8")
|
||||
yield b"\r\n--" + boundary + b"--\r\n"
|
||||
|
||||
resp = await self._http.post(
|
||||
f"/v1/capsules/{self._capsule_id}/files/stream/write",
|
||||
content=_multipart(),
|
||||
headers={
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}"
|
||||
},
|
||||
)
|
||||
_raise_for_status(resp)
|
||||
|
||||
async def download_stream(self, path: str) -> AsyncIterator[bytes]:
|
||||
"""Stream a large file out of the capsule.
|
||||
|
||||
Prefer this over :meth:`read_bytes` when the file is too large to hold
|
||||
in memory.
|
||||
|
||||
Args:
|
||||
path (str): Absolute path to the file inside the capsule.
|
||||
|
||||
Yields:
|
||||
bytes: Successive byte chunks of the file.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If the path does not exist.
|
||||
"""
|
||||
async with self._http.stream(
|
||||
"POST",
|
||||
f"/v1/capsules/{self._capsule_id}/files/stream/read",
|
||||
json={"path": path},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
async for chunk in resp.aiter_bytes():
|
||||
yield chunk
|
||||
@ -1,15 +1,8 @@
|
||||
from wrenn.models._generated import (
|
||||
APIKeyResponse,
|
||||
AuthResponse,
|
||||
BackgroundExecResponse,
|
||||
Capsule,
|
||||
CapsuleMetrics,
|
||||
CapsuleStats,
|
||||
ChangePasswordRequest,
|
||||
ChannelResponse,
|
||||
CreateAPIKeyRequest,
|
||||
CreateCapsuleRequest,
|
||||
CreateChannelRequest,
|
||||
CreateHostRequest,
|
||||
CreateHostResponse,
|
||||
CreateSnapshotRequest,
|
||||
@ -20,55 +13,30 @@ from wrenn.models._generated import (
|
||||
ExecResponse,
|
||||
FileEntry,
|
||||
Host,
|
||||
HostDeletePreview,
|
||||
ListDirRequest,
|
||||
ListDirResponse,
|
||||
LoginRequest,
|
||||
MakeDirRequest,
|
||||
MakeDirResponse,
|
||||
MeResponse,
|
||||
MetricPoint,
|
||||
ProcessEntry,
|
||||
ProcessListResponse,
|
||||
ReadFileRequest,
|
||||
RefreshHostTokenRequest,
|
||||
RefreshHostTokenResponse,
|
||||
RegisterHostRequest,
|
||||
RegisterHostResponse,
|
||||
RemoveRequest,
|
||||
RotateConfigRequest,
|
||||
SignupRequest,
|
||||
SignupResponse,
|
||||
Status,
|
||||
Status1,
|
||||
Template,
|
||||
Team,
|
||||
TeamDetail,
|
||||
TeamMember,
|
||||
TeamWithRole,
|
||||
TestChannelRequest,
|
||||
Type,
|
||||
Type1,
|
||||
Type2,
|
||||
UpdateChannelRequest,
|
||||
UsageResponse,
|
||||
UserSearchResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"APIKeyResponse",
|
||||
"AuthResponse",
|
||||
"BackgroundExecResponse",
|
||||
"Capsule",
|
||||
"CapsuleMetrics",
|
||||
"CapsuleStats",
|
||||
"ChangePasswordRequest",
|
||||
"ChannelResponse",
|
||||
"CreateAPIKeyRequest",
|
||||
"CreateCapsuleRequest",
|
||||
"CreateChannelRequest",
|
||||
"CreateHostRequest",
|
||||
"CreateHostResponse",
|
||||
"CreateCapsuleRequest",
|
||||
"CreateSnapshotRequest",
|
||||
"Encoding",
|
||||
"Error",
|
||||
@ -77,37 +45,21 @@ __all__ = [
|
||||
"ExecResponse",
|
||||
"FileEntry",
|
||||
"Host",
|
||||
"HostDeletePreview",
|
||||
"ListDirRequest",
|
||||
"ListDirResponse",
|
||||
"LoginRequest",
|
||||
"MakeDirRequest",
|
||||
"MakeDirResponse",
|
||||
"MeResponse",
|
||||
"MetricPoint",
|
||||
"ProcessEntry",
|
||||
"ProcessListResponse",
|
||||
"ReadFileRequest",
|
||||
"RefreshHostTokenRequest",
|
||||
"RefreshHostTokenResponse",
|
||||
"RegisterHostRequest",
|
||||
"RegisterHostResponse",
|
||||
"RemoveRequest",
|
||||
"RotateConfigRequest",
|
||||
"Capsule",
|
||||
"SignupRequest",
|
||||
"SignupResponse",
|
||||
"Status",
|
||||
"Status1",
|
||||
"Template",
|
||||
"Team",
|
||||
"TeamDetail",
|
||||
"TeamMember",
|
||||
"TeamWithRole",
|
||||
"TestChannelRequest",
|
||||
"Type",
|
||||
"Type1",
|
||||
"Type2",
|
||||
"UpdateChannelRequest",
|
||||
"UsageResponse",
|
||||
"UserSearchResult",
|
||||
]
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: openapi.yaml
|
||||
# timestamp: 2026-04-19T19:56:15+00:00
|
||||
# timestamp: 2026-05-19T08:54:50+00:00
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
|
||||
from typing import Annotated, Any
|
||||
from datetime import date as date_aliased
|
||||
from enum import StrEnum
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class SignupRequest(BaseModel):
|
||||
@ -29,14 +27,20 @@ class SignupResponse(BaseModel):
|
||||
] = None
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
token: Annotated[str | None, Field(description="JWT token (valid for 6 hours)")] = (
|
||||
None
|
||||
)
|
||||
class SessionResponse(BaseModel):
|
||||
"""
|
||||
Returned by login, activate, and switch-team. The actual auth credential
|
||||
is the wrenn_sid cookie set on the response. The body carries identity
|
||||
data the SPA needs to bootstrap.
|
||||
|
||||
"""
|
||||
|
||||
user_id: str | None = None
|
||||
team_id: str | None = None
|
||||
email: str | None = None
|
||||
name: str | None = None
|
||||
role: str | None = None
|
||||
is_admin: bool | None = None
|
||||
|
||||
|
||||
class CreateAPIKeyRequest(BaseModel):
|
||||
@ -64,10 +68,17 @@ class CreateCapsuleRequest(BaseModel):
|
||||
template: str | None = "minimal"
|
||||
vcpus: int | None = 1
|
||||
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[
|
||||
int | None,
|
||||
Field(
|
||||
description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause.\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
|
||||
|
||||
@ -135,7 +146,10 @@ class Status(StrEnum):
|
||||
pending = "pending"
|
||||
starting = "starting"
|
||||
running = "running"
|
||||
pausing = "pausing"
|
||||
paused = "paused"
|
||||
resuming = "resuming"
|
||||
stopping = "stopping"
|
||||
hibernated = "hibernated"
|
||||
stopped = "stopped"
|
||||
missing = "missing"
|
||||
@ -155,6 +169,13 @@ class Capsule(BaseModel):
|
||||
started_at: AwareDatetime | None = None
|
||||
last_active_at: 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):
|
||||
@ -179,6 +200,13 @@ class Template(BaseModel):
|
||||
memory_mb: int | None = None
|
||||
size_bytes: int | 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):
|
||||
@ -401,7 +429,7 @@ class HostDeletePreview(BaseModel):
|
||||
host: Host | None = None
|
||||
sandbox_ids: Annotated[
|
||||
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
|
||||
|
||||
|
||||
@ -409,8 +437,7 @@ class Error(BaseModel):
|
||||
code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None
|
||||
message: str | None = None
|
||||
sandbox_ids: Annotated[
|
||||
list[str] | None,
|
||||
Field(description="IDs of active capsulees blocking deletion."),
|
||||
list[str] | None, Field(description="IDs of active capsules blocking deletion.")
|
||||
] = None
|
||||
|
||||
|
||||
@ -478,7 +505,9 @@ class MetricPoint(BaseModel):
|
||||
] = None
|
||||
mem_bytes: Annotated[
|
||||
int | None,
|
||||
Field(description="Resident memory in bytes (VmRSS of Firecracker process)"),
|
||||
Field(
|
||||
description="Resident memory in bytes (VmRSS of Cloud Hypervisor process)"
|
||||
),
|
||||
] = None
|
||||
disk_bytes: Annotated[
|
||||
int | None, Field(description="Allocated disk bytes for the CoW sparse file")
|
||||
@ -496,12 +525,12 @@ class Provider(StrEnum):
|
||||
|
||||
|
||||
class Event(StrEnum):
|
||||
capsule_created = "capsule.created"
|
||||
capsule_running = "capsule.running"
|
||||
capsule_paused = "capsule.paused"
|
||||
capsule_destroyed = "capsule.destroyed"
|
||||
template_snapshot_created = "template.snapshot.created"
|
||||
template_snapshot_deleted = "template.snapshot.deleted"
|
||||
capsule_create = "capsule.create"
|
||||
capsule_pause = "capsule.pause"
|
||||
capsule_resume = "capsule.resume"
|
||||
capsule_destroy = "capsule.destroy"
|
||||
template_snapshot_create = "template.snapshot.create"
|
||||
template_snapshot_delete = "template.snapshot.delete"
|
||||
host_up = "host.up"
|
||||
host_down = "host.down"
|
||||
|
||||
@ -593,6 +622,106 @@ class Error1(BaseModel):
|
||||
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):
|
||||
entries: list[FileEntry] | None = None
|
||||
|
||||
|
||||
@ -9,6 +9,10 @@ from typing import Any
|
||||
import httpx_ws
|
||||
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):
|
||||
started = "started"
|
||||
@ -109,6 +113,13 @@ class PtySession:
|
||||
def _send_connect(self, tag: str) -> None:
|
||||
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:
|
||||
"""Send raw bytes to the PTY stdin.
|
||||
|
||||
@ -144,7 +155,7 @@ class PtySession:
|
||||
raise StopIteration
|
||||
try:
|
||||
raw = self._ws.receive_text()
|
||||
except httpx_ws.WebSocketDisconnect:
|
||||
except _WS_CLOSED:
|
||||
raise StopIteration
|
||||
event = _parse_pty_event(json.loads(raw))
|
||||
if event.type == PtyEventType.started:
|
||||
@ -152,8 +163,11 @@ class PtySession:
|
||||
self._tag = event.tag
|
||||
if event.pid is not None:
|
||||
self._pid = event.pid
|
||||
if event.type == PtyEventType.ping:
|
||||
self._send_pong()
|
||||
if event.type == PtyEventType.exit:
|
||||
raise StopIteration
|
||||
self._done = True
|
||||
return event
|
||||
if event.type == PtyEventType.error and event.fatal:
|
||||
self._done = True
|
||||
return event
|
||||
@ -235,6 +249,13 @@ class AsyncPtySession:
|
||||
async def _send_connect(self, tag: str) -> None:
|
||||
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:
|
||||
"""Send raw bytes to the PTY stdin.
|
||||
|
||||
@ -272,7 +293,7 @@ class AsyncPtySession:
|
||||
raise StopAsyncIteration
|
||||
try:
|
||||
raw = await self._ws.receive_text()
|
||||
except httpx_ws.WebSocketDisconnect:
|
||||
except _WS_CLOSED:
|
||||
raise StopAsyncIteration
|
||||
event = _parse_pty_event(json.loads(raw))
|
||||
if event.type == PtyEventType.started:
|
||||
@ -280,8 +301,11 @@ class AsyncPtySession:
|
||||
self._tag = event.tag
|
||||
if event.pid is not None:
|
||||
self._pid = event.pid
|
||||
if event.type == PtyEventType.ping:
|
||||
await self._send_pong()
|
||||
if event.type == PtyEventType.exit:
|
||||
raise StopAsyncIteration
|
||||
self._done = True
|
||||
return event
|
||||
if event.type == PtyEventType.error and event.fatal:
|
||||
self._done = True
|
||||
return event
|
||||
|
||||
@ -1,25 +1,21 @@
|
||||
import warnings as _warnings
|
||||
|
||||
from wrenn.capsule import ( # noqa: F401
|
||||
CodeResult,
|
||||
ExecResult,
|
||||
from wrenn.capsule import Capsule # noqa: F401
|
||||
from wrenn.commands import ( # noqa: F401
|
||||
StreamErrorEvent,
|
||||
StreamEvent,
|
||||
StreamExitEvent,
|
||||
StreamStartEvent,
|
||||
StreamStderrEvent,
|
||||
StreamStdoutEvent,
|
||||
_build_proxy_url,
|
||||
_parse_stream_event,
|
||||
)
|
||||
from wrenn.capsule import Capsule
|
||||
|
||||
|
||||
def __getattr__(name: str) -> type:
|
||||
if name == "Sandbox":
|
||||
_warnings.warn(
|
||||
"'Sandbox' is deprecated, use 'Capsule' instead",
|
||||
DeprecationWarning,
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return Capsule
|
||||
|
||||
37
tests/conftest.py
Normal file
37
tests/conftest.py
Normal file
@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ENV_FILE = Path(__file__).resolve().parent.parent / ".env"
|
||||
|
||||
|
||||
def _read_env_file() -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
if not ENV_FILE.exists():
|
||||
return result
|
||||
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 = key.strip()
|
||||
value = value.strip().strip("\"'")
|
||||
if key:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(
|
||||
config: pytest.Config, items: list[pytest.Item]
|
||||
) -> None:
|
||||
env_vars = _read_env_file()
|
||||
has_key = bool(os.environ.get("WRENN_API_KEY") or env_vars.get("WRENN_API_KEY"))
|
||||
if has_key:
|
||||
return
|
||||
skip = pytest.mark.skip(reason="WRENN_API_KEY not set")
|
||||
for item in items:
|
||||
if "integration" in item.keywords:
|
||||
item.add_marker(skip)
|
||||
@ -1,95 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Generator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from typing_extensions import AsyncGenerator
|
||||
|
||||
from wrenn.capsule import Capsule
|
||||
from wrenn.client import AsyncWrennClient, WrennClient
|
||||
|
||||
WRENN_API_KEY = os.environ.get("WRENN_API_KEY")
|
||||
WRENN_TOKEN = os.environ.get("WRENN_TOKEN")
|
||||
WRENN_BASE_URL = os.environ.get("WRENN_BASE_URL", "http://localhost:8080")
|
||||
WRENN_TEST_EMAIL = os.environ.get("WRENN_TEST_EMAIL")
|
||||
WRENN_TEST_PASSWORD = os.environ.get("WRENN_TEST_PASSWORD")
|
||||
|
||||
|
||||
def _has_auth() -> bool:
|
||||
return bool(WRENN_API_KEY or WRENN_TOKEN)
|
||||
|
||||
|
||||
requires_auth = pytest.mark.skipif(
|
||||
not _has_auth(),
|
||||
reason="Set WRENN_API_KEY or WRENN_TOKEN to run integration tests",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> Generator[WrennClient, None, None]:
|
||||
with WrennClient(
|
||||
api_key=WRENN_API_KEY,
|
||||
token=WRENN_TOKEN,
|
||||
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, token=WRENN_TOKEN, base_url=WRENN_BASE_URL
|
||||
) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bearer_client() -> Generator[WrennClient, None, None]:
|
||||
if WRENN_TOKEN:
|
||||
with WrennClient(token=WRENN_TOKEN, base_url=WRENN_BASE_URL) as c:
|
||||
yield c
|
||||
elif WRENN_TEST_EMAIL and WRENN_TEST_PASSWORD:
|
||||
with WrennClient(api_key=WRENN_API_KEY, base_url=WRENN_BASE_URL) as c:
|
||||
resp = c.auth.login(WRENN_TEST_EMAIL, WRENN_TEST_PASSWORD)
|
||||
with WrennClient(token=resp.token, base_url=WRENN_BASE_URL) as c:
|
||||
yield c
|
||||
else:
|
||||
pytest.skip(
|
||||
"Set WRENN_TOKEN or WRENN_TEST_EMAIL+WRENN_TEST_PASSWORD for bearer-auth tests"
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_minimal_capsule(
|
||||
async_client: AsyncWrennClient,
|
||||
) -> AsyncGenerator[Capsule, None]:
|
||||
"""Provides a ready-to-use minimal capsule and cleans it up afterward."""
|
||||
cap = await async_client.capsules.create(template="minimal", timeout_sec=120)
|
||||
await cap.async_wait_ready(timeout=60, interval=1)
|
||||
yield cap
|
||||
await cap.async_destroy()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_python_capsule(
|
||||
async_client: AsyncWrennClient,
|
||||
) -> AsyncGenerator[Capsule, None]:
|
||||
"""Provides a ready-to-use Python interpreter capsule."""
|
||||
cap = await async_client.capsules.create(
|
||||
template="python-interpreter-v0-beta", timeout_sec=120
|
||||
)
|
||||
await cap.async_wait_ready(timeout=60, interval=1)
|
||||
yield cap
|
||||
await cap.async_destroy()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minimal_capsule(
|
||||
client: WrennClient,
|
||||
) -> Generator[Capsule, None, None]:
|
||||
"""Provides a ready-to-use minimal capsule and cleans it up afterward."""
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
yield cap
|
||||
@ -1,79 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from wrenn.capsule import Capsule, ExecResult
|
||||
|
||||
from .conftest import requires_auth
|
||||
|
||||
# --- Tests ---
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestAsyncCapsuleLifecycle:
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_create_exec_destroy(self, async_minimal_capsule: Capsule):
|
||||
result = await async_minimal_capsule.async_exec("echo", args=["async_hello"])
|
||||
assert isinstance(result, ExecResult)
|
||||
assert result.exit_code == 0
|
||||
assert "async_hello" in result.stdout
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_upload_download(self, async_minimal_capsule: Capsule):
|
||||
content = b"Async upload test"
|
||||
await async_minimal_capsule.async_upload("/tmp/async_test.txt", content)
|
||||
downloaded = await async_minimal_capsule.async_download("/tmp/async_test.txt")
|
||||
assert downloaded == content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_run_code(self, async_python_capsule: Capsule):
|
||||
r = await async_python_capsule.async_run_code("42 * 2")
|
||||
assert r.text == "84"
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestAsyncFilesystem:
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_list_dir(self, async_minimal_capsule: Capsule):
|
||||
await async_minimal_capsule.async_mkdir("/tmp/async_ls_test")
|
||||
await async_minimal_capsule.async_upload("/tmp/async_ls_test/file.txt", b"data")
|
||||
entries = await async_minimal_capsule.async_list_dir("/tmp/async_ls_test")
|
||||
|
||||
assert isinstance(entries, list)
|
||||
assert any(e.name == "file.txt" for e in entries)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_mkdir(self, async_minimal_capsule: Capsule):
|
||||
entry = await async_minimal_capsule.async_mkdir("/tmp/async_mkdir_test")
|
||||
assert entry.type == "directory"
|
||||
assert entry.name == "async_mkdir_test"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_remove(self, async_minimal_capsule: Capsule):
|
||||
await async_minimal_capsule.async_upload("/tmp/async_rm.txt", b"bye")
|
||||
|
||||
entries = await async_minimal_capsule.async_list_dir("/tmp")
|
||||
assert any(e.name == "async_rm.txt" for e in entries)
|
||||
|
||||
await async_minimal_capsule.async_remove("/tmp/async_rm.txt")
|
||||
entries = await async_minimal_capsule.async_list_dir("/tmp")
|
||||
assert not any(e.name == "async_rm.txt" for e in entries)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_full_filesystem_roundtrip(
|
||||
self, async_minimal_capsule: Capsule
|
||||
):
|
||||
await async_minimal_capsule.async_mkdir("/tmp/async_rt")
|
||||
await async_minimal_capsule.async_upload(
|
||||
"/tmp/async_rt/file.txt", b"async content"
|
||||
)
|
||||
|
||||
entries = await async_minimal_capsule.async_list_dir("/tmp/async_rt")
|
||||
assert any(e.name == "file.txt" for e in entries)
|
||||
|
||||
data = await async_minimal_capsule.async_download("/tmp/async_rt/file.txt")
|
||||
assert data == b"async content"
|
||||
|
||||
await async_minimal_capsule.async_remove("/tmp/async_rt/file.txt")
|
||||
entries = await async_minimal_capsule.async_list_dir("/tmp/async_rt")
|
||||
assert not any(e.name == "file.txt" for e in entries)
|
||||
@ -1,28 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wrenn.client import WrennClient
|
||||
|
||||
from .conftest import requires_auth
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestSnapshots:
|
||||
def test_list_templates(self, client: WrennClient):
|
||||
templates = client.snapshots.list()
|
||||
assert isinstance(templates, list)
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestAPIKeys:
|
||||
def test_create_list_delete(self, bearer_client: WrennClient):
|
||||
key_resp = bearer_client.api_keys.create(name="integration-test-key")
|
||||
assert key_resp.name == "integration-test-key"
|
||||
assert key_resp.key is not None
|
||||
assert key_resp.id is not None
|
||||
|
||||
try:
|
||||
keys = bearer_client.api_keys.list()
|
||||
ids = [k.id for k in keys]
|
||||
assert key_resp.id in ids
|
||||
finally:
|
||||
bearer_client.api_keys.delete(key_resp.id)
|
||||
@ -1,91 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from wrenn.capsule import Capsule
|
||||
from wrenn.client import WrennClient
|
||||
from wrenn.exceptions import WrennNotFoundError, WrennValidationError
|
||||
|
||||
from .conftest import requires_auth
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestCapsuleLifecycle:
|
||||
def test_create_exec_destroy(self, minimal_capsule: Capsule):
|
||||
result = minimal_capsule.exec("echo", args=["hello"])
|
||||
assert result.exit_code == 0
|
||||
assert "hello" in result.stdout
|
||||
|
||||
def test_exec_with_args(self, minimal_capsule: Capsule):
|
||||
result = minimal_capsule.exec("echo", args=["hello", "world"])
|
||||
assert result.exit_code == 0
|
||||
assert "hello world" in result.stdout
|
||||
|
||||
def test_exec_nonzero_exit(self, minimal_capsule: Capsule):
|
||||
result = minimal_capsule.exec("sh", args=["-c", "exit 42"])
|
||||
assert result.exit_code == 42
|
||||
|
||||
def test_exec_stderr(self, minimal_capsule: Capsule):
|
||||
result = minimal_capsule.exec("sh", args=["-c", "echo err>&2"])
|
||||
assert result.exit_code == 0
|
||||
assert "err" in result.stderr
|
||||
|
||||
def test_context_manager_cleanup(self, client: WrennClient):
|
||||
# This test explicitly requires manual management to verify the context manager
|
||||
cap = client.capsules.create(template="minimal", timeout_sec=120)
|
||||
cap_id = cap.id
|
||||
|
||||
with cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
|
||||
fetched = client.capsules.get(cap_id)
|
||||
assert fetched.status in ("stopped", "destroyed")
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestPauseResume:
|
||||
def test_pause_and_resume(self, minimal_capsule: Capsule):
|
||||
minimal_capsule.pause()
|
||||
assert minimal_capsule.status == "paused"
|
||||
|
||||
minimal_capsule.resume()
|
||||
minimal_capsule.wait_ready(timeout=60, interval=1)
|
||||
|
||||
result = minimal_capsule.exec("echo", args=["resumed"])
|
||||
assert result.exit_code == 0
|
||||
assert "resumed" in result.stdout
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestPing:
|
||||
def test_ping_resets_timer(self, minimal_capsule: Capsule):
|
||||
minimal_capsule.ping()
|
||||
result = minimal_capsule.exec("echo", args=["still_alive"])
|
||||
assert result.exit_code == 0
|
||||
assert "still_alive" in result.stdout
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestProxy:
|
||||
def test_get_url(self, minimal_capsule: Capsule):
|
||||
url = minimal_capsule.get_url(8888)
|
||||
assert minimal_capsule.id in url
|
||||
assert "8888" in url
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestListAndGet:
|
||||
def test_list_capsules(self, client: WrennClient, minimal_capsule: Capsule):
|
||||
# Require minimal_capsule to ensure one exists, use client to list
|
||||
boxes = client.capsules.list()
|
||||
ids = [b.id for b in boxes]
|
||||
assert minimal_capsule.id in ids
|
||||
|
||||
def test_get_existing_capsule(self, client: WrennClient, minimal_capsule: Capsule):
|
||||
fetched = client.capsules.get(minimal_capsule.id)
|
||||
assert fetched.id == minimal_capsule.id
|
||||
assert fetched.status == "running"
|
||||
|
||||
def test_get_nonexistent_capsule(self, client: WrennClient):
|
||||
with pytest.raises((WrennNotFoundError, WrennValidationError)):
|
||||
client.capsules.get("cl-nonexistent00000000000000000")
|
||||
@ -1,133 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from wrenn.client import WrennClient
|
||||
|
||||
from .conftest import requires_auth
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestFileIO:
|
||||
def test_upload_and_download(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
content = b"Hello from integration test!"
|
||||
cap.upload("/tmp/test_file.txt", content)
|
||||
downloaded = cap.download("/tmp/test_file.txt")
|
||||
assert downloaded == content
|
||||
|
||||
def test_download_nonexistent_file(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
with pytest.raises(Exception):
|
||||
cap.download("/tmp/no_such_file_12345")
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestFilesystemListDir:
|
||||
def test_list_dir_root(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
cap.mkdir("/tmp/ls_test_root")
|
||||
cap.upload("/tmp/ls_test_root/hello.txt", b"hello")
|
||||
entries = cap.list_dir("/tmp/ls_test_root")
|
||||
assert isinstance(entries, list)
|
||||
names = [e.name for e in entries]
|
||||
assert "hello.txt" in names
|
||||
|
||||
def test_list_dir_after_mkdir(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
cap.mkdir("/tmp/fs_test_dir")
|
||||
entries = cap.list_dir("/tmp")
|
||||
names = [e.name for e in entries]
|
||||
assert "fs_test_dir" in names
|
||||
|
||||
def test_list_dir_file_metadata(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
cap.upload("/tmp/meta_test.txt", b"hello world")
|
||||
entries = cap.list_dir("/tmp")
|
||||
match = [e for e in entries if e.name == "meta_test.txt"]
|
||||
assert len(match) == 1
|
||||
f = match[0]
|
||||
assert f.type == "file"
|
||||
assert f.size == 11
|
||||
assert f.permissions is not None
|
||||
assert f.owner is not None
|
||||
assert f.group is not None
|
||||
assert f.modified_at is not None
|
||||
|
||||
def test_list_dir_depth(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
cap.mkdir("/tmp/depth_a/depth_b")
|
||||
cap.upload("/tmp/depth_a/depth_b/nested.txt", b"deep")
|
||||
entries = cap.list_dir("/tmp/depth_a", depth=2)
|
||||
paths = [e.path for e in entries]
|
||||
assert any("nested.txt" in p for p in paths)
|
||||
|
||||
def test_list_dir_empty_directory(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
cap.mkdir("/tmp/empty_dir_test")
|
||||
entries = cap.list_dir("/tmp/empty_dir_test")
|
||||
assert entries == []
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestFilesystemMkdir:
|
||||
def test_mkdir_creates_directory(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
entry = cap.mkdir("/tmp/mkdir_test")
|
||||
assert entry.name == "mkdir_test"
|
||||
assert entry.type == "directory"
|
||||
assert entry.path == "/tmp/mkdir_test"
|
||||
|
||||
def test_mkdir_creates_parents(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
entry = cap.mkdir("/tmp/a/b/c/d")
|
||||
assert entry.type == "directory"
|
||||
|
||||
def test_mkdir_already_exists(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
cap.mkdir("/tmp/exist_test")
|
||||
entry = cap.mkdir("/tmp/exist_test")
|
||||
assert entry.type == "directory"
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestFilesystemRemove:
|
||||
def test_remove_file(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
cap.upload("/tmp/rm_test.txt", b"delete me")
|
||||
entries_before = cap.list_dir("/tmp")
|
||||
assert any(e.name == "rm_test.txt" for e in entries_before)
|
||||
cap.remove("/tmp/rm_test.txt")
|
||||
entries_after = cap.list_dir("/tmp")
|
||||
assert not any(e.name == "rm_test.txt" for e in entries_after)
|
||||
|
||||
def test_remove_directory(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
cap.mkdir("/tmp/rm_dir_test")
|
||||
cap.upload("/tmp/rm_dir_test/file.txt", b"inside")
|
||||
cap.remove("/tmp/rm_dir_test")
|
||||
entries = cap.list_dir("/tmp")
|
||||
assert not any(e.name == "rm_dir_test" for e in entries)
|
||||
|
||||
def test_upload_download_remove_roundtrip(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
content = b"round trip test data " * 100
|
||||
cap.upload("/tmp/rt.txt", content)
|
||||
downloaded = cap.download("/tmp/rt.txt")
|
||||
assert downloaded == content
|
||||
cap.remove("/tmp/rt.txt")
|
||||
with pytest.raises(Exception):
|
||||
cap.download("/tmp/rt.txt")
|
||||
@ -1,77 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wrenn.client import WrennClient
|
||||
from wrenn.pty import PtyEventType
|
||||
|
||||
from .conftest import requires_auth
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestPty:
|
||||
def test_pty_basic_output(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
with cap.pty(cmd="/bin/sh", cwd="/tmp") as term:
|
||||
term.write(b"echo pty_hello\n")
|
||||
output = b""
|
||||
for event in term:
|
||||
if event.type == PtyEventType.output:
|
||||
output += event.data
|
||||
elif event.type == PtyEventType.exit:
|
||||
break
|
||||
if b"pty_hello" in output:
|
||||
term.write(b"exit\n")
|
||||
assert b"pty_hello" in output
|
||||
|
||||
def test_pty_tag_and_pid(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
with cap.pty(cmd="/bin/sh") as term:
|
||||
started = False
|
||||
for event in term:
|
||||
if event.type == PtyEventType.started:
|
||||
started = True
|
||||
assert term.tag is not None
|
||||
assert term.pid is not None
|
||||
assert term.tag.startswith("pty-")
|
||||
elif event.type == PtyEventType.output:
|
||||
term.write(b"exit\n")
|
||||
elif event.type == PtyEventType.exit:
|
||||
break
|
||||
assert started
|
||||
|
||||
def test_pty_exit_on_command_exit(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
with cap.pty(cmd="/bin/echo", args=["immediate"]) as term:
|
||||
events = list(term)
|
||||
types = [e.type for e in events]
|
||||
assert PtyEventType.started in types
|
||||
assert PtyEventType.output in types or PtyEventType.exit in types
|
||||
|
||||
def test_pty_resize(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
with cap.pty(cmd="/bin/sh", cols=80, rows=24) as term:
|
||||
for event in term:
|
||||
if event.type == PtyEventType.started:
|
||||
term.resize(120, 40)
|
||||
term.write(b"exit\n")
|
||||
elif event.type == PtyEventType.exit:
|
||||
break
|
||||
|
||||
def test_pty_envs(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
with cap.pty(cmd="/bin/sh", envs={"MY_VAR": "hello_env"}) as term:
|
||||
output = b""
|
||||
for event in term:
|
||||
if event.type == PtyEventType.started:
|
||||
term.write(b"echo $MY_VAR\n")
|
||||
elif event.type == PtyEventType.output:
|
||||
output += event.data
|
||||
if b"hello_env" in output:
|
||||
term.write(b"exit\n")
|
||||
elif event.type == PtyEventType.exit:
|
||||
break
|
||||
assert b"hello_env" in output
|
||||
@ -1,49 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wrenn.client import WrennClient
|
||||
|
||||
from .conftest import requires_auth
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestRunCode:
|
||||
def test_basic_execution(self, client: WrennClient):
|
||||
with client.capsules.create(
|
||||
template="python-interpreter-v0-beta", timeout_sec=120
|
||||
) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
|
||||
r = cap.run_code("x = 42")
|
||||
assert r.error is None
|
||||
|
||||
r = cap.run_code("x * 2")
|
||||
assert r.text == "84"
|
||||
|
||||
def test_state_persists(self, client: WrennClient):
|
||||
with client.capsules.create(
|
||||
template="python-interpreter-v0-beta", timeout_sec=120
|
||||
) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
|
||||
cap.run_code("def greet(name): return f'hello {name}'")
|
||||
r = cap.run_code("greet('capsule')")
|
||||
assert "hello capsule" in (r.text or "")
|
||||
|
||||
def test_error_traceback(self, client: WrennClient):
|
||||
with client.capsules.create(
|
||||
template="python-interpreter-v0-beta", timeout_sec=120
|
||||
) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
|
||||
r = cap.run_code("1/0")
|
||||
assert r.error is not None
|
||||
assert "ZeroDivisionError" in r.error
|
||||
|
||||
def test_stdout_capture(self, client: WrennClient):
|
||||
with client.capsules.create(
|
||||
template="python-interpreter-v0-beta", timeout_sec=120
|
||||
) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
|
||||
r = cap.run_code("print('hello from kernel')")
|
||||
assert "hello from kernel" in r.stdout
|
||||
@ -1,30 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wrenn.client import WrennClient
|
||||
|
||||
from .conftest import requires_auth
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestStreamUploadDownload:
|
||||
def test_stream_upload_and_download(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
chunks = [b"chunk0_", b"chunk1_", b"chunk2"]
|
||||
|
||||
def data_gen():
|
||||
yield from chunks
|
||||
|
||||
cap.stream_upload("/tmp/stream_test.bin", data_gen())
|
||||
downloaded = cap.download("/tmp/stream_test.bin")
|
||||
assert downloaded == b"chunk0_chunk1_chunk2"
|
||||
|
||||
def test_stream_download_large(self, client: WrennClient):
|
||||
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
|
||||
cap.wait_ready(timeout=60, interval=1)
|
||||
content = b"x" * 65536 * 3
|
||||
cap.upload("/tmp/large.bin", content)
|
||||
collected = b""
|
||||
for chunk in cap.stream_download("/tmp/large.bin"):
|
||||
collected += chunk
|
||||
assert collected == content
|
||||
@ -1,24 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
import respx
|
||||
|
||||
from wrenn.capsule import Capsule, CodeResult, _build_proxy_url
|
||||
from wrenn.client import WrennClient
|
||||
from wrenn.capsule import Capsule, _build_proxy_url
|
||||
from wrenn.code_interpreter.models import Execution, ExecutionError, Logs, Result
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with WrennClient(
|
||||
api_key="wrn_test1234567890abcdef12345678", token="jwt-test-token-abc123"
|
||||
) as c:
|
||||
yield c
|
||||
BASE = "https://app.wrenn.dev/api"
|
||||
|
||||
|
||||
class TestBuildProxyUrl:
|
||||
def test_https_production(self):
|
||||
url = _build_proxy_url("https://api.wrenn.dev", "cl-abc123", 8888)
|
||||
assert url == "wss://8888-cl-abc123.api.wrenn.dev"
|
||||
url = _build_proxy_url("https://app.wrenn.dev/api", "cl-abc123", 8888)
|
||||
assert url == "wss://8888-cl-abc123.app.wrenn.dev"
|
||||
|
||||
def test_http_localhost(self):
|
||||
url = _build_proxy_url("http://localhost:8080", "cl-abc123", 3000)
|
||||
@ -33,176 +27,186 @@ class TestBuildProxyUrl:
|
||||
assert url == "ws://5000-sb-2.192.168.1.1"
|
||||
|
||||
|
||||
class TestCapsuleGetUrl:
|
||||
class TestCapsuleCreate:
|
||||
@respx.mock
|
||||
def test_get_url_returns_proxy_url(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
201, json={"id": "cl-abc", "status": "pending"}
|
||||
def test_capsule_constructor_creates(self):
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
202, json={"id": "cl-1", "status": "starting", "template": "minimal"}
|
||||
)
|
||||
cap = client.capsules.create(template="minimal")
|
||||
url = cap.get_url(8888)
|
||||
assert url == "wss://8888-cl-abc.api.wrenn.dev"
|
||||
|
||||
@respx.mock
|
||||
def test_get_url_localhost(self):
|
||||
with WrennClient(
|
||||
cap = Capsule(
|
||||
template="minimal",
|
||||
api_key="wrn_test1234567890abcdef12345678",
|
||||
base_url="http://localhost:8080",
|
||||
) as c:
|
||||
respx.post("http://localhost:8080/v1/capsules").respond(
|
||||
201, json={"id": "cl-xyz", "status": "pending"}
|
||||
)
|
||||
cap = c.capsules.create()
|
||||
url = cap.get_url(3000)
|
||||
assert url == "ws://3000-cl-xyz.localhost:8080"
|
||||
|
||||
|
||||
class TestCapsuleHttpClient:
|
||||
@respx.mock
|
||||
def test_http_client_has_api_key_header(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
201, json={"id": "cl-abc", "status": "pending"}
|
||||
base_url=BASE,
|
||||
)
|
||||
cap = client.capsules.create()
|
||||
hc = cap.http_client
|
||||
assert hc.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
|
||||
assert cap.capsule_id == "cl-1"
|
||||
assert hasattr(cap, "commands")
|
||||
assert hasattr(cap, "files")
|
||||
|
||||
@respx.mock
|
||||
def test_http_client_sends_to_proxy(self, client):
|
||||
route = respx.get("https://8888-cl-abc.api.wrenn.dev/api/kernels").respond(
|
||||
200, json=[]
|
||||
def test_capsule_create_classmethod(self):
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
202, json={"id": "cl-2", "status": "starting"}
|
||||
)
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
201, json={"id": "cl-abc", "status": "pending"}
|
||||
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
assert cap.capsule_id == "cl-2"
|
||||
|
||||
@respx.mock
|
||||
def test_capsule_context_manager_kills(self):
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
202, json={"id": "cl-1", "status": "starting"}
|
||||
)
|
||||
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
|
||||
with Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as cap:
|
||||
assert cap.capsule_id == "cl-1"
|
||||
assert kill_route.called
|
||||
|
||||
@respx.mock
|
||||
def test_capsule_env_var(self, monkeypatch):
|
||||
monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env_key")
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
202, json={"id": "cl-3", "status": "starting"}
|
||||
)
|
||||
cap = Capsule(base_url=BASE)
|
||||
assert cap.capsule_id == "cl-3"
|
||||
|
||||
|
||||
class TestCapsuleStaticMethods:
|
||||
@respx.mock
|
||||
def test_static_destroy(self):
|
||||
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
|
||||
Capsule._static_destroy(
|
||||
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||
)
|
||||
cap = client.capsules.create()
|
||||
resp = cap.http_client.get("/api/kernels")
|
||||
assert resp.status_code == 200
|
||||
assert route.called
|
||||
|
||||
def test_jwt_only_get_url_works(self):
|
||||
with WrennClient(token="jwt-abc") as c:
|
||||
cap = Capsule(id="cl-abc")
|
||||
assert c._mgmt_http is not None
|
||||
cap._bind(
|
||||
c._mgmt_http, str(c._mgmt_http.base_url), api_key=None, token="jwt-abc"
|
||||
)
|
||||
url = cap.get_url(8888)
|
||||
assert "8888-cl-abc" in url
|
||||
|
||||
def test_jwt_only_http_client_has_bearer_header(self):
|
||||
with WrennClient(token="jwt-abc") as c:
|
||||
cap = Capsule(id="cl-abc")
|
||||
assert c._mgmt_http is not None
|
||||
cap._bind(
|
||||
c._mgmt_http, str(c._mgmt_http.base_url), api_key=None, token="jwt-abc"
|
||||
)
|
||||
hc = cap.http_client
|
||||
assert hc.headers["Authorization"] == "Bearer jwt-abc"
|
||||
|
||||
|
||||
class TestCreateReturnsBoundCapsule:
|
||||
@respx.mock
|
||||
def test_create_returns_capsule_subclass(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
201, json={"id": "cl-1", "status": "pending", "template": "minimal"}
|
||||
def test_static_pause(self):
|
||||
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
|
||||
202, json={"id": "cl-1", "status": "pausing"}
|
||||
)
|
||||
cap = client.capsules.create(template="minimal")
|
||||
assert isinstance(cap, Capsule)
|
||||
assert cap.id == "cl-1"
|
||||
assert hasattr(cap, "exec")
|
||||
assert hasattr(cap, "run_code")
|
||||
assert hasattr(cap, "get_url")
|
||||
info = Capsule._static_pause(
|
||||
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||
)
|
||||
assert info.status.value == "pausing"
|
||||
|
||||
@respx.mock
|
||||
def test_create_context_manager(self, client):
|
||||
route = respx.delete("https://api.wrenn.dev/v1/capsules/cl-1").respond(204)
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
201, json={"id": "cl-1", "status": "pending"}
|
||||
def test_static_list(self):
|
||||
respx.get(f"{BASE}/v1/capsules").respond(
|
||||
200, json=[{"id": "cl-1", "status": "running"}]
|
||||
)
|
||||
cap = client.capsules.create()
|
||||
with cap:
|
||||
assert cap.id == "cl-1"
|
||||
assert route.called
|
||||
items = Capsule.list(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
assert len(items) == 1
|
||||
assert items[0].id == "cl-1"
|
||||
|
||||
|
||||
class TestCodeResult:
|
||||
def test_defaults(self):
|
||||
r = CodeResult()
|
||||
assert r.text is None
|
||||
assert r.data is None
|
||||
assert r.stdout == ""
|
||||
assert r.stderr == ""
|
||||
assert r.error is None
|
||||
|
||||
def test_with_values(self):
|
||||
r = CodeResult(
|
||||
text="84",
|
||||
data={"text/plain": "84"},
|
||||
stdout="",
|
||||
stderr="",
|
||||
error=None,
|
||||
@respx.mock
|
||||
def test_static_get_info(self):
|
||||
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
||||
200, json={"id": "cl-1", "status": "running"}
|
||||
)
|
||||
info = Capsule._static_get_info(
|
||||
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||
)
|
||||
assert info.id == "cl-1"
|
||||
|
||||
|
||||
class TestCapsuleConnect:
|
||||
@respx.mock
|
||||
def test_connect_running(self):
|
||||
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
||||
200, json={"id": "cl-1", "status": "running"}
|
||||
)
|
||||
cap = Capsule.connect(
|
||||
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||
)
|
||||
assert cap.capsule_id == "cl-1"
|
||||
|
||||
@respx.mock
|
||||
def test_connect_paused_resumes(self):
|
||||
get_route = respx.get(f"{BASE}/v1/capsules/cl-1")
|
||||
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(
|
||||
202, json={"id": "cl-1", "status": "resuming"}
|
||||
)
|
||||
cap = Capsule.connect(
|
||||
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
|
||||
)
|
||||
assert cap.capsule_id == "cl-1"
|
||||
|
||||
|
||||
class TestExecutionModels:
|
||||
def test_execution_defaults(self):
|
||||
e = Execution()
|
||||
assert e.results == []
|
||||
assert e.logs.stdout == []
|
||||
assert e.logs.stderr == []
|
||||
assert e.error is None
|
||||
assert e.text is None
|
||||
|
||||
def test_result_from_bundle(self):
|
||||
bundle = {"text/plain": "84", "image/png": "base64data"}
|
||||
r = Result.from_bundle(bundle, is_main_result=True)
|
||||
assert r.text == "84"
|
||||
assert r.data is not None
|
||||
assert r.data["text/plain"] == "84"
|
||||
assert r.png == "base64data"
|
||||
assert r.is_main_result is True
|
||||
|
||||
def test_error_result(self):
|
||||
r = CodeResult(error="ZeroDivisionError: division by zero\n...")
|
||||
assert r.error is not None
|
||||
assert "ZeroDivisionError" in r.error
|
||||
def test_result_from_bundle_strips_quotes(self):
|
||||
bundle = {"text/plain": "'hello'"}
|
||||
r = Result.from_bundle(bundle)
|
||||
assert r.text == "hello"
|
||||
|
||||
def test_result_from_bundle_extra_mimes(self):
|
||||
bundle = {"text/plain": "x", "application/vnd.custom": "data"}
|
||||
r = Result.from_bundle(bundle)
|
||||
assert r.extra == {"application/vnd.custom": "data"}
|
||||
|
||||
class TestJupyterMessageFormat:
|
||||
def test_execute_request_structure(self):
|
||||
cap = Capsule(id="test")
|
||||
msg = cap._jupyter_execute_request("x = 42")
|
||||
assert msg["msg_type"] == "execute_request"
|
||||
assert msg["content"]["code"] == "x = 42"
|
||||
assert msg["content"]["silent"] is False
|
||||
assert "msg_id" in msg
|
||||
assert "header" in msg
|
||||
assert msg["header"]["msg_type"] == "execute_request"
|
||||
def test_result_formats(self):
|
||||
r = Result(text="hi", png="data")
|
||||
assert "text" in r.formats()
|
||||
assert "png" in r.formats()
|
||||
assert "html" not in r.formats()
|
||||
|
||||
def test_execute_request_unique_ids(self):
|
||||
cap = Capsule(id="test")
|
||||
m1 = cap._jupyter_execute_request("a")
|
||||
m2 = cap._jupyter_execute_request("b")
|
||||
assert m1["msg_id"] != m2["msg_id"]
|
||||
def test_execution_text_property(self):
|
||||
e = Execution(
|
||||
results=[
|
||||
Result(text="chart", is_main_result=False),
|
||||
Result(text="42", is_main_result=True),
|
||||
]
|
||||
)
|
||||
assert e.text == "42"
|
||||
|
||||
def test_execution_error(self):
|
||||
err = ExecutionError(
|
||||
name="ZeroDivisionError",
|
||||
value="division by zero",
|
||||
traceback="Traceback ...\nZeroDivisionError: division by zero",
|
||||
)
|
||||
e = Execution(error=err)
|
||||
assert e.error is not None
|
||||
assert "ZeroDivisionError" in e.error.name
|
||||
|
||||
def test_logs(self):
|
||||
logs = Logs(stdout=["hello\n", "world\n"], stderr=["warn\n"])
|
||||
assert "".join(logs.stdout) == "hello\nworld\n"
|
||||
assert "".join(logs.stderr) == "warn\n"
|
||||
|
||||
|
||||
class TestDeprecationWarnings:
|
||||
def test_import_sandbox_from_capsule_warns(self):
|
||||
import warnings
|
||||
|
||||
import wrenn.capsule as capsule_mod
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
klass = capsule_mod.Sandbox
|
||||
assert klass is Capsule
|
||||
assert len(w) == 1
|
||||
assert issubclass(w[0].category, DeprecationWarning)
|
||||
assert "Sandbox" in str(w[0].message)
|
||||
|
||||
def test_import_sandbox_from_wrenn_warns(self):
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
# Clear cached attribute
|
||||
if "Sandbox" in dir(sys.modules.get("wrenn", object())):
|
||||
delattr(sys.modules["wrenn"], "Sandbox")
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
from wrenn import Sandbox
|
||||
|
||||
assert Sandbox is Capsule
|
||||
assert any(issubclass(x.category, DeprecationWarning) for x in w)
|
||||
|
||||
def test_client_sandboxes_property_warns(self):
|
||||
import warnings
|
||||
|
||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
resource = c.sandboxes
|
||||
assert resource is c.capsules
|
||||
assert len(w) == 1
|
||||
assert issubclass(w[0].category, DeprecationWarning)
|
||||
assert "sandboxes" in str(w[0].message)
|
||||
fw = [x for x in w if issubclass(x.category, FutureWarning)]
|
||||
assert len(fw) >= 1
|
||||
assert "Sandbox" in str(fw[0].message)
|
||||
|
||||
@ -8,112 +8,38 @@ from wrenn.exceptions import (
|
||||
WrennAgentError,
|
||||
WrennAuthenticationError,
|
||||
WrennConflictError,
|
||||
WrennForbiddenError,
|
||||
WrennHostHasCapsulesError,
|
||||
WrennInternalError,
|
||||
WrennNotFoundError,
|
||||
WrennValidationError,
|
||||
)
|
||||
from wrenn.models import (
|
||||
APIKeyResponse,
|
||||
Capsule,
|
||||
CreateHostResponse,
|
||||
Host,
|
||||
SignupResponse,
|
||||
Status,
|
||||
Template,
|
||||
UsageResponse,
|
||||
)
|
||||
|
||||
BASE = "https://app.wrenn.dev/api"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with WrennClient(
|
||||
api_key="wrn_test1234567890abcdef12345678", token="jwt-test-token-abc123"
|
||||
) as c:
|
||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def async_client():
|
||||
return AsyncWrennClient(
|
||||
api_key="wrn_test1234567890abcdef12345678", token="jwt-test-token-abc123"
|
||||
)
|
||||
|
||||
|
||||
class TestAuth:
|
||||
@respx.mock
|
||||
def test_signup(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/auth/signup").respond(
|
||||
201,
|
||||
json={"message": "Account created. Check your email to activate."},
|
||||
)
|
||||
resp = client.auth.signup("a@b.com", "password123", "Test User")
|
||||
assert isinstance(resp, SignupResponse)
|
||||
assert resp.message is not None
|
||||
|
||||
@respx.mock
|
||||
def test_signup_no_creds(self):
|
||||
respx.post("https://api.wrenn.dev/v1/auth/signup").respond(
|
||||
201,
|
||||
json={"message": "Account created."},
|
||||
)
|
||||
with WrennClient() as c:
|
||||
resp = c.auth.signup("a@b.com", "password123", "Test User")
|
||||
assert isinstance(resp, SignupResponse)
|
||||
|
||||
@respx.mock
|
||||
def test_login(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/auth/login").respond(
|
||||
200,
|
||||
json={"token": "jwt-token", "email": "a@b.com"},
|
||||
)
|
||||
resp = client.auth.login("a@b.com", "password123")
|
||||
assert resp.token == "jwt-token"
|
||||
|
||||
|
||||
class TestAPIKeys:
|
||||
@respx.mock
|
||||
def test_create(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/api-keys").respond(
|
||||
201,
|
||||
json={
|
||||
"id": "key-1",
|
||||
"name": "my-key",
|
||||
"key_prefix": "wrn_ab12cd34",
|
||||
"key": "wrn_ab12cd34fullkey",
|
||||
},
|
||||
)
|
||||
resp = client.api_keys.create(name="my-key")
|
||||
assert isinstance(resp, APIKeyResponse)
|
||||
assert resp.name == "my-key"
|
||||
assert resp.key == "wrn_ab12cd34fullkey"
|
||||
|
||||
@respx.mock
|
||||
def test_list(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/api-keys").respond(
|
||||
200,
|
||||
json=[{"id": "key-1", "name": "k1"}, {"id": "key-2", "name": "k2"}],
|
||||
)
|
||||
keys = client.api_keys.list()
|
||||
assert len(keys) == 2
|
||||
assert keys[0].id == "key-1"
|
||||
|
||||
@respx.mock
|
||||
def test_delete(self, client):
|
||||
route = respx.delete("https://api.wrenn.dev/v1/api-keys/key-1").respond(204)
|
||||
client.api_keys.delete("key-1")
|
||||
assert route.called
|
||||
return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
|
||||
|
||||
class TestCapsules:
|
||||
@respx.mock
|
||||
def test_create(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
201,
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
202,
|
||||
json={
|
||||
"id": "sb-1",
|
||||
"status": "pending",
|
||||
"status": "starting",
|
||||
"template": "base-python",
|
||||
"vcpus": 2,
|
||||
"memory_mb": 1024,
|
||||
@ -122,19 +48,19 @@ class TestCapsules:
|
||||
resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024)
|
||||
assert isinstance(resp, Capsule)
|
||||
assert resp.id == "sb-1"
|
||||
assert resp.status == Status.pending
|
||||
assert resp.status == Status.starting
|
||||
|
||||
@respx.mock
|
||||
def test_create_defaults(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
201, json={"id": "sb-2", "status": "pending"}
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
202, json={"id": "sb-2", "status": "starting"}
|
||||
)
|
||||
resp = client.capsules.create()
|
||||
assert resp.id == "sb-2"
|
||||
|
||||
@respx.mock
|
||||
def test_list(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/capsules").respond(
|
||||
respx.get(f"{BASE}/v1/capsules").respond(
|
||||
200, json=[{"id": "sb-1", "status": "running"}]
|
||||
)
|
||||
boxes = client.capsules.list()
|
||||
@ -143,7 +69,7 @@ class TestCapsules:
|
||||
|
||||
@respx.mock
|
||||
def test_get(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond(
|
||||
respx.get(f"{BASE}/v1/capsules/sb-1").respond(
|
||||
200, json={"id": "sb-1", "status": "running"}
|
||||
)
|
||||
resp = client.capsules.get("sb-1")
|
||||
@ -151,49 +77,37 @@ class TestCapsules:
|
||||
|
||||
@respx.mock
|
||||
def test_destroy(self, client):
|
||||
route = respx.delete("https://api.wrenn.dev/v1/capsules/sb-1").respond(204)
|
||||
route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(202)
|
||||
client.capsules.destroy("sb-1")
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
def test_usage(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/capsules/usage").respond(
|
||||
200,
|
||||
json={
|
||||
"from": "2026-03-21",
|
||||
"to": "2026-04-20",
|
||||
"points": [
|
||||
{
|
||||
"date": "2026-04-19",
|
||||
"cpu_minutes": 12.5,
|
||||
"ram_mb_minutes": 640.0,
|
||||
},
|
||||
{"date": "2026-04-20", "cpu_minutes": 8.0, "ram_mb_minutes": 512.0},
|
||||
],
|
||||
},
|
||||
def test_pause(self, client):
|
||||
respx.post(f"{BASE}/v1/capsules/sb-1/pause").respond(
|
||||
202, json={"id": "sb-1", "status": "pausing"}
|
||||
)
|
||||
resp = client.capsules.usage()
|
||||
assert isinstance(resp, UsageResponse)
|
||||
assert resp.points is not None
|
||||
assert len(resp.points) == 2
|
||||
assert resp.points[0].cpu_minutes == 12.5
|
||||
resp = client.capsules.pause("sb-1")
|
||||
assert resp.status == Status.pausing
|
||||
|
||||
@respx.mock
|
||||
def test_usage_with_dates(self, client):
|
||||
route = respx.get("https://api.wrenn.dev/v1/capsules/usage").respond(
|
||||
200,
|
||||
json={"from": "2026-04-01", "to": "2026-04-15", "points": []},
|
||||
def test_resume(self, client):
|
||||
respx.post(f"{BASE}/v1/capsules/sb-1/resume").respond(
|
||||
202, json={"id": "sb-1", "status": "resuming"}
|
||||
)
|
||||
client.capsules.usage(from_date="2026-04-01", to_date="2026-04-15")
|
||||
req = route.calls[0].request
|
||||
assert "from=2026-04-01" in str(req.url)
|
||||
assert "to=2026-04-15" in str(req.url)
|
||||
resp = client.capsules.resume("sb-1")
|
||||
assert resp.status == Status.resuming
|
||||
|
||||
@respx.mock
|
||||
def test_ping(self, client):
|
||||
route = respx.post(f"{BASE}/v1/capsules/sb-1/ping").respond(204)
|
||||
client.capsules.ping("sb-1")
|
||||
assert route.called
|
||||
|
||||
|
||||
class TestSnapshots:
|
||||
@respx.mock
|
||||
def test_create(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/snapshots").respond(
|
||||
respx.post(f"{BASE}/v1/snapshots").respond(
|
||||
201,
|
||||
json={"name": "snap-1", "type": "snapshot", "vcpus": 1},
|
||||
)
|
||||
@ -203,7 +117,7 @@ class TestSnapshots:
|
||||
|
||||
@respx.mock
|
||||
def test_create_with_overwrite(self, client):
|
||||
route = respx.post("https://api.wrenn.dev/v1/snapshots").respond(
|
||||
route = respx.post(f"{BASE}/v1/snapshots").respond(
|
||||
201, json={"name": "snap-1", "type": "snapshot"}
|
||||
)
|
||||
client.snapshots.create(capsule_id="sb-1", overwrite=True)
|
||||
@ -212,7 +126,7 @@ class TestSnapshots:
|
||||
|
||||
@respx.mock
|
||||
def test_list(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/snapshots").respond(
|
||||
respx.get(f"{BASE}/v1/snapshots").respond(
|
||||
200, json=[{"name": "base-python", "type": "base"}]
|
||||
)
|
||||
snaps = client.snapshots.list()
|
||||
@ -220,92 +134,22 @@ class TestSnapshots:
|
||||
|
||||
@respx.mock
|
||||
def test_list_with_filter(self, client):
|
||||
route = respx.get("https://api.wrenn.dev/v1/snapshots").respond(200, json=[])
|
||||
route = respx.get(f"{BASE}/v1/snapshots").respond(200, json=[])
|
||||
client.snapshots.list(type="snapshot")
|
||||
req = route.calls[0].request
|
||||
assert "type=snapshot" in str(req.url)
|
||||
|
||||
@respx.mock
|
||||
def test_delete(self, client):
|
||||
route = respx.delete("https://api.wrenn.dev/v1/snapshots/snap-1").respond(204)
|
||||
route = respx.delete(f"{BASE}/v1/snapshots/snap-1").respond(204)
|
||||
client.snapshots.delete("snap-1")
|
||||
assert route.called
|
||||
|
||||
|
||||
class TestHosts:
|
||||
@respx.mock
|
||||
def test_create(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/hosts").respond(
|
||||
201,
|
||||
json={
|
||||
"host": {"id": "h-1", "type": "regular", "status": "pending"},
|
||||
"registration_token": "reg-tok-123",
|
||||
},
|
||||
)
|
||||
resp = client.hosts.create(type="regular")
|
||||
assert isinstance(resp, CreateHostResponse)
|
||||
assert resp.registration_token == "reg-tok-123"
|
||||
|
||||
@respx.mock
|
||||
def test_list(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/hosts").respond(
|
||||
200, json=[{"id": "h-1", "status": "online"}]
|
||||
)
|
||||
hosts = client.hosts.list()
|
||||
assert len(hosts) == 1
|
||||
assert isinstance(hosts[0], Host)
|
||||
|
||||
@respx.mock
|
||||
def test_get(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/hosts/h-1").respond(
|
||||
200, json={"id": "h-1", "status": "online"}
|
||||
)
|
||||
resp = client.hosts.get("h-1")
|
||||
assert resp.id == "h-1"
|
||||
|
||||
@respx.mock
|
||||
def test_delete(self, client):
|
||||
route = respx.delete("https://api.wrenn.dev/v1/hosts/h-1").respond(204)
|
||||
client.hosts.delete("h-1")
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
def test_regenerate_token(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/hosts/h-1/token").respond(
|
||||
201,
|
||||
json={
|
||||
"host": {"id": "h-1"},
|
||||
"registration_token": "new-tok",
|
||||
},
|
||||
)
|
||||
resp = client.hosts.regenerate_token("h-1")
|
||||
assert resp.registration_token == "new-tok"
|
||||
|
||||
@respx.mock
|
||||
def test_list_tags(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/hosts/h-1/tags").respond(
|
||||
200, json=["gpu", "high-mem"]
|
||||
)
|
||||
tags = client.hosts.list_tags("h-1")
|
||||
assert tags == ["gpu", "high-mem"]
|
||||
|
||||
@respx.mock
|
||||
def test_add_tag(self, client):
|
||||
route = respx.post("https://api.wrenn.dev/v1/hosts/h-1/tags").respond(204)
|
||||
client.hosts.add_tag("h-1", "gpu")
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
def test_remove_tag(self, client):
|
||||
route = respx.delete("https://api.wrenn.dev/v1/hosts/h-1/tags/gpu").respond(204)
|
||||
client.hosts.remove_tag("h-1", "gpu")
|
||||
assert route.called
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
@respx.mock
|
||||
def test_validation_error(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
400,
|
||||
json={"error": {"code": "invalid_request", "message": "bad input"}},
|
||||
)
|
||||
@ -316,25 +160,16 @@ class TestErrorHandling:
|
||||
|
||||
@respx.mock
|
||||
def test_auth_error(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/capsules").respond(
|
||||
respx.get(f"{BASE}/v1/capsules").respond(
|
||||
401,
|
||||
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
||||
)
|
||||
with pytest.raises(WrennAuthenticationError):
|
||||
client.capsules.list()
|
||||
|
||||
@respx.mock
|
||||
def test_forbidden_error(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/hosts").respond(
|
||||
403,
|
||||
json={"error": {"code": "forbidden", "message": "nope"}},
|
||||
)
|
||||
with pytest.raises(WrennForbiddenError):
|
||||
client.hosts.create(type="regular")
|
||||
|
||||
@respx.mock
|
||||
def test_not_found_error(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/capsules/nope").respond(
|
||||
respx.get(f"{BASE}/v1/capsules/nope").respond(
|
||||
404,
|
||||
json={"error": {"code": "not_found", "message": "capsule not found"}},
|
||||
)
|
||||
@ -343,32 +178,16 @@ class TestErrorHandling:
|
||||
|
||||
@respx.mock
|
||||
def test_conflict_error(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond(
|
||||
respx.get(f"{BASE}/v1/capsules/sb-1").respond(
|
||||
409,
|
||||
json={"error": {"code": "invalid_state", "message": "not running"}},
|
||||
)
|
||||
with pytest.raises(WrennConflictError):
|
||||
client.capsules.get("sb-1")
|
||||
|
||||
@respx.mock
|
||||
def test_host_has_capsules_error(self, client):
|
||||
respx.delete("https://api.wrenn.dev/v1/hosts/h-1").respond(
|
||||
409,
|
||||
json={
|
||||
"error": {
|
||||
"code": "host_has_capsules",
|
||||
"message": "host has running capsules",
|
||||
},
|
||||
"sandbox_ids": ["sb-1", "sb-2"],
|
||||
},
|
||||
)
|
||||
with pytest.raises(WrennHostHasCapsulesError) as exc_info:
|
||||
client.hosts.delete("h-1")
|
||||
assert exc_info.value.capsule_ids == ["sb-1", "sb-2"]
|
||||
|
||||
@respx.mock
|
||||
def test_agent_error(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
502,
|
||||
json={"error": {"code": "agent_error", "message": "host agent failed"}},
|
||||
)
|
||||
@ -377,7 +196,7 @@ class TestErrorHandling:
|
||||
|
||||
@respx.mock
|
||||
def test_internal_error(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond(
|
||||
respx.get(f"{BASE}/v1/capsules/sb-1").respond(
|
||||
500,
|
||||
json={"error": {"code": "internal_error", "message": "oops"}},
|
||||
)
|
||||
@ -386,7 +205,7 @@ class TestErrorHandling:
|
||||
|
||||
@respx.mock
|
||||
def test_unknown_error_code_falls_back(self, client):
|
||||
respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond(
|
||||
respx.get(f"{BASE}/v1/capsules/sb-1").respond(
|
||||
418,
|
||||
json={"error": {"code": "teapot", "message": "I'm a teapot"}},
|
||||
)
|
||||
@ -398,92 +217,19 @@ class TestErrorHandling:
|
||||
|
||||
|
||||
class TestAuthModes:
|
||||
def test_api_key_only_creates_data_client(self):
|
||||
def test_api_key_header(self):
|
||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
||||
assert c._data_http is not None
|
||||
assert (
|
||||
c._data_http.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
|
||||
)
|
||||
assert c._mgmt_http is None
|
||||
assert c._http.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
|
||||
|
||||
def test_token_only_creates_mgmt_client(self):
|
||||
with WrennClient(token="jwt-token-abc") as c:
|
||||
assert c._mgmt_http is not None
|
||||
assert c._mgmt_http.headers["Authorization"] == "Bearer jwt-token-abc"
|
||||
assert c._data_http is None
|
||||
def test_no_auth_raises(self, monkeypatch):
|
||||
monkeypatch.delenv("WRENN_API_KEY", raising=False)
|
||||
with pytest.raises(ValueError, match="No API key"):
|
||||
WrennClient()
|
||||
|
||||
def test_no_auth_allowed(self):
|
||||
def test_env_var_fallback(self, monkeypatch):
|
||||
monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env")
|
||||
with WrennClient() as c:
|
||||
assert c._data_http is None
|
||||
assert c._mgmt_http is None
|
||||
assert c._public_http is not None
|
||||
|
||||
def test_both_creds_creates_both_clients(self):
|
||||
with WrennClient(
|
||||
api_key="wrn_test1234567890abcdef12345678", token="jwt-abc"
|
||||
) as c:
|
||||
assert c._data_http is not None
|
||||
assert c._mgmt_http is not None
|
||||
|
||||
def test_capsule_ops_require_api_key(self):
|
||||
with WrennClient(token="jwt-abc") as c:
|
||||
with pytest.raises(ValueError, match="API key"):
|
||||
c.capsules.list()
|
||||
|
||||
def test_snapshot_ops_require_api_key(self):
|
||||
with WrennClient(token="jwt-abc") as c:
|
||||
with pytest.raises(ValueError, match="API key"):
|
||||
c.snapshots.list()
|
||||
|
||||
def test_mgmt_ops_require_token(self):
|
||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
||||
with pytest.raises(ValueError, match="JWT token"):
|
||||
c.api_keys.list()
|
||||
with pytest.raises(ValueError, match="JWT token"):
|
||||
c.teams.list()
|
||||
with pytest.raises(ValueError, match="JWT token"):
|
||||
c.hosts.list()
|
||||
with pytest.raises(ValueError, match="JWT token"):
|
||||
c.channels.list()
|
||||
with pytest.raises(ValueError, match="JWT token"):
|
||||
c.users.search("a@b.com")
|
||||
with pytest.raises(ValueError, match="JWT token"):
|
||||
c.account.get()
|
||||
with pytest.raises(ValueError, match="JWT token"):
|
||||
c.auth.switch_team("team-1")
|
||||
|
||||
@respx.mock
|
||||
def test_mgmt_sends_bearer_only(self):
|
||||
route = respx.get("https://api.wrenn.dev/v1/api-keys").respond(200, json=[])
|
||||
with WrennClient(
|
||||
api_key="wrn_test1234567890abcdef12345678", token="jwt-abc"
|
||||
) as c:
|
||||
c.api_keys.list()
|
||||
req = route.calls[0].request
|
||||
assert req.headers["Authorization"] == "Bearer jwt-abc"
|
||||
assert "X-API-Key" not in req.headers
|
||||
|
||||
@respx.mock
|
||||
def test_data_sends_api_key_only(self):
|
||||
route = respx.get("https://api.wrenn.dev/v1/capsules").respond(200, json=[])
|
||||
with WrennClient(
|
||||
api_key="wrn_test1234567890abcdef12345678", token="jwt-abc"
|
||||
) as c:
|
||||
c.capsules.list()
|
||||
req = route.calls[0].request
|
||||
assert req.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
@respx.mock
|
||||
def test_public_sends_no_auth(self):
|
||||
route = respx.post("https://api.wrenn.dev/v1/auth/signup").respond(
|
||||
201, json={"message": "ok"}
|
||||
)
|
||||
with WrennClient() as c:
|
||||
c.auth.signup("a@b.com", "password123", "Test")
|
||||
req = route.calls[0].request
|
||||
assert "X-API-Key" not in req.headers
|
||||
assert "Authorization" not in req.headers
|
||||
assert c._http.headers["X-API-Key"] == "wrn_from_env"
|
||||
|
||||
|
||||
class TestAsyncClient:
|
||||
@ -491,8 +237,8 @@ class TestAsyncClient:
|
||||
@respx.mock
|
||||
async def test_async_capsules_create(self, async_client):
|
||||
async with async_client:
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
201, json={"id": "sb-1", "status": "pending"}
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
202, json={"id": "sb-1", "status": "starting"}
|
||||
)
|
||||
resp = await async_client.capsules.create(template="base-python")
|
||||
assert resp.id == "sb-1"
|
||||
@ -501,25 +247,15 @@ class TestAsyncClient:
|
||||
@respx.mock
|
||||
async def test_async_capsules_list(self, async_client):
|
||||
async with async_client:
|
||||
respx.get("https://api.wrenn.dev/v1/capsules").respond(
|
||||
200, json=[{"id": "sb-1"}]
|
||||
)
|
||||
respx.get(f"{BASE}/v1/capsules").respond(200, json=[{"id": "sb-1"}])
|
||||
boxes = await async_client.capsules.list()
|
||||
assert len(boxes) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_hosts_list(self, async_client):
|
||||
async with async_client:
|
||||
respx.get("https://api.wrenn.dev/v1/hosts").respond(200, json=[])
|
||||
hosts = await async_client.hosts.list()
|
||||
assert hosts == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_error_handling(self, async_client):
|
||||
async with async_client:
|
||||
respx.get("https://api.wrenn.dev/v1/capsules/nope").respond(
|
||||
respx.get(f"{BASE}/v1/capsules/nope").respond(
|
||||
404,
|
||||
json={"error": {"code": "not_found", "message": "not found"}},
|
||||
)
|
||||
|
||||
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"]
|
||||
@ -8,7 +8,6 @@ import pytest
|
||||
import respx
|
||||
|
||||
from wrenn.capsule import Capsule
|
||||
from wrenn.client import WrennClient
|
||||
from wrenn.models import FileEntry
|
||||
from wrenn.pty import (
|
||||
AsyncPtySession,
|
||||
@ -17,25 +16,59 @@ from wrenn.pty import (
|
||||
_parse_pty_event,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
||||
yield c
|
||||
BASE = "https://app.wrenn.dev/api"
|
||||
|
||||
|
||||
def _make_capsule(client: WrennClient, cap_id: str = "cl-abc") -> Capsule:
|
||||
respx.post("https://api.wrenn.dev/v1/capsules").respond(
|
||||
def _make_capsule(cap_id: str = "cl-abc") -> Capsule:
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201, json={"id": cap_id, "status": "running"}
|
||||
)
|
||||
return client.capsules.create()
|
||||
return Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
|
||||
|
||||
class TestListDir:
|
||||
class TestFilesRead:
|
||||
@respx.mock
|
||||
def test_list_dir_returns_entries(self, client):
|
||||
cap = _make_capsule(client)
|
||||
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond(
|
||||
def test_read_returns_string(self):
|
||||
cap = _make_capsule()
|
||||
content = b"file contents here"
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/read").respond(
|
||||
200, content=content
|
||||
)
|
||||
data = cap.files.read("/app/main.py")
|
||||
assert data == "file contents here"
|
||||
|
||||
@respx.mock
|
||||
def test_read_bytes(self):
|
||||
cap = _make_capsule()
|
||||
content = b"\x00\x01\x02"
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/read").respond(
|
||||
200, content=content
|
||||
)
|
||||
data = cap.files.read_bytes("/bin/binary")
|
||||
assert data == b"\x00\x01\x02"
|
||||
|
||||
|
||||
class TestFilesWrite:
|
||||
@respx.mock
|
||||
def test_write_string(self):
|
||||
cap = _make_capsule()
|
||||
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/write").respond(204)
|
||||
cap.files.write("/app/main.py", "print('hello')")
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
def test_write_bytes(self):
|
||||
cap = _make_capsule()
|
||||
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/write").respond(204)
|
||||
cap.files.write("/app/data.bin", b"\x00\x01\x02")
|
||||
assert route.called
|
||||
|
||||
|
||||
class TestFilesList:
|
||||
@respx.mock
|
||||
def test_list_returns_entries(self):
|
||||
cap = _make_capsule()
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||
200,
|
||||
json={
|
||||
"entries": [
|
||||
@ -66,7 +99,7 @@ class TestListDir:
|
||||
]
|
||||
},
|
||||
)
|
||||
entries = cap.list_dir("/home/user")
|
||||
entries = cap.files.list("/home/user")
|
||||
assert len(entries) == 2
|
||||
assert isinstance(entries[0], FileEntry)
|
||||
assert entries[0].name == "main.py"
|
||||
@ -75,57 +108,30 @@ class TestListDir:
|
||||
assert entries[1].type == "directory"
|
||||
|
||||
@respx.mock
|
||||
def test_list_dir_with_depth(self, client):
|
||||
cap = _make_capsule(client)
|
||||
route = respx.post(
|
||||
"https://api.wrenn.dev/v1/capsules/cl-abc/files/list"
|
||||
).respond(200, json={"entries": []})
|
||||
cap.list_dir("/home/user", depth=3)
|
||||
def test_list_with_depth(self):
|
||||
cap = _make_capsule()
|
||||
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||
200, json={"entries": []}
|
||||
)
|
||||
cap.files.list("/home/user", depth=3)
|
||||
body = json.loads(route.calls[0].request.content)
|
||||
assert body["depth"] == 3
|
||||
|
||||
@respx.mock
|
||||
def test_list_dir_empty(self, client):
|
||||
cap = _make_capsule(client)
|
||||
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond(
|
||||
def test_list_empty(self):
|
||||
cap = _make_capsule()
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||
200, json={"entries": []}
|
||||
)
|
||||
entries = cap.list_dir("/empty")
|
||||
entries = cap.files.list("/empty")
|
||||
assert entries == []
|
||||
|
||||
@respx.mock
|
||||
def test_list_dir_symlink(self, client):
|
||||
cap = _make_capsule(client)
|
||||
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond(
|
||||
200,
|
||||
json={
|
||||
"entries": [
|
||||
{
|
||||
"name": "link",
|
||||
"path": "/home/user/link",
|
||||
"type": "symlink",
|
||||
"size": 4,
|
||||
"mode": 41471,
|
||||
"permissions": "lrwxrwxrwx",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"modified_at": 1712899000,
|
||||
"symlink_target": "/bin",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
entries = cap.list_dir("/home/user")
|
||||
assert len(entries) == 1
|
||||
assert entries[0].type == "symlink"
|
||||
assert entries[0].symlink_target == "/bin"
|
||||
|
||||
|
||||
class TestMkdir:
|
||||
class TestFilesMakeDir:
|
||||
@respx.mock
|
||||
def test_mkdir_returns_entry(self, client):
|
||||
cap = _make_capsule(client)
|
||||
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/mkdir").respond(
|
||||
def test_make_dir_returns_entry(self):
|
||||
cap = _make_capsule()
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/mkdir").respond(
|
||||
200,
|
||||
json={
|
||||
"entry": {
|
||||
@ -142,19 +148,19 @@ class TestMkdir:
|
||||
}
|
||||
},
|
||||
)
|
||||
entry = cap.mkdir("/home/user/data")
|
||||
entry = cap.files.make_dir("/home/user/data")
|
||||
assert isinstance(entry, FileEntry)
|
||||
assert entry.name == "data"
|
||||
assert entry.type == "directory"
|
||||
|
||||
@respx.mock
|
||||
def test_mkdir_existing_returns_gracefully(self, client):
|
||||
cap = _make_capsule(client)
|
||||
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/mkdir").respond(
|
||||
def test_make_dir_existing_returns_gracefully(self):
|
||||
cap = _make_capsule()
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/mkdir").respond(
|
||||
409,
|
||||
json={"error": {"code": "conflict", "message": "already exists"}},
|
||||
)
|
||||
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond(
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||
200,
|
||||
json={
|
||||
"entries": [
|
||||
@ -173,52 +179,48 @@ class TestMkdir:
|
||||
]
|
||||
},
|
||||
)
|
||||
entry = cap.mkdir("/home/user/data")
|
||||
entry = cap.files.make_dir("/home/user/data")
|
||||
assert entry.name == "data"
|
||||
|
||||
|
||||
class TestRemove:
|
||||
class TestFilesRemove:
|
||||
@respx.mock
|
||||
def test_remove_succeeds(self, client):
|
||||
cap = _make_capsule(client)
|
||||
route = respx.post(
|
||||
"https://api.wrenn.dev/v1/capsules/cl-abc/files/remove"
|
||||
).respond(204)
|
||||
cap.remove("/home/user/old_data")
|
||||
def test_remove_succeeds(self):
|
||||
cap = _make_capsule()
|
||||
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204)
|
||||
cap.files.remove("/home/user/old_data")
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
def test_remove_sends_path(self, client):
|
||||
cap = _make_capsule(client)
|
||||
route = respx.post(
|
||||
"https://api.wrenn.dev/v1/capsules/cl-abc/files/remove"
|
||||
).respond(204)
|
||||
cap.remove("/tmp/test.txt")
|
||||
def test_remove_sends_path(self):
|
||||
cap = _make_capsule()
|
||||
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204)
|
||||
cap.files.remove("/tmp/test.txt")
|
||||
body = json.loads(route.calls[0].request.content)
|
||||
assert body["path"] == "/tmp/test.txt"
|
||||
|
||||
|
||||
class TestUpload:
|
||||
class TestFilesExists:
|
||||
@respx.mock
|
||||
def test_upload_sends_multipart(self, client):
|
||||
cap = _make_capsule(client)
|
||||
route = respx.post(
|
||||
"https://api.wrenn.dev/v1/capsules/cl-abc/files/write"
|
||||
).respond(204)
|
||||
cap.upload("/app/main.py", b"print('hello')")
|
||||
assert route.called
|
||||
req = route.calls[0].request
|
||||
assert b"multipart/form-data" in req.headers.get("content-type", "").encode()
|
||||
def test_exists_true(self):
|
||||
cap = _make_capsule()
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||
200,
|
||||
json={
|
||||
"entries": [
|
||||
{"name": "hello.txt", "path": "/tmp/hello.txt", "type": "file"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert cap.files.exists("/tmp/hello.txt") is True
|
||||
|
||||
@respx.mock
|
||||
def test_download_returns_bytes(self, client):
|
||||
cap = _make_capsule(client)
|
||||
content = b"file contents here"
|
||||
respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/read").respond(
|
||||
200, content=content
|
||||
def test_exists_false(self):
|
||||
cap = _make_capsule()
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||
200, json={"entries": []}
|
||||
)
|
||||
data = cap.download("/app/main.py")
|
||||
assert data == content
|
||||
assert cap.files.exists("/tmp/nope.txt") is False
|
||||
|
||||
|
||||
class TestPtyEventParsing:
|
||||
@ -254,11 +256,6 @@ class TestPtyEventParsing:
|
||||
assert event.data == "process not found"
|
||||
assert event.fatal is True
|
||||
|
||||
def test_error_event_non_fatal(self):
|
||||
raw = {"type": "error", "data": "something", "fatal": False}
|
||||
event = _parse_pty_event(raw)
|
||||
assert event.fatal is False
|
||||
|
||||
def test_ping_event(self):
|
||||
raw = {"type": "ping"}
|
||||
event = _parse_pty_event(raw)
|
||||
@ -314,12 +311,14 @@ class TestPtySessionIteration:
|
||||
ws.receive_text.side_effect = messages
|
||||
session = PtySession(ws, "cl-abc")
|
||||
events = list(session)
|
||||
assert len(events) == 2
|
||||
assert len(events) == 3
|
||||
assert events[0].type == PtyEventType.started
|
||||
assert session.tag == "pty-abc12345"
|
||||
assert session.pid == 1
|
||||
assert events[1].type == PtyEventType.output
|
||||
assert events[1].data == b"hello"
|
||||
assert events[2].type == PtyEventType.exit
|
||||
assert events[2].exit_code == 0
|
||||
|
||||
def test_iter_stops_on_fatal_error(self):
|
||||
ws = MagicMock()
|
||||
@ -342,6 +341,39 @@ class TestPtySessionIteration:
|
||||
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:
|
||||
def test_exit_kills_and_closes(self):
|
||||
ws = MagicMock()
|
||||
@ -385,9 +417,6 @@ class TestPtySessionSendStart:
|
||||
assert sent["cmd"] == "/bin/zsh"
|
||||
assert sent["args"] == ["-l"]
|
||||
assert sent["cols"] == 120
|
||||
assert sent["rows"] == 40
|
||||
assert sent["envs"] == {"TERM": "xterm-256color"}
|
||||
assert sent["cwd"] == "/home/user"
|
||||
|
||||
|
||||
class TestPtySessionSendConnect:
|
||||
@ -453,16 +482,28 @@ class TestAsyncPtySession:
|
||||
assert sent["type"] == "start"
|
||||
assert sent["cmd"] == "/bin/zsh"
|
||||
assert sent["cols"] == 100
|
||||
assert sent["rows"] == 30
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_send_connect(self):
|
||||
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")
|
||||
await session._send_connect("pty-abc12345")
|
||||
sent = json.loads(ws.send_text.call_args[0][0])
|
||||
assert sent["type"] == "connect"
|
||||
assert sent["tag"] == "pty-abc12345"
|
||||
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
|
||||
async def test_async_iteration(self):
|
||||
@ -477,10 +518,11 @@ class TestAsyncPtySession:
|
||||
events = []
|
||||
async for event in session:
|
||||
events.append(event)
|
||||
assert len(events) == 2
|
||||
assert len(events) == 3
|
||||
assert events[0].type == PtyEventType.started
|
||||
assert session.tag == "pty-xyz"
|
||||
assert session.pid == 5
|
||||
assert events[2].type == PtyEventType.exit
|
||||
|
||||
|
||||
class TestExports:
|
||||
|
||||
1137
tests/test_git.py
Normal file
1137
tests/test_git.py
Normal file
File diff suppressed because it is too large
Load Diff
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"
|
||||
472
uv.lock
generated
472
uv.lock
generated
@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
@ -72,6 +72,72 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfgv"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.2"
|
||||
@ -93,6 +159,46 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "databind"
|
||||
version = "4.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecated" },
|
||||
{ name = "nr-date" },
|
||||
{ name = "nr-stream" },
|
||||
{ name = "typeapi" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/9e/835a5211eeb7228a0e3870d54def48dd7951dbd951f51b30900020a5f9fc/databind-4.5.4.tar.gz", hash = "sha256:342e170a219b1661e5c1b20778b532aecfa67e46560ba75beb7e2c6faa2150b5", size = 43193, upload-time = "2026-04-02T22:21:47.318Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/db/3b8eb860b5baef89b72c7aadcc5072e1648ca0c98d6ba4b9e4eabbdc2cf5/databind-4.5.4-py3-none-any.whl", hash = "sha256:78467f874a3e80bcd1d3de349167587a0d369831bc64c03798520be86074f96e", size = 52381, upload-time = "2026-04-02T22:21:45.389Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "databind-core"
|
||||
version = "4.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "databind" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/dc/b63a6f6a404146e8e3f1226c9243a5cb30784a1f75218d014cafce9a411f/databind_core-4.5.4.tar.gz", hash = "sha256:a7a47af183d4a8046c893fc19fa9c085f287a15e57a05e58345016086ce0f807", size = 974, upload-time = "2026-04-02T22:21:56.588Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/cf/1d1f4d37b4112f26ea5086d54200837d1dcbddaa536f3a70bb1d8b48ed9a/databind_core-4.5.4-py3-none-any.whl", hash = "sha256:25482c352f4f6fcade7c106c665553a18febeccda2972c00cf5af19f473960ab", size = 1666, upload-time = "2026-04-02T22:21:55.504Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "databind-json"
|
||||
version = "4.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "databind" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/72/9af59950a23ff6a03062acd85879de289595168ec43d6cec57253d00497c/databind_json-4.5.4.tar.gz", hash = "sha256:2c714f9c3039a81e42fc3826e47d7826ef020d93131c34daf4c9ae0483108e4d", size = 966, upload-time = "2026-04-02T22:22:05.538Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/d4/e00d531202314e29d90c9496f6b4730e3647128b9866180b8ce8ebb79394/databind_json-4.5.4-py3-none-any.whl", hash = "sha256:22e6faaeb6f2ec5cf815fd597a539dfe1f4846b80b618760112f4fe59a0898cc", size = 1664, upload-time = "2026-04-02T22:22:04.204Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "datamodel-code-generator"
|
||||
version = "0.56.0"
|
||||
@ -112,6 +218,32 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/3a/7f169ffc7a2d69a4f9158b1ac083f685b7f4a1a8a1db5d1e4abbb4e741b7/datamodel_code_generator-0.56.0-py3-none-any.whl", hash = "sha256:a0559683fbe90cdf2ce9b6637e3adae3e3a8056a8d0516df581d486e2834ead2", size = 256545, upload-time = "2026-04-04T09:46:17.582Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
ruff = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deprecated"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.8.0"
|
||||
@ -121,6 +253,40 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docspec"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "databind-core" },
|
||||
{ name = "databind-json" },
|
||||
{ name = "deprecated" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3c/39/7a71382107445b2cd50c67c6194e3e584f19748a817c3b29e8be8a14f00f/docspec-2.2.1.tar.gz", hash = "sha256:4854e77edc0e2de40e785e57e95880f7095a05fe978f8b54cef7a269586e15ff", size = 8646, upload-time = "2023-05-28T11:24:18.68Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/aa/0c9d71cc9d450afd3993d09835e2910810a45b0703f585e1aee1d9b78969/docspec-2.2.1-py3-none-any.whl", hash = "sha256:7538f750095a9688c6980ff9a4e029a823a500f64bd00b6b4bdb27951feb31cb", size = 9844, upload-time = "2023-05-28T11:24:15.419Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docspec-python"
|
||||
version = "2.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "black" },
|
||||
{ name = "docspec" },
|
||||
{ name = "nr-util" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ea/ea/e6d9d9c2f805c6ac8072d0e3ee5b1da2dd61886c662327df937dec9f282c/docspec_python-2.2.2.tar.gz", hash = "sha256:429be834d09549461b95bf45eb53c16859f3dfb3e9220408b3bfb12812ccb3fb", size = 22154, upload-time = "2025-05-06T12:40:33.286Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/c2/b3226746fb6b91893da270a60e77bb420d59cf33a7b9a4e719a236955971/docspec_python-2.2.2-py3-none-any.whl", hash = "sha256:caa32dc1e8c470af8a5ecad67cca614e68c1563ac01dab0c0486c4d7f709d6b1", size = 15988, upload-time = "2025-05-06T12:40:31.554Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docstring-parser"
|
||||
version = "0.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/ce/5d6a3782b9f88097ce3e579265015db3372ae78d12f67629b863a9208c96/docstring_parser-0.11.tar.gz", hash = "sha256:93b3f8f481c7d24e37c5d9f30293c89e2933fa209421c8abd731dd3ef0715ecb", size = 22775, upload-time = "2021-09-30T07:44:10.288Z" }
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.3.0"
|
||||
@ -134,6 +300,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.29.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "genson"
|
||||
version = "1.3.0"
|
||||
@ -195,6 +370,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/f8/a6bc80313a9e93c888fa10534dfce2ad76ff86911b6f485777ce6de6a073/httpx_ws-0.9.0-py3-none-any.whl", hash = "sha256:71640d2fb1bf9a225775015b33cd755cfd4c5f7e21c885192fe3adc4c387b248", size = 15759, upload-time = "2026-03-28T14:11:11.887Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.6.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@ -400,6 +584,46 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nr-date"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a0/92/08110dd3d7ff5e2b852a220752eb6c40183839f5b7cc91f9f38dd2298e7d/nr_date-2.1.0.tar.gz", hash = "sha256:0643aea13bcdc2a8bc56af9d5e6a89ef244c9744a1ef00cdc735902ba7f7d2e6", size = 8789, upload-time = "2023-08-16T13:46:04.114Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/10/1d2b00172537c1522fe64bbc6fb16b015632a02f7b3864e788ccbcb4dd85/nr_date-2.1.0-py3-none-any.whl", hash = "sha256:bd672a9dfbdcf7c4b9289fea6750c42490eaee08036a72059dcc78cb236ed568", size = 10496, upload-time = "2023-08-16T13:46:02.627Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nr-stream"
|
||||
version = "1.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b7/37/e4d36d852c441233c306c5fbd98147685dce3ac9b0a8bbf4a587d0ea29ea/nr_stream-1.1.5.tar.gz", hash = "sha256:eb0216c6bfc61a46d4568dba3b588502c610ec8ddef4ac98f3932a2bd7264f65", size = 10053, upload-time = "2023-02-14T22:44:09.074Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/e1/f93485fe09aa36c0e1a3b76363efa1791241f7f863a010f725c95e8a74fe/nr_stream-1.1.5-py3-none-any.whl", hash = "sha256:47e12150b331ad2cb729cfd9d2abd281c9949809729ba461c6aa87dd9927b2d4", size = 10448, upload-time = "2023-02-14T22:44:07.72Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nr-util"
|
||||
version = "0.8.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecated" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/0c/078c567d95e25564bc1ede3c2cf6ce1c91f50648c83786354b47224326da/nr.util-0.8.12.tar.gz", hash = "sha256:a4549c2033d99d2f0379b3f3d233fd2a8ade286bbf0b3ad0cc7cea16022214f4", size = 63707, upload-time = "2022-06-20T13:29:29.192Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/58/eab08df9dbd69d9e21fc5e7be6f67454f386336ec71e6b64e378a2dddea4/nr.util-0.8.12-py3-none-any.whl", hash = "sha256:91da02ac9795eb8e015372275c1efe54bac9051231ee9b0e7e6f96b0b4e7d2bb", size = 90319, upload-time = "2022-06-20T13:29:27.312Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
@ -436,6 +660,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "4.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cfgv" },
|
||||
{ name = "identify" },
|
||||
{ name = "nodeenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "virtualenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
@ -504,6 +744,31 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydoc-markdown"
|
||||
version = "4.8.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "databind-core" },
|
||||
{ name = "databind-json" },
|
||||
{ name = "docspec" },
|
||||
{ name = "docspec-python" },
|
||||
{ name = "docstring-parser" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "nr-util" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "tomli" },
|
||||
{ name = "tomli-w" },
|
||||
{ name = "watchdog" },
|
||||
{ name = "yapf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/8a/2c7f7ad656d22371a596d232fc140327b958d7f1d491b889632ea0cb7e87/pydoc_markdown-4.8.2.tar.gz", hash = "sha256:fb6c927e31386de17472d42f9bd3d3be2905977d026f6216881c65145aa67f0b", size = 44506, upload-time = "2023-06-26T12:37:01.152Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/5a/ce0b056d9a95fd0c06a6cfa5972477d79353392d19230c748a7ba5a9df04/pydoc_markdown-4.8.2-py3-none-any.whl", hash = "sha256:203f74119e6bb2f9deba43d452422de7c8ec31955b61e0620fa4dd8c2611715f", size = 67830, upload-time = "2023-06-26T12:36:59.502Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
@ -541,6 +806,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-discovery"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "filelock" },
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytokens"
|
||||
version = "0.4.1"
|
||||
@ -601,6 +879,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.33.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "respx"
|
||||
version = "0.23.1"
|
||||
@ -638,6 +931,63 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli-w"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeapi"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/92/5a23ad34aa877edf00906166e339bfdc571543ea183ea7ab727bb01516c7/typeapi-2.3.0.tar.gz", hash = "sha256:a60d11f72c5ec27338cfd1c807f035b0b16ed2e3b798fb1c1d34fc5589f544be", size = 122687, upload-time = "2025-10-23T13:44:11.26Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/84/021bbeb7edb990dd6875cb6ab08d32faaa49fec63453d863730260a01f9e/typeapi-2.3.0-py3-none-any.whl", hash = "sha256:576b7dcb94412e91c5cae107a393674f8f99c10a24beb8be2302e3fed21d5cc2", size = 26858, upload-time = "2025-10-23T13:44:09.833Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeguard"
|
||||
version = "4.5.1"
|
||||
@ -671,9 +1021,107 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "21.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "distlib" },
|
||||
{ name = "filelock" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "python-discovery" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/8b/6331f7a7fe70131c301106ec1e7cf23e2501bf7d4ca3636805801ca191bb/virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e", size = 7614069, upload-time = "2026-04-27T17:05:58.927Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/eb/03bfb1299d4c4510329e470f13f9a4ce793df7fcb5a2fd3510f911066f61/virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", size = 7594690, upload-time = "2026-04-27T17:05:55.468Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchdog"
|
||||
version = "6.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wrapt"
|
||||
version = "2.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wrenn"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "email-validator" },
|
||||
@ -684,8 +1132,10 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "datamodel-code-generator" },
|
||||
{ name = "datamodel-code-generator", extra = ["ruff"] },
|
||||
{ name = "mypy" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pydoc-markdown" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "respx" },
|
||||
@ -702,8 +1152,10 @@ requires-dist = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "datamodel-code-generator", specifier = ">=0.56.0" },
|
||||
{ name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" },
|
||||
{ name = "mypy", specifier = ">=1.20.0" },
|
||||
{ name = "pre-commit", specifier = ">=4.6.0" },
|
||||
{ name = "pydoc-markdown", specifier = ">=4.8.2" },
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||
{ name = "respx", specifier = ">=0.23.1" },
|
||||
@ -721,3 +1173,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yapf"
|
||||
version = "0.43.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user