Compare commits
1 Commits
v0.1.2
...
feat/clien
| Author | SHA1 | Date | |
|---|---|---|---|
| f51a962fff |
24
.github/workflows/release.yml
vendored
24
.github/workflows/release.yml
vendored
@ -1,24 +0,0 @@
|
||||
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
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -175,9 +175,3 @@ cython_debug/
|
||||
.pypirc
|
||||
|
||||
CODE_EXECUTION.md
|
||||
|
||||
.opencode/
|
||||
# AI
|
||||
.code-review-graph/
|
||||
.claude
|
||||
.mcp.json
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
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,24 +0,0 @@
|
||||
when:
|
||||
event: pull_request
|
||||
branch:
|
||||
- main
|
||||
- dev
|
||||
path:
|
||||
- "src/**"
|
||||
- "tests/**"
|
||||
|
||||
steps:
|
||||
unit-tests:
|
||||
image: ghcr.io/astral-sh/uv:python3.13-bookworm
|
||||
commands:
|
||||
- uv sync --dev
|
||||
- uv run pytest -m "not integration" -v
|
||||
|
||||
integration-tests:
|
||||
image: ghcr.io/astral-sh/uv:python3.13-bookworm
|
||||
environment:
|
||||
WRENN_API_KEY:
|
||||
from_secret: WRENN_API_KEY
|
||||
commands:
|
||||
- uv sync --dev
|
||||
- uv run pytest -m integration -v
|
||||
278
AGENTS.md
278
AGENTS.md
@ -1,56 +1,252 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project
|
||||
This file provides strict guidance to AI coding agents and assistants when modifying code in the `wrenn-python-sdk` repository. Read this entirely before writing or refactoring any code.
|
||||
|
||||
Wrenn Python SDK — a client library for the Wrenn microVM platform. e2b drop-in replacement.
|
||||
Package name: `wrenn`. Python 3.13+, managed with [uv](https://docs.astral.sh/uv/).
|
||||
## Project Overview
|
||||
|
||||
## Commands
|
||||
This is the official Python SDK for **Wrenn**, a microVM-based code execution platform. The SDK provides developers and AI agents with a clean, typed interface to interact with the Wrenn Control Plane over REST and WebSockets.
|
||||
|
||||
```bash
|
||||
uv sync # install deps
|
||||
make lint # ruff check + format check (no auto-fix)
|
||||
make test # unit tests only (tests/test_client.py)
|
||||
make test-integration # all tests including integration (needs live server)
|
||||
make generate # regenerate models from OpenAPI spec (fetches from remote)
|
||||
make check # lint + unit test
|
||||
**Important:** The SDK communicates exclusively with the Control Plane over HTTP/HTTPS and WebSockets. It does **not** generate or use gRPC stubs. The `envd` guest agent and `HostAgentService` are internal RPCs between the control plane and host agents — they are never reachable from the SDK. All data-plane operations (exec, file I/O) are proxied through the control plane's REST/WS endpoints.
|
||||
|
||||
## Repository Architecture & Structure
|
||||
|
||||
This is a modern Python package managed entirely by `uv`. It uses a flattened `src/` layout.
|
||||
|
||||
```text
|
||||
.
|
||||
├── LICENSE
|
||||
├── Makefile # Central command runner
|
||||
├── pyproject.toml # uv dependency and build config
|
||||
├── uv.lock # Exact dependency resolution
|
||||
├── internal/
|
||||
│ └── api/
|
||||
│ └── openapi.yaml # Cached OpenAPI spec from the Go backend
|
||||
├── src/
|
||||
│ └── wrenn/ # The actual importable Python package
|
||||
│ ├── __init__.py # Version + top-level re-exports
|
||||
│ ├── client.py # WrennClient & AsyncWrennClient (httpx transport)
|
||||
│ ├── sandbox.py # Sandbox class (exec, files, context manager)
|
||||
│ ├── exceptions.py # Typed exception hierarchy
|
||||
│ ├── py.typed # PEP 561 marker
|
||||
│ └── models/
|
||||
│ ├── __init__.py # Public re-exports via __all__
|
||||
│ └── _generated.py # DO NOT EDIT — generated by datamodel-codegen
|
||||
└── tests/ # Pytest suite
|
||||
```
|
||||
|
||||
- `make test` only runs `tests/test_client.py`, not all unit tests. To run a specific test file: `uv run pytest tests/test_capsule_features.py -v`
|
||||
- No typecheck step in Makefile or CI. `mypy` is a dev dependency but not wired up — do not assume it runs.
|
||||
## Build & Development Commands
|
||||
|
||||
## Architecture
|
||||
Never use raw `pip`, `venv`, or `python -m venv`. **All dependency management and script execution goes through `uv` and the `Makefile`.**
|
||||
|
||||
- `src/wrenn/` — the library package
|
||||
- `capsule.py` / `async_capsule.py` — high-level `Capsule` / `AsyncCapsule` (main user-facing classes)
|
||||
- `client.py` — low-level `WrennClient` / `AsyncWrennClient`
|
||||
- `commands.py` — command execution and streaming
|
||||
- `files.py` — filesystem operations
|
||||
- `pty.py` — interactive terminal (PTY) over WebSocket
|
||||
- `exceptions.py` — typed error hierarchy (`WrennError` base)
|
||||
- `models/_generated.py` — **auto-generated** from OpenAPI spec via `datamodel-codegen` (never edit directly; run `make generate`)
|
||||
- `sandbox.py` — deprecated `Sandbox` alias for `Capsule`
|
||||
- `code_interpreter/` — specialized capsule for stateful Jupyter kernel execution
|
||||
- `tests/` — unit tests use `respx` to mock `httpx`; integration tests are in `tests/integration/`
|
||||
- `api/openapi.yaml` — downloaded OpenAPI spec used for code generation
|
||||
```bash
|
||||
make generate # Fetches openapi.yaml and runs datamodel-codegen → models/_generated.py
|
||||
make lint # Runs ruff check and ruff format
|
||||
make test # Runs pytest
|
||||
make check # Runs lint + test
|
||||
```
|
||||
|
||||
## Key Conventions
|
||||
There is no `make proto`. The SDK does not generate gRPC stubs — the `envd` and `HostAgentService` protos are internal to the Go backend.
|
||||
|
||||
- Generated code lives in `src/wrenn/models/_generated.py`. Never edit it. Run `make generate` to update.
|
||||
- `Sandbox` is a deprecated alias for `Capsule`. New code should use `Capsule` / `AsyncCapsule`.
|
||||
- Dual sync/async API: every major class has an `Async` counterpart.
|
||||
- Uses `httpx` for HTTP, `httpx-ws` for WebSockets, `pydantic` for models.
|
||||
- `__init__.py` uses `__getattr__` for lazy deprecated aliases (`Sandbox`, `WrennHostHasSandboxesError`).
|
||||
## Dependency Management (`uv`)
|
||||
|
||||
## Testing
|
||||
- **Adding a runtime dependency:** `uv add <package>` (e.g., `uv add httpx pydantic`)
|
||||
- **Adding a dev dependency:** `uv add --dev <package>` (e.g., `uv add --dev pytest ruff`)
|
||||
- **Running isolated scripts:** Use `uv run <command>`. `uv` implicitly manages the `.venv`; do not try to manually activate it in automation scripts.
|
||||
|
||||
- Unit tests mock HTTP via `respx` (httpx mocking library).
|
||||
- Integration tests require env vars: `WRENN_API_KEY` (or `WRENN_TOKEN`), optionally `WRENN_BASE_URL`.
|
||||
- Integration test fixtures in `tests/integration/conftest.py` create real capsules and clean them up.
|
||||
- `pytest` marker: `@pytest.mark.integration` for tests needing a live server.
|
||||
## Code Generation Invariants (CRITICAL)
|
||||
|
||||
## CI
|
||||
The data models for this SDK are generated directly from the Go backend's OpenAPI contract (`internal/api/openapi.yaml`).
|
||||
|
||||
Woodpecker CI (`.woodpecker/check.yml`) runs on push to `main` and `dev`:
|
||||
1. `make lint`
|
||||
2. `make test` (unit tests only — integration tests are not in CI)
|
||||
1. **Never manually edit `src/wrenn/models/_generated.py`.** Any custom logic placed here will be destroyed on the next `make generate`.
|
||||
2. If the Go API contract changes, run `make generate`.
|
||||
3. **Export routing:** The `_generated.py` file is large. Users must never import from it directly. All user-facing models must be explicitly re-exported in `src/wrenn/models/__init__.py` using the `__all__` dunder list.
|
||||
4. **Extending models:** If a generated Pydantic model needs custom Python methods, subclass it in a new file (e.g., `src/wrenn/sandbox.py` extends the generated `Sandbox` model) and export the subclass.
|
||||
|
||||
## Authentication
|
||||
|
||||
The SDK supports two authentication mechanisms, set via the `WrennClient` constructor:
|
||||
|
||||
1. **API Key (primary):** Pass `api_key="wrn_..."` to the constructor. Sent as `X-API-Key` header. Format: `wrn_` + 32 hex chars. Used for programmatic/agent access.
|
||||
2. **JWT (secondary):** Pass `token="<jwt>"` to the constructor. Sent as `Authorization: Bearer <jwt>` header. Used for user-facing tooling. Tokens expire after 6 hours.
|
||||
|
||||
Host tokens (`X-Host-Token`) are for the host agent binary only and are **not** exposed in the SDK.
|
||||
|
||||
```python
|
||||
client = WrennClient(api_key="wrn_ab12cd34...") # typical usage
|
||||
client = WrennClient(token="eyJhbGci...") # alternative
|
||||
```
|
||||
|
||||
## Core SDK Design Patterns
|
||||
|
||||
### 1. Sync and Async Parity
|
||||
|
||||
The SDK must natively support both synchronous and asynchronous workflows.
|
||||
- Core logic lives in `WrennClient` and `AsyncWrennClient` inside `client.py`.
|
||||
- Under the hood, rely on `httpx.Client` and `httpx.AsyncClient`.
|
||||
- Resource namespaces are injected via constructor.
|
||||
|
||||
### 2. Resource Namespaces
|
||||
|
||||
The client exposes resources as plural namespaces matching the API path convention:
|
||||
|
||||
```python
|
||||
client = WrennClient(api_key="wrn_...")
|
||||
client.sandboxes.create(template="base-python")
|
||||
client.sandboxes.list()
|
||||
client.snapshots.create(sandbox_id="cl-...")
|
||||
client.api_keys.create(name="my-key")
|
||||
client.hosts.list()
|
||||
client.teams.list()
|
||||
client.audit.list(limit=50)
|
||||
client.builds.list() # admin-only
|
||||
```
|
||||
|
||||
### 3. The Sandbox Class
|
||||
|
||||
The `Sandbox` object is the primary developer-facing interface. It wraps the generated `Sandbox` model with lifecycle and data-plane methods:
|
||||
|
||||
```python
|
||||
with client.sandboxes.create("base-python") as sb:
|
||||
sb.wait_ready(timeout=30)
|
||||
|
||||
result = sb.exec("echo hello")
|
||||
print(result.stdout) # "hello\n"
|
||||
print(result.exit_code) # 0
|
||||
|
||||
sb.upload("/app/main.py", b"print('hello')")
|
||||
data = sb.download("/app/main.py")
|
||||
|
||||
sb.ping()
|
||||
sb.pause()
|
||||
sb.resume()
|
||||
# Exiting the block automatically calls sb.destroy()
|
||||
```
|
||||
|
||||
**Key methods:**
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `sb.exec(cmd)` | `POST /v1/sandboxes/{id}/exec` | Synchronous exec. Returns `ExecResult` with `stdout`, `stderr`, `exit_code`, `duration_ms`. |
|
||||
| `sb.exec_stream(cmd)` | `WS GET /v1/sandboxes/{id}/exec/stream` | Streaming exec via WebSocket. Returns an `Iterator[StreamEvent]` yielding `start`, `stdout`, `stderr`, `exit`, `error` events. |
|
||||
| `sb.upload(path, data)` | `POST /v1/sandboxes/{id}/files/write` | Upload a small file (multipart form-data). |
|
||||
| `sb.download(path)` | `POST /v1/sandboxes/{id}/files/read` | Download a small file. Returns bytes. |
|
||||
| `sb.stream_upload(path, stream)` | `POST /v1/sandboxes/{id}/files/stream/write` | Streaming multipart upload for large files. No in-memory buffering. |
|
||||
| `sb.stream_download(path)` | `POST /v1/sandboxes/{id}/files/stream/read` | Streaming chunked download for large files. Returns `Iterator[bytes]`. |
|
||||
| `sb.wait_ready(timeout=30)` | Polls `GET /v1/sandboxes/{id}` | Blocks until status is `running`. Raises `TimeoutError` on expiry. |
|
||||
| `sb.ping()` | `POST /v1/sandboxes/{id}/ping` | Resets inactivity timer. |
|
||||
| `sb.pause()` | `POST /v1/sandboxes/{id}/pause` | Snapshots and releases resources. |
|
||||
| `sb.resume()` | `POST /v1/sandboxes/{id}/resume` | Restores from snapshot. |
|
||||
| `sb.destroy()` | `DELETE /v1/sandboxes/{id}` | Tears down the sandbox. Called automatically by context manager. |
|
||||
| `sb.metrics(range="10m")` | `GET /v1/sandboxes/{id}/metrics` | Returns CPU, memory, disk time-series. |
|
||||
| `sb.run_code(code, language="python")` | Jupyter kernel via proxy WS | Stateful code execution in any language with a Jupyter kernel. Variables persist across calls. Returns `CodeResult` with `.text`, `.stdout`, `.stderr`, `.error`, `.data`. See `CODE_EXECUTION.md`. |
|
||||
|
||||
### 4. Context Managers
|
||||
|
||||
Sandboxes are ephemeral. The SDK must use context managers (`with` and `async with`) to guarantee cleanup:
|
||||
|
||||
```python
|
||||
with client.sandboxes.create("base-python") as sb:
|
||||
sb.wait_ready(timeout=30)
|
||||
result = sb.exec("python -c 'print(42)'")
|
||||
# __exit__ calls sb.destroy() / DELETE /v1/sandboxes/{id}
|
||||
```
|
||||
|
||||
### 5. Streaming Executions
|
||||
|
||||
There are two distinct exec endpoints:
|
||||
|
||||
**Synchronous exec** — `sb.exec(cmd, args=[], timeout_sec=30)`
|
||||
- Calls `POST /v1/sandboxes/{id}/exec`. Blocks until the command completes.
|
||||
- Returns an `ExecResult` with `stdout`, `stderr`, `exit_code`, `duration_ms`, `encoding`.
|
||||
|
||||
**Streaming exec** — `sb.exec_stream(cmd, args=[])`
|
||||
- Opens a WebSocket to `GET /v1/sandboxes/{id}/exec/stream`.
|
||||
- Returns an `Iterator[StreamEvent]` (or `AsyncIterator[StreamEvent]` for async).
|
||||
- The client sends `{"type": "start", "cmd": "...", "args": [...]}` as the first message.
|
||||
- The server sends events: `StreamStartEvent(pid)`, `StreamStdoutEvent(data)`, `StreamStderrEvent(data)`, `StreamExitEvent(exit_code)`, `StreamErrorEvent(data)`.
|
||||
- The connection closes after the process exits. The client can send `{"type": "stop"}` to terminate early.
|
||||
|
||||
### 6. Error Handling
|
||||
|
||||
Do not leak raw `httpx.HTTPStatusError` to the user. The server returns errors as:
|
||||
|
||||
```json
|
||||
{"error": {"code": "not_found", "message": "sandbox not found"}}
|
||||
```
|
||||
|
||||
Map the `code` field (not just HTTP status) to typed exceptions:
|
||||
|
||||
| Error code | HTTP status | Exception |
|
||||
|-----------|-------------|-----------|
|
||||
| `invalid_request` | 400 | `WrennValidationError` |
|
||||
| `unauthorized` | 401 | `WrennAuthenticationError` |
|
||||
| `forbidden` | 403 | `WrennForbiddenError` |
|
||||
| `not_found` | 404 | `WrennNotFoundError` |
|
||||
| `invalid_state` | 409 | `WrennConflictError` |
|
||||
| `conflict` | 409 | `WrennConflictError` |
|
||||
| `host_has_sandboxes` | 409 | `WrennHostHasSandboxesError` (includes `sandbox_ids`) |
|
||||
| `host_unavailable` | 503 | `WrennHostUnavailableError` |
|
||||
| `agent_error` | 502 | `WrennAgentError` |
|
||||
| `internal_error` | 500 | `WrennInternalError` |
|
||||
|
||||
All exceptions inherit from `WrennError` and expose `.code`, `.message`, and `.status_code`.
|
||||
|
||||
### 7. Resource Coverage
|
||||
|
||||
The full API surface exposed through resource namespaces:
|
||||
|
||||
**`client.sandboxes`** — `create`, `list`, `get`, `destroy`, `get_stats`
|
||||
**`client.snapshots`** — `create`, `list`, `delete`
|
||||
**`client.api_keys`** — `create`, `list`, `delete`
|
||||
**`client.hosts`** — `create`, `list`, `get`, `delete`, `delete_preview`, `regenerate_token`, `list_tags`, `add_tag`, `remove_tag`
|
||||
**`client.teams`** — `list`, `create`, `get`, `rename`, `delete`, `list_members`, `add_member`, `update_member_role`, `remove_member`, `leave`
|
||||
**`client.audit`** — `list` (paginated with `before`/`before_id` cursors)
|
||||
**`client.builds`** — `create`, `list`, `get`, `cancel` (admin-only)
|
||||
**`client.admin`** — `set_team_byoc`, `list_templates`, `delete_template`
|
||||
|
||||
### 8. Sandbox Proxy / Port Forwarding
|
||||
|
||||
Services running inside a sandbox are accessible via a reverse proxy. The control plane intercepts requests whose `Host` header matches `{port}-{sandbox_id}.{domain}` and forwards them to the host agent.
|
||||
|
||||
The SDK exposes two helpers on the `Sandbox` object:
|
||||
|
||||
**`sb.get_url(port) -> str`**
|
||||
- Constructs the proxy URL from the client's `base_url`.
|
||||
- Derivation: parse `base_url` host, build `http://{port}-{sandbox_id}.{host}`.
|
||||
- Example: `base_url="https://api.wrenn.dev"`, `sb.id="cl-abc123"` → `"http://8888-cl-abc123.api.wrenn.dev"`
|
||||
- Example: `base_url="http://localhost:8080"`, `sb.id="cl-abc123"` → `"http://8888-cl-abc123.localhost:8080"`
|
||||
|
||||
**`sb.http_client -> httpx.Client`**
|
||||
- A pre-configured `httpx.Client` with:
|
||||
- `base_url` set to the proxy URL (root `/` maps to the proxied service)
|
||||
- `X-API-Key` header set from the parent client's API key
|
||||
- Allows direct HTTP interaction with services inside the sandbox without manual header management.
|
||||
- Closed automatically when the sandbox context manager exits.
|
||||
|
||||
**Auth:** Proxy requests require the `X-API-Key` header. JWT is not supported for proxy routes. If the client was constructed with a JWT token only, `sb.get_url()` and `sb.http_client` must raise `WrennAuthenticationError`.
|
||||
|
||||
**Example: Jupyter inside a sandbox**
|
||||
|
||||
```python
|
||||
with client.sandboxes.create("python-jupyter") as sb:
|
||||
sb.wait_ready(timeout=60)
|
||||
|
||||
# High-level: stateful code execution (see CODE_EXECUTION.md)
|
||||
result = sb.run_code("print('hello from persistent kernel')")
|
||||
print(result.stdout)
|
||||
|
||||
# Low-level: direct HTTP to Jupyter REST API
|
||||
resp = sb.http_client.get("/api/kernels")
|
||||
print(resp.json())
|
||||
|
||||
# Low-level: direct proxy URL for browser access
|
||||
jupyter_url = sb.get_url(8888)
|
||||
```
|
||||
|
||||
## Coding Conventions & Typing
|
||||
|
||||
- **Python Target:** `3.13+`. Use modern syntax (`|` for Unions, standard library generics like `list[str]`).
|
||||
- **Typing:** Everything must be strictly typed. Use `pyright` for validation.
|
||||
- **Formatting:** `ruff` is the sole linter and formatter. Do not use `black`, `isort`, or `flake8`.
|
||||
- **Docstrings:** Use Google-style docstrings. These surface to end-users via IDE hover.
|
||||
- **No comments:** Do not add comments unless explicitly asked.
|
||||
|
||||
171
CLAUDE.md
171
CLAUDE.md
@ -1,171 +0,0 @@
|
||||
## 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://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml"
|
||||
SPEC_URL = "https://git.omukk.dev/wrenn/wrenn/raw/branch/main/internal/api/openapi.yaml"
|
||||
SPEC_PATH = "api/openapi.yaml"
|
||||
|
||||
generate:
|
||||
@ -21,9 +21,7 @@ generate:
|
||||
--use-schema-description \
|
||||
--target-python-version 3.13 \
|
||||
--use-annotated \
|
||||
--openapi-scopes schemas \
|
||||
--formatters ruff-format ruff-check \
|
||||
--input-file-type openapi
|
||||
--openapi-scopes schemas
|
||||
|
||||
lint:
|
||||
uv run ruff check src/
|
||||
@ -36,7 +34,3 @@ 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
|
||||
|
||||
624
README.md
624
README.md
@ -1,623 +1,3 @@
|
||||
# Wrenn Python SDK
|
||||
# python-sdk
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
pip install wrenn
|
||||
```
|
||||
|
||||
Requires Python 3.13+.
|
||||
|
||||
## Authentication
|
||||
|
||||
Set the `WRENN_API_KEY` environment variable:
|
||||
|
||||
```bash
|
||||
export WRENN_API_KEY="wrn_your_api_key_here"
|
||||
```
|
||||
|
||||
Optionally override the API base URL:
|
||||
|
||||
```bash
|
||||
export WRENN_BASE_URL="https://app.wrenn.dev/api" # default
|
||||
```
|
||||
|
||||
You can also pass credentials directly:
|
||||
|
||||
```python
|
||||
from wrenn import Capsule
|
||||
|
||||
capsule = Capsule(api_key="wrn_...", base_url="https://...")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 (destroys capsule on exit):
|
||||
|
||||
```python
|
||||
with Capsule(template="minimal", wait=True) as capsule:
|
||||
capsule.commands.run("echo hello")
|
||||
# capsule is automatically destroyed
|
||||
```
|
||||
|
||||
### Connecting to Existing Capsules
|
||||
|
||||
Attach to a running capsule by ID. If it's paused, it will be resumed automatically:
|
||||
|
||||
```python
|
||||
capsule = Capsule.connect("cl-abc123")
|
||||
result = capsule.commands.run("echo still running")
|
||||
```
|
||||
|
||||
For code interpreter capsules:
|
||||
|
||||
```python
|
||||
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="")
|
||||
case "stderr":
|
||||
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="")
|
||||
```
|
||||
|
||||
#### Process Management
|
||||
|
||||
```python
|
||||
# List running processes
|
||||
for proc in capsule.commands.list():
|
||||
print(proc.pid, proc.cmd, proc.tag)
|
||||
|
||||
# Kill a process
|
||||
capsule.commands.kill(pid=1234)
|
||||
```
|
||||
|
||||
### Filesystem
|
||||
|
||||
Files are accessed via `capsule.files`:
|
||||
|
||||
```python
|
||||
# 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
|
||||
|
||||
# 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"
|
||||
|
||||
capsule.files.upload_stream("/data/large.bin", chunks())
|
||||
|
||||
# Streaming download
|
||||
for chunk in capsule.files.download_stream("/data/large.bin"):
|
||||
process(chunk)
|
||||
```
|
||||
|
||||
### Git
|
||||
|
||||
Git operations are accessed via `capsule.git`. All commands execute the real `git` binary inside the capsule:
|
||||
|
||||
```python
|
||||
# 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:**
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `write(data: bytes)` | Send raw bytes to stdin |
|
||||
| `resize(cols, rows)` | Resize the terminal |
|
||||
| `kill()` | Send SIGKILL to the process |
|
||||
| `tag` | Session tag (after `started` event) |
|
||||
| `pid` | Process PID (after `started` event) |
|
||||
|
||||
### Proxy URL
|
||||
|
||||
Access services running inside a capsule:
|
||||
|
||||
```python
|
||||
url = capsule.get_url(8080)
|
||||
# "wss://8080-cl-abc123.app.wrenn.dev"
|
||||
```
|
||||
|
||||
### Snapshots
|
||||
|
||||
Create reusable templates from running capsules:
|
||||
|
||||
```python
|
||||
template = capsule.create_snapshot(name="my-template", overwrite=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Interpreter
|
||||
|
||||
The `wrenn.code_interpreter` module provides a specialized capsule for stateful code execution via a persistent Jupyter kernel.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```python
|
||||
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"
|
||||
```
|
||||
|
||||
### Stateful Execution
|
||||
|
||||
Variables, imports, and function definitions persist across `run_code` calls:
|
||||
|
||||
```python
|
||||
from wrenn.code_interpreter import Capsule
|
||||
|
||||
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"
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### Error Handling in Code
|
||||
|
||||
```python
|
||||
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}"),
|
||||
)
|
||||
```
|
||||
|
||||
### Custom Templates
|
||||
|
||||
By default, `code-runner-beta` template is used. You can specify a custom template:
|
||||
|
||||
```python
|
||||
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 via `AsyncCapsule`:
|
||||
|
||||
### Async Capsule
|
||||
|
||||
```python
|
||||
from wrenn import AsyncCapsule
|
||||
|
||||
async with await AsyncCapsule.create(template="minimal", wait=True) as capsule:
|
||||
result = await capsule.commands.run("echo hello")
|
||||
print(result.stdout)
|
||||
|
||||
await capsule.files.write("/app/file.txt", "data")
|
||||
entries = await capsule.files.list("/app")
|
||||
|
||||
await capsule.pause()
|
||||
await capsule.resume()
|
||||
```
|
||||
|
||||
### Async Code Interpreter
|
||||
|
||||
```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
|
||||
|
||||
The SDK maps server error codes to typed exceptions:
|
||||
|
||||
```python
|
||||
from wrenn import (
|
||||
WrennError,
|
||||
WrennValidationError, # 400
|
||||
WrennAuthenticationError, # 401
|
||||
WrennForbiddenError, # 403
|
||||
WrennNotFoundError, # 404
|
||||
WrennConflictError, # 409
|
||||
WrennHostHasCapsulesError, # 409 (host has running capsules)
|
||||
WrennAgentError, # 502
|
||||
WrennInternalError, # 500
|
||||
WrennHostUnavailableError, # 503
|
||||
)
|
||||
|
||||
try:
|
||||
Capsule.get_info("nonexistent")
|
||||
except WrennNotFoundError as e:
|
||||
print(e.code) # "not_found"
|
||||
print(e.message) # "capsule not found"
|
||||
print(e.status_code) # 404
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Run linting
|
||||
make lint
|
||||
|
||||
# Run unit tests
|
||||
make test
|
||||
|
||||
# Run all tests (including integration)
|
||||
make test-integration
|
||||
```
|
||||
|
||||
### Running Integration Tests
|
||||
|
||||
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_..."
|
||||
|
||||
# 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
|
||||
Python SDK for wrenn
|
||||
2008
api/openapi.yaml
2008
api/openapi.yaml
File diff suppressed because it is too large
Load Diff
4274
docs/reference.md
4274
docs/reference.md
File diff suppressed because it is too large
Load Diff
@ -1,12 +0,0 @@
|
||||
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,26 +1,12 @@
|
||||
[project]
|
||||
name = "wrenn"
|
||||
version = "0.1.2"
|
||||
description = "Python SDK for Wrenn"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ["LICENSE"]
|
||||
authors = [
|
||||
{ name = "Rafeed M. Bhuiyan", email = "rafeed@omukk.dev" },
|
||||
{ name = "Tasnim Kabir Sadik", email = "tksadik@omukk.dev" },
|
||||
{ name = "Tasnim Kabir Sadik", email = "tksadik92@gmail.com" }
|
||||
]
|
||||
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",
|
||||
@ -34,20 +20,14 @@ build-backend = "hatchling.build"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"datamodel-code-generator[ruff]>=0.56.0",
|
||||
"datamodel-code-generator>=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,20 +1,20 @@
|
||||
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,
|
||||
from wrenn.exceptions import (
|
||||
WrennAgentError,
|
||||
WrennAuthenticationError,
|
||||
WrennConflictError,
|
||||
WrennError,
|
||||
WrennForbiddenError,
|
||||
WrennHostHasSandboxesError,
|
||||
WrennHostUnavailableError,
|
||||
WrennInternalError,
|
||||
WrennNotFoundError,
|
||||
WrennValidationError,
|
||||
)
|
||||
from wrenn.sandbox import (
|
||||
CodeResult,
|
||||
ExecResult,
|
||||
Sandbox,
|
||||
StreamErrorEvent,
|
||||
StreamEvent,
|
||||
StreamExitEvent,
|
||||
@ -22,44 +22,14 @@ from wrenn.commands import (
|
||||
StreamStderrEvent,
|
||||
StreamStdoutEvent,
|
||||
)
|
||||
from wrenn.exceptions import (
|
||||
WrennAgentError,
|
||||
WrennAuthenticationError,
|
||||
WrennConflictError,
|
||||
WrennError,
|
||||
WrennForbiddenError,
|
||||
WrennHostHasCapsulesError,
|
||||
WrennHostUnavailableError,
|
||||
WrennInternalError,
|
||||
WrennNotFoundError,
|
||||
WrennValidationError,
|
||||
)
|
||||
from wrenn.models import FileEntry
|
||||
from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"AsyncCapsule",
|
||||
"AsyncGit",
|
||||
"AsyncPtySession",
|
||||
"AsyncWrennClient",
|
||||
"Capsule",
|
||||
"CommandHandle",
|
||||
"CommandResult",
|
||||
"FileEntry",
|
||||
"FileStatus",
|
||||
"Git",
|
||||
"GitAuthError",
|
||||
"GitBranch",
|
||||
"GitCommandError",
|
||||
"GitError",
|
||||
"GitStatus",
|
||||
"ProcessInfo",
|
||||
"PtyEvent",
|
||||
"PtyEventType",
|
||||
"PtySession",
|
||||
"CodeResult",
|
||||
"ExecResult",
|
||||
"Sandbox",
|
||||
"StreamErrorEvent",
|
||||
"StreamEvent",
|
||||
@ -73,35 +43,9 @@ __all__ = [
|
||||
"WrennConflictError",
|
||||
"WrennError",
|
||||
"WrennForbiddenError",
|
||||
"WrennHostHasCapsulesError",
|
||||
"WrennHostHasSandboxesError",
|
||||
"WrennHostUnavailableError",
|
||||
"WrennInternalError",
|
||||
"WrennNotFoundError",
|
||||
"WrennValidationError",
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
if name == "WrennHostHasSandboxesError":
|
||||
warnings.warn(
|
||||
"'WrennHostHasSandboxesError' is deprecated, use 'WrennHostHasCapsulesError' instead",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
setattr(_module, name, WrennHostHasCapsulesError)
|
||||
return WrennHostHasCapsulesError
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
DEFAULT_BASE_URL = "https://app.wrenn.dev/api"
|
||||
ENV_API_KEY = "WRENN_API_KEY"
|
||||
ENV_BASE_URL = "WRENN_BASE_URL"
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,104 +0,0 @@
|
||||
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"
|
||||
@ -1,499 +0,0 @@
|
||||
"""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"
|
||||
@ -1,28 +0,0 @@
|
||||
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."""
|
||||
@ -1,400 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import builtins
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import httpx_ws
|
||||
|
||||
from wrenn._git import AsyncGit
|
||||
from wrenn.capsule import _DualMethod, _build_proxy_url
|
||||
from wrenn.client import AsyncWrennClient
|
||||
from wrenn.commands import AsyncCommands
|
||||
from wrenn.files import AsyncFiles
|
||||
from wrenn.models import Capsule as CapsuleModel
|
||||
from wrenn.models import Status, Template
|
||||
from wrenn.pty import AsyncPtySession
|
||||
|
||||
|
||||
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)
|
||||
|
||||
if info.status == Status.paused:
|
||||
info = await client.capsules.resume(capsule_id)
|
||||
|
||||
return cls(
|
||||
_capsule_id=capsule_id,
|
||||
_client=client,
|
||||
_info=info,
|
||||
)
|
||||
|
||||
# ── 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) -> None:
|
||||
await self._client.capsules.destroy(self._id)
|
||||
|
||||
@classmethod
|
||||
async def _static_destroy(
|
||||
cls,
|
||||
capsule_id: str,
|
||||
*,
|
||||
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)
|
||||
|
||||
async def _instance_pause(self) -> CapsuleModel:
|
||||
self._info = await self._client.capsules.pause(self._id)
|
||||
return self._info
|
||||
|
||||
@classmethod
|
||||
async def _static_pause(
|
||||
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.pause(capsule_id)
|
||||
|
||||
async def _instance_resume(self) -> CapsuleModel:
|
||||
self._info = await self._client.capsules.resume(self._id)
|
||||
return self._info
|
||||
|
||||
@classmethod
|
||||
async def _static_resume(
|
||||
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.resume(capsule_id)
|
||||
|
||||
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_ready(self, timeout: float = 30, interval: float = 0.5) -> None:
|
||||
"""Await until the capsule status is ``running``.
|
||||
|
||||
Args:
|
||||
timeout (float): Maximum seconds to wait. Defaults to ``30``.
|
||||
interval (float): Polling interval in seconds. Defaults to ``0.5``.
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the capsule does not reach ``running`` state
|
||||
within ``timeout`` seconds.
|
||||
RuntimeError: If the capsule enters an error, stopped, or paused
|
||||
state while waiting.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
info = await self._client.capsules.get(self._id)
|
||||
if info.status == Status.running:
|
||||
self._info = info
|
||||
return
|
||||
if info.status in (Status.error, Status.stopped):
|
||||
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
||||
if info.status == Status.paused:
|
||||
info = await self._client.capsules.resume(self._id)
|
||||
await asyncio.sleep(interval)
|
||||
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
||||
|
||||
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
|
||||
@ -1,481 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import builtins
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import httpx_ws
|
||||
|
||||
from wrenn._git import Git
|
||||
from wrenn.client import WrennClient
|
||||
from wrenn.commands import Commands
|
||||
from wrenn.files import Files
|
||||
from wrenn.models import Capsule as CapsuleModel
|
||||
from wrenn.models import Status, Template
|
||||
from wrenn.pty import PtySession
|
||||
|
||||
|
||||
def _build_proxy_url(base_url: str, capsule_id: str | None, port: int) -> str:
|
||||
parsed = httpx.URL(base_url)
|
||||
host = parsed.host
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
scheme = "ws" if parsed.scheme == "http" else "wss"
|
||||
return f"{scheme}://{port}-{capsule_id}.{host}"
|
||||
|
||||
|
||||
class _DualMethod:
|
||||
"""Descriptor that dispatches to instance method or classmethod depending on call site."""
|
||||
|
||||
def __init__(self, instance_fn_name: str, static_fn_name: str) -> None:
|
||||
self._ifn = instance_fn_name
|
||||
self._sfn = static_fn_name
|
||||
|
||||
def __set_name__(self, owner: type, name: str) -> None:
|
||||
self._name = name
|
||||
|
||||
def __get__(self, obj: Any, cls: type) -> Any:
|
||||
if obj is None:
|
||||
return getattr(cls, self._sfn)
|
||||
return getattr(obj, self._ifn)
|
||||
|
||||
|
||||
class Capsule:
|
||||
"""A Wrenn capsule (sandbox) with e2b-compatible interface.
|
||||
|
||||
Create directly::
|
||||
|
||||
capsule = Capsule(api_key="wrn_...")
|
||||
capsule = Capsule(template="minimal") # reads WRENN_API_KEY env
|
||||
|
||||
Or via classmethod::
|
||||
|
||||
capsule = Capsule.create(template="minimal")
|
||||
|
||||
Use as context manager for automatic cleanup::
|
||||
|
||||
with Capsule() as capsule:
|
||||
capsule.commands.run("echo hello")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
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,
|
||||
# Private: used by classmethods to skip creation
|
||||
_capsule_id: str | None = None,
|
||||
_client: WrennClient | None = None,
|
||||
_info: CapsuleModel | None = None,
|
||||
) -> None:
|
||||
"""Create and start a new capsule.
|
||||
|
||||
Args:
|
||||
template (str | None): Template name to boot from. Defaults to
|
||||
the server-side default (``"minimal"``).
|
||||
vcpus (int | None): Number of virtual CPUs. Defaults to the
|
||||
server-side default.
|
||||
memory_mb (int | None): Memory in MiB. Defaults to the
|
||||
server-side default.
|
||||
timeout (int | None): Inactivity TTL in seconds before the capsule
|
||||
is auto-paused. ``0`` disables auto-pause.
|
||||
wait (bool): If ``True``, block until the capsule status is
|
||||
``running`` before returning.
|
||||
api_key (str | None): Wrenn API key (``wrn_...``). Falls back to
|
||||
the ``WRENN_API_KEY`` environment variable.
|
||||
base_url (str | None): Wrenn API base URL. Falls back to
|
||||
``WRENN_BASE_URL`` or the default production endpoint.
|
||||
"""
|
||||
if _capsule_id is not None:
|
||||
assert _client is not None
|
||||
self._id: str = _capsule_id
|
||||
self._client = _client
|
||||
self._info = _info
|
||||
if self._id is None:
|
||||
self._client.close()
|
||||
raise RuntimeError("API returned a capsule without an ID")
|
||||
else:
|
||||
self._client = WrennClient(api_key=api_key, base_url=base_url)
|
||||
try:
|
||||
self._info = self._client.capsules.create(
|
||||
template=template,
|
||||
vcpus=vcpus,
|
||||
memory_mb=memory_mb,
|
||||
timeout_sec=timeout,
|
||||
)
|
||||
self._id = self._info.id
|
||||
if self._id is None:
|
||||
raise RuntimeError("API returned a capsule without an ID")
|
||||
except Exception:
|
||||
self._client.close()
|
||||
raise
|
||||
|
||||
self.commands = Commands(self._id, self._client.http)
|
||||
self.files = Files(self._id, self._client.http)
|
||||
self.git = Git(self._id, self._client.http)
|
||||
|
||||
if wait:
|
||||
self.wait_ready()
|
||||
|
||||
# ── 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
|
||||
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 capsule.
|
||||
|
||||
Equivalent to calling ``Capsule(...)`` directly.
|
||||
|
||||
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): 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 capsule instance.
|
||||
"""
|
||||
return cls(
|
||||
template=template,
|
||||
vcpus=vcpus,
|
||||
memory_mb=memory_mb,
|
||||
timeout=timeout,
|
||||
wait=wait,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def connect(
|
||||
cls,
|
||||
capsule_id: str,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> Capsule:
|
||||
"""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:
|
||||
Capsule: A capsule instance bound to the existing capsule.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If no capsule with the given ID exists.
|
||||
"""
|
||||
client = WrennClient(api_key=api_key, base_url=base_url)
|
||||
info = client.capsules.get(capsule_id)
|
||||
|
||||
if info.status == Status.paused:
|
||||
info = client.capsules.resume(capsule_id)
|
||||
|
||||
return cls(
|
||||
_capsule_id=capsule_id,
|
||||
_client=client,
|
||||
_info=info,
|
||||
)
|
||||
|
||||
# ── 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")
|
||||
|
||||
def _instance_destroy(self) -> None:
|
||||
"""Destroy this capsule."""
|
||||
self._client.capsules.destroy(self._id)
|
||||
|
||||
@classmethod
|
||||
def _static_destroy(
|
||||
cls,
|
||||
capsule_id: str,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> None:
|
||||
"""Destroy a capsule by ID."""
|
||||
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
||||
client.capsules.destroy(capsule_id)
|
||||
|
||||
def _instance_pause(self) -> CapsuleModel:
|
||||
"""Pause this capsule."""
|
||||
self._info = self._client.capsules.pause(self._id)
|
||||
return self._info
|
||||
|
||||
@classmethod
|
||||
def _static_pause(
|
||||
cls,
|
||||
capsule_id: str,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> CapsuleModel:
|
||||
"""Pause a capsule by ID."""
|
||||
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
||||
return client.capsules.pause(capsule_id)
|
||||
|
||||
def _instance_resume(self) -> CapsuleModel:
|
||||
"""Resume this capsule."""
|
||||
self._info = self._client.capsules.resume(self._id)
|
||||
return self._info
|
||||
|
||||
@classmethod
|
||||
def _static_resume(
|
||||
cls,
|
||||
capsule_id: str,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> CapsuleModel:
|
||||
"""Resume a capsule by ID."""
|
||||
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
||||
return client.capsules.resume(capsule_id)
|
||||
|
||||
def _instance_get_info(self) -> CapsuleModel:
|
||||
"""Get current info for this capsule."""
|
||||
self._info = self._client.capsules.get(self._id)
|
||||
return self._info
|
||||
|
||||
@classmethod
|
||||
def _static_get_info(
|
||||
cls,
|
||||
capsule_id: str,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> CapsuleModel:
|
||||
"""Get capsule info by ID."""
|
||||
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
||||
return client.capsules.get(capsule_id)
|
||||
|
||||
# ── Instance-only methods ───────────────────────────────────
|
||||
|
||||
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.
|
||||
"""
|
||||
self._client.capsules.ping(self._id)
|
||||
|
||||
def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None:
|
||||
"""Block until the capsule status is ``running``.
|
||||
|
||||
Args:
|
||||
timeout (float): Maximum seconds to wait. Defaults to ``30``.
|
||||
interval (float): Polling interval in seconds. Defaults to ``0.5``.
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the capsule does not reach ``running`` state
|
||||
within ``timeout`` seconds.
|
||||
RuntimeError: If the capsule enters an error, stopped, or paused
|
||||
state while waiting.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
info = self._client.capsules.get(self._id)
|
||||
if info.status == Status.running:
|
||||
self._info = info
|
||||
return
|
||||
if info.status in (Status.error, Status.stopped):
|
||||
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
||||
if info.status == Status.paused:
|
||||
info = self._client.capsules.resume(self._id)
|
||||
time.sleep(interval)
|
||||
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
||||
|
||||
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 = self._instance_get_info()
|
||||
return info.status == Status.running
|
||||
|
||||
# ── Static list ─────────────────────────────────────────────
|
||||
|
||||
@classmethod
|
||||
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.
|
||||
"""
|
||||
with WrennClient(api_key=api_key, base_url=base_url) as client:
|
||||
return client.capsules.list()
|
||||
|
||||
# ── PTY ─────────────────────────────────────────────────────
|
||||
|
||||
@contextmanager
|
||||
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,
|
||||
) -> Iterator[PtySession]:
|
||||
"""Open an interactive PTY session backed by a WebSocket.
|
||||
|
||||
Use as a context manager and iterate over :class:`PtyEvent` objects::
|
||||
|
||||
with capsule.pty() as term:
|
||||
term.write(b"echo hello\\n")
|
||||
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:
|
||||
PtySession: An interactive PTY session.
|
||||
"""
|
||||
with httpx_ws.connect_ws(
|
||||
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
||||
) as ws: # type: httpx_ws.WebSocketSession
|
||||
session = PtySession(ws, self._id)
|
||||
session._send_start(
|
||||
cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd
|
||||
)
|
||||
yield session
|
||||
|
||||
@contextmanager
|
||||
def pty_connect(self, tag: str) -> Iterator[PtySession]:
|
||||
"""Reconnect to an existing PTY session by tag.
|
||||
|
||||
Args:
|
||||
tag (str): Session tag returned in the ``started`` PTY event.
|
||||
|
||||
Yields:
|
||||
PtySession: The reconnected PTY session.
|
||||
"""
|
||||
with httpx_ws.connect_ws(
|
||||
f"/v1/capsules/{self._id}/pty", client=self._client.http
|
||||
) as ws: # type: httpx_ws.WebSocketSession
|
||||
session = PtySession(ws, self._id)
|
||||
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 ───────────────────────────────────────────────
|
||||
|
||||
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 self._client.snapshots.create(
|
||||
capsule_id=self._id, name=name, overwrite=overwrite
|
||||
)
|
||||
|
||||
# ── Context manager ─────────────────────────────────────────
|
||||
|
||||
def __enter__(self) -> Capsule:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: object,
|
||||
) -> None:
|
||||
try:
|
||||
self._instance_destroy()
|
||||
except Exception as exc:
|
||||
logging.warning("Failed to destroy capsule %s: %s", self._id, exc)
|
||||
try:
|
||||
self._client.close()
|
||||
except Exception:
|
||||
pass
|
||||
@ -1,56 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import builtins
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
|
||||
from wrenn._config import DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL
|
||||
from wrenn.exceptions import handle_response
|
||||
|
||||
from wrenn.exceptions import (
|
||||
WrennAgentError,
|
||||
WrennAuthenticationError,
|
||||
WrennConflictError,
|
||||
WrennError,
|
||||
WrennForbiddenError,
|
||||
WrennHostHasSandboxesError,
|
||||
WrennHostUnavailableError,
|
||||
WrennInternalError,
|
||||
WrennNotFoundError,
|
||||
WrennValidationError,
|
||||
)
|
||||
from wrenn.models import (
|
||||
APIKeyResponse,
|
||||
AuthResponse,
|
||||
CreateHostResponse,
|
||||
Host,
|
||||
Sandbox as SandboxModel,
|
||||
Template,
|
||||
)
|
||||
from wrenn.models import (
|
||||
Capsule as CapsuleModel,
|
||||
)
|
||||
from wrenn.sandbox import Sandbox
|
||||
|
||||
_LONG_TIMEOUT = httpx.Timeout(60.0)
|
||||
DEFAULT_BASE_URL = "https://api.wrenn.dev"
|
||||
|
||||
_ERROR_MAP: dict[str, type[WrennError]] = {
|
||||
"invalid_request": WrennValidationError,
|
||||
"unauthorized": WrennAuthenticationError,
|
||||
"forbidden": WrennForbiddenError,
|
||||
"not_found": WrennNotFoundError,
|
||||
"invalid_state": WrennConflictError,
|
||||
"conflict": WrennConflictError,
|
||||
"host_has_sandboxes": WrennHostHasSandboxesError,
|
||||
"host_unavailable": WrennHostUnavailableError,
|
||||
"agent_error": WrennAgentError,
|
||||
"internal_error": WrennInternalError,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_api_key(api_key: str | None) -> str:
|
||||
resolved = api_key or os.environ.get(ENV_API_KEY)
|
||||
if not resolved:
|
||||
raise ValueError(
|
||||
f"No API key provided. Pass api_key= or set the {ENV_API_KEY} environment variable."
|
||||
def _handle_response(resp: httpx.Response) -> dict | list:
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
resp.raise_for_status()
|
||||
raise
|
||||
|
||||
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 WrennHostHasSandboxesError:
|
||||
raise WrennHostHasSandboxesError(
|
||||
code=code,
|
||||
message=message,
|
||||
status_code=resp.status_code,
|
||||
sandbox_ids=body.get("sandbox_ids", []),
|
||||
)
|
||||
return resolved
|
||||
|
||||
raise exc_cls(
|
||||
code=code,
|
||||
message=message,
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
|
||||
if resp.status_code == 204:
|
||||
return {}
|
||||
|
||||
return resp.json()
|
||||
|
||||
|
||||
class CapsulesResource:
|
||||
"""Sync capsule control-plane operations."""
|
||||
def _build_headers(api_key: str | None, token: str | None) -> dict[str, str]:
|
||||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers
|
||||
|
||||
|
||||
class AuthResource:
|
||||
"""Sync auth operations."""
|
||||
|
||||
def __init__(self, http: httpx.Client) -> None:
|
||||
self._http = http
|
||||
|
||||
def signup(self, email: str, password: str) -> AuthResponse:
|
||||
resp = self._http.post(
|
||||
"/v1/auth/signup", json={"email": email, "password": password}
|
||||
)
|
||||
return AuthResponse.model_validate(_handle_response(resp))
|
||||
|
||||
def login(self, email: str, password: str) -> AuthResponse:
|
||||
resp = self._http.post(
|
||||
"/v1/auth/login", json={"email": email, "password": password}
|
||||
)
|
||||
return AuthResponse.model_validate(_handle_response(resp))
|
||||
|
||||
|
||||
class AsyncAuthResource:
|
||||
"""Async auth operations."""
|
||||
|
||||
def __init__(self, http: httpx.AsyncClient) -> None:
|
||||
self._http = http
|
||||
|
||||
async def signup(self, email: str, password: str) -> AuthResponse:
|
||||
resp = await self._http.post(
|
||||
"/v1/auth/signup", json={"email": email, "password": password}
|
||||
)
|
||||
return AuthResponse.model_validate(_handle_response(resp))
|
||||
|
||||
async def login(self, email: str, password: str) -> AuthResponse:
|
||||
resp = await self._http.post(
|
||||
"/v1/auth/login", json={"email": email, "password": password}
|
||||
)
|
||||
return AuthResponse.model_validate(_handle_response(resp))
|
||||
|
||||
|
||||
class APIKeysResource:
|
||||
"""Sync API key operations."""
|
||||
|
||||
def __init__(self, http: httpx.Client) -> None:
|
||||
self._http = http
|
||||
|
||||
def create(self, name: str | None = None) -> APIKeyResponse:
|
||||
payload: dict = {}
|
||||
if name is not None:
|
||||
payload["name"] = name
|
||||
resp = self._http.post("/v1/api-keys", json=payload)
|
||||
return APIKeyResponse.model_validate(_handle_response(resp))
|
||||
|
||||
def list(self) -> list[APIKeyResponse]:
|
||||
resp = self._http.get("/v1/api-keys")
|
||||
return [APIKeyResponse.model_validate(item) for item in _handle_response(resp)]
|
||||
|
||||
def delete(self, id: str) -> None:
|
||||
resp = self._http.delete(f"/v1/api-keys/{id}")
|
||||
_handle_response(resp)
|
||||
|
||||
|
||||
class AsyncAPIKeysResource:
|
||||
"""Async API key operations."""
|
||||
|
||||
def __init__(self, http: httpx.AsyncClient) -> None:
|
||||
self._http = http
|
||||
|
||||
async def create(self, name: str | None = None) -> APIKeyResponse:
|
||||
payload: dict = {}
|
||||
if name is not None:
|
||||
payload["name"] = name
|
||||
resp = await self._http.post("/v1/api-keys", json=payload)
|
||||
return APIKeyResponse.model_validate(_handle_response(resp))
|
||||
|
||||
async def list(self) -> list[APIKeyResponse]:
|
||||
resp = await self._http.get("/v1/api-keys")
|
||||
return [APIKeyResponse.model_validate(item) for item in _handle_response(resp)]
|
||||
|
||||
async def delete(self, id: str) -> None:
|
||||
resp = await self._http.delete(f"/v1/api-keys/{id}")
|
||||
_handle_response(resp)
|
||||
|
||||
|
||||
class SandboxesResource:
|
||||
"""Sync sandbox control-plane operations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
http: httpx.Client,
|
||||
base_url: str,
|
||||
api_key: str | None = None,
|
||||
token: str | None = None,
|
||||
) -> None:
|
||||
self._http = http
|
||||
self._base_url = base_url
|
||||
self._api_key = api_key
|
||||
self._token = token
|
||||
|
||||
def create(
|
||||
self,
|
||||
template: str | None = None,
|
||||
vcpus: int | None = None,
|
||||
memory_mb: int | None = None,
|
||||
timeout_sec: int | None = None,
|
||||
) -> CapsuleModel:
|
||||
"""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_sec (int | None): Inactivity TTL in seconds before
|
||||
auto-pause. ``0`` disables auto-pause.
|
||||
|
||||
Returns:
|
||||
CapsuleModel: The newly created capsule.
|
||||
"""
|
||||
) -> Sandbox:
|
||||
payload: dict = {}
|
||||
if template is not None:
|
||||
payload["template"] = template
|
||||
@ -60,93 +199,39 @@ class CapsulesResource:
|
||||
payload["memory_mb"] = memory_mb
|
||||
if timeout_sec is not None:
|
||||
payload["timeout_sec"] = timeout_sec
|
||||
resp = self._http.post("/v1/capsules", json=payload)
|
||||
return CapsuleModel.model_validate(handle_response(resp))
|
||||
resp = self._http.post("/v1/sandboxes", json=payload)
|
||||
model = SandboxModel.model_validate(_handle_response(resp))
|
||||
sb = Sandbox.model_validate(model.model_dump())
|
||||
sb._bind(self._http, self._base_url, self._api_key, self._token)
|
||||
return sb
|
||||
|
||||
def list(self) -> list[CapsuleModel]:
|
||||
"""List all capsules for the authenticated team.
|
||||
def list(self) -> list[SandboxModel]:
|
||||
resp = self._http.get("/v1/sandboxes")
|
||||
return [SandboxModel.model_validate(item) for item in _handle_response(resp)]
|
||||
|
||||
Returns:
|
||||
list[CapsuleModel]: All capsules belonging to the team.
|
||||
"""
|
||||
resp = self._http.get("/v1/capsules")
|
||||
return [CapsuleModel.model_validate(item) for item in handle_response(resp)]
|
||||
|
||||
def get(self, id: str) -> CapsuleModel:
|
||||
"""Get a capsule by ID.
|
||||
|
||||
Args:
|
||||
id (str): Capsule ID.
|
||||
|
||||
Returns:
|
||||
CapsuleModel: Current state of the capsule.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If no capsule with the given ID exists.
|
||||
"""
|
||||
resp = self._http.get(f"/v1/capsules/{id}")
|
||||
return CapsuleModel.model_validate(handle_response(resp))
|
||||
def get(self, id: str) -> SandboxModel:
|
||||
resp = self._http.get(f"/v1/sandboxes/{id}")
|
||||
return SandboxModel.model_validate(_handle_response(resp))
|
||||
|
||||
def destroy(self, id: str) -> None:
|
||||
"""Destroy a capsule permanently.
|
||||
|
||||
Args:
|
||||
id (str): Capsule ID.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If no capsule with the given ID exists.
|
||||
"""
|
||||
resp = self._http.delete(f"/v1/capsules/{id}")
|
||||
handle_response(resp)
|
||||
|
||||
def pause(self, id: str) -> CapsuleModel:
|
||||
"""Pause a running capsule.
|
||||
|
||||
Args:
|
||||
id (str): Capsule ID.
|
||||
|
||||
Returns:
|
||||
CapsuleModel: Updated capsule state.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If no capsule with the given ID exists.
|
||||
"""
|
||||
resp = self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT)
|
||||
return CapsuleModel.model_validate(handle_response(resp))
|
||||
|
||||
def resume(self, id: str) -> CapsuleModel:
|
||||
"""Resume a paused capsule.
|
||||
|
||||
Args:
|
||||
id (str): Capsule ID.
|
||||
|
||||
Returns:
|
||||
CapsuleModel: Updated capsule state.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If no capsule with the given ID exists.
|
||||
"""
|
||||
resp = self._http.post(f"/v1/capsules/{id}/resume")
|
||||
return CapsuleModel.model_validate(handle_response(resp))
|
||||
|
||||
def ping(self, id: str) -> None:
|
||||
"""Reset the inactivity timer for a capsule.
|
||||
|
||||
Args:
|
||||
id (str): Capsule ID.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If no capsule with the given ID exists.
|
||||
"""
|
||||
resp = self._http.post(f"/v1/capsules/{id}/ping")
|
||||
handle_response(resp)
|
||||
resp = self._http.delete(f"/v1/sandboxes/{id}")
|
||||
_handle_response(resp)
|
||||
|
||||
|
||||
class AsyncCapsulesResource:
|
||||
"""Async capsule control-plane operations."""
|
||||
class AsyncSandboxesResource:
|
||||
"""Async sandbox control-plane operations."""
|
||||
|
||||
def __init__(self, http: httpx.AsyncClient) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
http: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str | None = None,
|
||||
token: str | None = None,
|
||||
) -> None:
|
||||
self._http = http
|
||||
self._base_url = base_url
|
||||
self._api_key = api_key
|
||||
self._token = token
|
||||
|
||||
async def create(
|
||||
self,
|
||||
@ -154,19 +239,7 @@ class AsyncCapsulesResource:
|
||||
vcpus: int | None = None,
|
||||
memory_mb: int | None = None,
|
||||
timeout_sec: int | None = None,
|
||||
) -> CapsuleModel:
|
||||
"""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_sec (int | None): Inactivity TTL in seconds before
|
||||
auto-pause. ``0`` disables auto-pause.
|
||||
|
||||
Returns:
|
||||
CapsuleModel: The newly created capsule.
|
||||
"""
|
||||
) -> Sandbox:
|
||||
payload: dict = {}
|
||||
if template is not None:
|
||||
payload["template"] = template
|
||||
@ -176,86 +249,23 @@ class AsyncCapsulesResource:
|
||||
payload["memory_mb"] = memory_mb
|
||||
if timeout_sec is not None:
|
||||
payload["timeout_sec"] = timeout_sec
|
||||
resp = await self._http.post("/v1/capsules", json=payload)
|
||||
return CapsuleModel.model_validate(handle_response(resp))
|
||||
resp = await self._http.post("/v1/sandboxes", json=payload)
|
||||
model = SandboxModel.model_validate(_handle_response(resp))
|
||||
sb = Sandbox.model_validate(model.model_dump())
|
||||
sb._bind(self._http, self._base_url, self._api_key, self._token)
|
||||
return sb
|
||||
|
||||
async def list(self) -> list[CapsuleModel]:
|
||||
"""List all capsules for the authenticated team.
|
||||
async def list(self) -> list[SandboxModel]:
|
||||
resp = await self._http.get("/v1/sandboxes")
|
||||
return [SandboxModel.model_validate(item) for item in _handle_response(resp)]
|
||||
|
||||
Returns:
|
||||
list[CapsuleModel]: All capsules belonging to the team.
|
||||
"""
|
||||
resp = await self._http.get("/v1/capsules")
|
||||
return [CapsuleModel.model_validate(item) for item in handle_response(resp)]
|
||||
|
||||
async def get(self, id: str) -> CapsuleModel:
|
||||
"""Get a capsule by ID.
|
||||
|
||||
Args:
|
||||
id (str): Capsule ID.
|
||||
|
||||
Returns:
|
||||
CapsuleModel: Current state of the capsule.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If no capsule with the given ID exists.
|
||||
"""
|
||||
resp = await self._http.get(f"/v1/capsules/{id}")
|
||||
return CapsuleModel.model_validate(handle_response(resp))
|
||||
async def get(self, id: str) -> SandboxModel:
|
||||
resp = await self._http.get(f"/v1/sandboxes/{id}")
|
||||
return SandboxModel.model_validate(_handle_response(resp))
|
||||
|
||||
async def destroy(self, id: str) -> None:
|
||||
"""Destroy a capsule permanently.
|
||||
|
||||
Args:
|
||||
id (str): Capsule ID.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If no capsule with the given ID exists.
|
||||
"""
|
||||
resp = await self._http.delete(f"/v1/capsules/{id}")
|
||||
handle_response(resp)
|
||||
|
||||
async def pause(self, id: str) -> CapsuleModel:
|
||||
"""Pause a running capsule.
|
||||
|
||||
Args:
|
||||
id (str): Capsule ID.
|
||||
|
||||
Returns:
|
||||
CapsuleModel: Updated capsule state.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If no capsule with the given ID exists.
|
||||
"""
|
||||
resp = await self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT)
|
||||
return CapsuleModel.model_validate(handle_response(resp))
|
||||
|
||||
async def resume(self, id: str) -> CapsuleModel:
|
||||
"""Resume a paused capsule.
|
||||
|
||||
Args:
|
||||
id (str): Capsule ID.
|
||||
|
||||
Returns:
|
||||
CapsuleModel: Updated capsule state.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If no capsule with the given ID exists.
|
||||
"""
|
||||
resp = await self._http.post(f"/v1/capsules/{id}/resume")
|
||||
return CapsuleModel.model_validate(handle_response(resp))
|
||||
|
||||
async def ping(self, id: str) -> None:
|
||||
"""Reset the inactivity timer for a capsule.
|
||||
|
||||
Args:
|
||||
id (str): Capsule ID.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If no capsule with the given ID exists.
|
||||
"""
|
||||
resp = await self._http.post(f"/v1/capsules/{id}/ping")
|
||||
handle_response(resp)
|
||||
resp = await self._http.delete(f"/v1/sandboxes/{id}")
|
||||
_handle_response(resp)
|
||||
|
||||
|
||||
class SnapshotsResource:
|
||||
@ -266,60 +276,29 @@ class SnapshotsResource:
|
||||
|
||||
def create(
|
||||
self,
|
||||
capsule_id: str,
|
||||
sandbox_id: str,
|
||||
name: str | None = None,
|
||||
overwrite: bool = False,
|
||||
) -> Template:
|
||||
"""Create a snapshot template from a running capsule.
|
||||
|
||||
Args:
|
||||
capsule_id (str): ID of the capsule to snapshot.
|
||||
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.
|
||||
"""
|
||||
payload: dict = {"sandbox_id": capsule_id}
|
||||
payload: dict = {"sandbox_id": sandbox_id}
|
||||
if name is not None:
|
||||
payload["name"] = name
|
||||
params: dict = {}
|
||||
if overwrite:
|
||||
params["overwrite"] = "true"
|
||||
resp = self._http.post(
|
||||
"/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT
|
||||
)
|
||||
return Template.model_validate(handle_response(resp))
|
||||
resp = self._http.post("/v1/snapshots", json=payload, params=params)
|
||||
return Template.model_validate(_handle_response(resp))
|
||||
|
||||
def list(self, type: str | None = None) -> list[Template]:
|
||||
"""List snapshot templates.
|
||||
|
||||
Args:
|
||||
type (str | None): Filter by template type. Returns all templates
|
||||
if not provided.
|
||||
|
||||
Returns:
|
||||
list[Template]: Matching snapshot templates.
|
||||
"""
|
||||
params: dict = {}
|
||||
if type is not None:
|
||||
params["type"] = type
|
||||
resp = self._http.get("/v1/snapshots", params=params)
|
||||
return [Template.model_validate(item) for item in handle_response(resp)]
|
||||
return [Template.model_validate(item) for item in _handle_response(resp)]
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
"""Delete a snapshot template by name.
|
||||
|
||||
Args:
|
||||
name (str): Template name to delete.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If no template with the given name exists.
|
||||
"""
|
||||
resp = self._http.delete(f"/v1/snapshots/{name}")
|
||||
handle_response(resp)
|
||||
_handle_response(resp)
|
||||
|
||||
|
||||
class AsyncSnapshotsResource:
|
||||
@ -330,91 +309,166 @@ class AsyncSnapshotsResource:
|
||||
|
||||
async def create(
|
||||
self,
|
||||
capsule_id: str,
|
||||
sandbox_id: str,
|
||||
name: str | None = None,
|
||||
overwrite: bool = False,
|
||||
) -> Template:
|
||||
"""Create a snapshot template from a running capsule.
|
||||
|
||||
Args:
|
||||
capsule_id (str): ID of the capsule to snapshot.
|
||||
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.
|
||||
"""
|
||||
payload: dict = {"sandbox_id": capsule_id}
|
||||
payload: dict = {"sandbox_id": sandbox_id}
|
||||
if name is not None:
|
||||
payload["name"] = name
|
||||
params: dict = {}
|
||||
if overwrite:
|
||||
params["overwrite"] = "true"
|
||||
resp = await self._http.post(
|
||||
"/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT
|
||||
)
|
||||
return Template.model_validate(handle_response(resp))
|
||||
resp = await self._http.post("/v1/snapshots", json=payload, params=params)
|
||||
return Template.model_validate(_handle_response(resp))
|
||||
|
||||
async def list(self, type: str | None = None) -> list[Template]:
|
||||
"""List snapshot templates.
|
||||
|
||||
Args:
|
||||
type (str | None): Filter by template type. Returns all templates
|
||||
if not provided.
|
||||
|
||||
Returns:
|
||||
list[Template]: Matching snapshot templates.
|
||||
"""
|
||||
params: dict = {}
|
||||
if type is not None:
|
||||
params["type"] = type
|
||||
resp = await self._http.get("/v1/snapshots", params=params)
|
||||
return [Template.model_validate(item) for item in handle_response(resp)]
|
||||
return [Template.model_validate(item) for item in _handle_response(resp)]
|
||||
|
||||
async def delete(self, name: str) -> None:
|
||||
"""Delete a snapshot template by name.
|
||||
|
||||
Args:
|
||||
name (str): Template name to delete.
|
||||
|
||||
Raises:
|
||||
WrennNotFoundError: If no template with the given name exists.
|
||||
"""
|
||||
resp = await self._http.delete(f"/v1/snapshots/{name}")
|
||||
handle_response(resp)
|
||||
_handle_response(resp)
|
||||
|
||||
|
||||
class HostsResource:
|
||||
"""Sync host operations."""
|
||||
|
||||
def __init__(self, http: httpx.Client) -> None:
|
||||
self._http = http
|
||||
|
||||
def create(
|
||||
self,
|
||||
type: str,
|
||||
team_id: str | None = None,
|
||||
provider: str | None = None,
|
||||
availability_zone: str | None = None,
|
||||
) -> CreateHostResponse:
|
||||
payload: dict = {"type": type}
|
||||
if team_id is not None:
|
||||
payload["team_id"] = team_id
|
||||
if provider is not None:
|
||||
payload["provider"] = provider
|
||||
if availability_zone is not None:
|
||||
payload["availability_zone"] = availability_zone
|
||||
resp = self._http.post("/v1/hosts", json=payload)
|
||||
return CreateHostResponse.model_validate(_handle_response(resp))
|
||||
|
||||
def list(self) -> list[Host]:
|
||||
resp = self._http.get("/v1/hosts")
|
||||
return [Host.model_validate(item) for item in _handle_response(resp)]
|
||||
|
||||
def get(self, id: str) -> Host:
|
||||
resp = self._http.get(f"/v1/hosts/{id}")
|
||||
return Host.model_validate(_handle_response(resp))
|
||||
|
||||
def delete(self, id: str) -> None:
|
||||
resp = self._http.delete(f"/v1/hosts/{id}")
|
||||
_handle_response(resp)
|
||||
|
||||
def regenerate_token(self, id: str) -> CreateHostResponse:
|
||||
resp = self._http.post(f"/v1/hosts/{id}/token")
|
||||
return CreateHostResponse.model_validate(_handle_response(resp))
|
||||
|
||||
def list_tags(self, id: str) -> builtins.list[str]:
|
||||
resp = self._http.get(f"/v1/hosts/{id}/tags")
|
||||
return cast(builtins.list[str], _handle_response(resp))
|
||||
|
||||
def add_tag(self, id: str, tag: str) -> None:
|
||||
resp = self._http.post(f"/v1/hosts/{id}/tags", json={"tag": tag})
|
||||
_handle_response(resp)
|
||||
|
||||
def remove_tag(self, id: str, tag: str) -> None:
|
||||
resp = self._http.delete(f"/v1/hosts/{id}/tags/{tag}")
|
||||
_handle_response(resp)
|
||||
|
||||
|
||||
class AsyncHostsResource:
|
||||
"""Async host operations."""
|
||||
|
||||
def __init__(self, http: httpx.AsyncClient) -> None:
|
||||
self._http = http
|
||||
|
||||
async def create(
|
||||
self,
|
||||
type: str,
|
||||
team_id: str | None = None,
|
||||
provider: str | None = None,
|
||||
availability_zone: str | None = None,
|
||||
) -> CreateHostResponse:
|
||||
payload: dict = {"type": type}
|
||||
if team_id is not None:
|
||||
payload["team_id"] = team_id
|
||||
if provider is not None:
|
||||
payload["provider"] = provider
|
||||
if availability_zone is not None:
|
||||
payload["availability_zone"] = availability_zone
|
||||
resp = await self._http.post("/v1/hosts", json=payload)
|
||||
return CreateHostResponse.model_validate(_handle_response(resp))
|
||||
|
||||
async def list(self) -> list[Host]:
|
||||
resp = await self._http.get("/v1/hosts")
|
||||
return [Host.model_validate(item) for item in _handle_response(resp)]
|
||||
|
||||
async def get(self, id: str) -> Host:
|
||||
resp = await self._http.get(f"/v1/hosts/{id}")
|
||||
return Host.model_validate(_handle_response(resp))
|
||||
|
||||
async def delete(self, id: str) -> None:
|
||||
resp = await self._http.delete(f"/v1/hosts/{id}")
|
||||
_handle_response(resp)
|
||||
|
||||
async def regenerate_token(self, id: str) -> CreateHostResponse:
|
||||
resp = await self._http.post(f"/v1/hosts/{id}/token")
|
||||
return CreateHostResponse.model_validate(_handle_response(resp))
|
||||
|
||||
async def list_tags(self, id: str) -> builtins.list[str]:
|
||||
resp = await self._http.get(f"/v1/hosts/{id}/tags")
|
||||
return cast(builtins.list[str], _handle_response(resp))
|
||||
|
||||
async def add_tag(self, id: str, tag: str) -> None:
|
||||
resp = await self._http.post(f"/v1/hosts/{id}/tags", json={"tag": tag})
|
||||
_handle_response(resp)
|
||||
|
||||
async def remove_tag(self, id: str, tag: str) -> None:
|
||||
resp = await self._http.delete(f"/v1/hosts/{id}/tags/{tag}")
|
||||
_handle_response(resp)
|
||||
|
||||
|
||||
class WrennClient:
|
||||
"""Synchronous client for the Wrenn API.
|
||||
|
||||
Authenticates with an API key.
|
||||
Authenticate with either an API key or a JWT token.
|
||||
|
||||
Args:
|
||||
api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var.
|
||||
base_url: Wrenn API base URL.
|
||||
api_key: API key (``wrn_...``). Sent as ``X-API-Key`` header.
|
||||
token: JWT token. Sent as ``Authorization: Bearer`` header.
|
||||
base_url: Wrenn Control Plane URL.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
token: str | None = None,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
) -> None:
|
||||
self._api_key = _resolve_api_key(api_key)
|
||||
self._base_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL)
|
||||
self._http = httpx.Client(
|
||||
base_url=self._base_url,
|
||||
headers={"X-API-Key": self._api_key},
|
||||
)
|
||||
if not api_key and not token:
|
||||
raise ValueError("Either api_key or token must be provided")
|
||||
|
||||
self.capsules = CapsulesResource(self._http)
|
||||
headers = _build_headers(api_key, token)
|
||||
self._http = httpx.Client(base_url=base_url, headers=headers)
|
||||
self._api_key = api_key
|
||||
self._token = token
|
||||
self._base_url = base_url
|
||||
|
||||
self.auth = AuthResource(self._http)
|
||||
self.api_keys = APIKeysResource(self._http)
|
||||
self.sandboxes = SandboxesResource(self._http, base_url, api_key, token)
|
||||
self.snapshots = SnapshotsResource(self._http)
|
||||
|
||||
@property
|
||||
def http(self) -> httpx.Client:
|
||||
"""The underlying httpx.Client (for sub-objects that need direct access)."""
|
||||
return self._http
|
||||
self.hosts = HostsResource(self._http)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying HTTP connection pool."""
|
||||
@ -435,32 +489,34 @@ class WrennClient:
|
||||
class AsyncWrennClient:
|
||||
"""Asynchronous client for the Wrenn API.
|
||||
|
||||
Authenticates with an API key.
|
||||
Authenticate with either an API key or a JWT token.
|
||||
|
||||
Args:
|
||||
api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var.
|
||||
base_url: Wrenn API base URL. Falls back to ``WRENN_BASE_URL`` env var.
|
||||
api_key: API key (``wrn_...``). Sent as ``X-API-Key`` header.
|
||||
token: JWT token. Sent as ``Authorization: Bearer`` header.
|
||||
base_url: Wrenn Control Plane URL.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
token: str | None = None,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
) -> None:
|
||||
self._api_key = _resolve_api_key(api_key)
|
||||
self._base_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL)
|
||||
self._http = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
headers={"X-API-Key": self._api_key},
|
||||
)
|
||||
if not api_key and not token:
|
||||
raise ValueError("Either api_key or token must be provided")
|
||||
|
||||
self.capsules = AsyncCapsulesResource(self._http)
|
||||
headers = _build_headers(api_key, token)
|
||||
self._http = httpx.AsyncClient(base_url=base_url, headers=headers)
|
||||
self._api_key = api_key
|
||||
self._token = token
|
||||
self._base_url = base_url
|
||||
|
||||
self.auth = AsyncAuthResource(self._http)
|
||||
self.api_keys = AsyncAPIKeysResource(self._http)
|
||||
self.sandboxes = AsyncSandboxesResource(self._http, base_url, api_key, token)
|
||||
self.snapshots = AsyncSnapshotsResource(self._http)
|
||||
|
||||
@property
|
||||
def http(self) -> httpx.AsyncClient:
|
||||
"""The underlying httpx.AsyncClient."""
|
||||
return self._http
|
||||
self.hosts = AsyncHostsResource(self._http)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close the underlying async HTTP connection pool."""
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
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}")
|
||||
@ -1,292 +0,0 @@
|
||||
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)
|
||||
@ -1,307 +0,0 @@
|
||||
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)
|
||||
@ -1,156 +0,0 @@
|
||||
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
|
||||
@ -1,501 +0,0 @@
|
||||
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
|
||||
|
||||
|
||||
@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 httpx_ws.WebSocketDisconnect:
|
||||
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 httpx_ws.WebSocketDisconnect:
|
||||
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 httpx_ws.WebSocketDisconnect:
|
||||
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 httpx_ws.WebSocketDisconnect:
|
||||
pass
|
||||
@ -1,31 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class WrennError(Exception):
|
||||
"""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.
|
||||
"""
|
||||
"""Base exception for all Wrenn SDK errors."""
|
||||
|
||||
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
|
||||
@ -52,36 +31,15 @@ class WrennConflictError(WrennError):
|
||||
"""409 — State conflict (e.g. invalid_state)."""
|
||||
|
||||
|
||||
class WrennHostHasCapsulesError(WrennConflictError):
|
||||
"""409 — Host still has running capsules.
|
||||
|
||||
Attributes:
|
||||
capsule_ids (list[str]): IDs of the capsules still running on the host.
|
||||
"""
|
||||
class WrennHostHasSandboxesError(WrennConflictError):
|
||||
"""409 — Host still has running sandboxes."""
|
||||
|
||||
def __init__(
|
||||
self, code: str, message: str, status_code: int, capsule_ids: list[str]
|
||||
self, code: str, message: str, status_code: int, sandbox_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
|
||||
self.sandbox_ids = sandbox_ids
|
||||
super().__init__(code, message, status_code)
|
||||
|
||||
@property
|
||||
def sandbox_ids(self) -> list[str]:
|
||||
warnings.warn(
|
||||
"'sandbox_ids' is deprecated, use 'capsule_ids' instead",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self.capsule_ids
|
||||
|
||||
|
||||
class WrennHostUnavailableError(WrennError):
|
||||
"""503 — No suitable host available."""
|
||||
@ -93,72 +51,3 @@ class WrennAgentError(WrennError):
|
||||
|
||||
class WrennInternalError(WrennError):
|
||||
"""500 — Unexpected server error."""
|
||||
|
||||
|
||||
_ERROR_MAP: dict[str, type[WrennError]] = {
|
||||
"invalid_request": WrennValidationError,
|
||||
"unauthorized": WrennAuthenticationError,
|
||||
"forbidden": WrennForbiddenError,
|
||||
"not_found": WrennNotFoundError,
|
||||
"invalid_state": WrennConflictError,
|
||||
"conflict": WrennConflictError,
|
||||
"host_has_sandboxes": WrennHostHasCapsulesError,
|
||||
"host_has_capsules": WrennHostHasCapsulesError,
|
||||
"host_unavailable": WrennHostUnavailableError,
|
||||
"agent_error": WrennAgentError,
|
||||
"internal_error": WrennInternalError,
|
||||
}
|
||||
|
||||
|
||||
def _raise_for_status(resp: httpx.Response) -> None:
|
||||
if resp.status_code < 400:
|
||||
return
|
||||
|
||||
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 {}
|
||||
|
||||
return resp.json()
|
||||
|
||||
|
||||
def __getattr__(name: str) -> type:
|
||||
if name == "WrennHostHasSandboxesError":
|
||||
warnings.warn(
|
||||
"'WrennHostHasSandboxesError' is deprecated, use 'WrennHostHasCapsulesError' instead",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return WrennHostHasCapsulesError
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@ -1,404 +0,0 @@
|
||||
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
|
||||
|
||||
|
||||
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 resp.status_code == 409:
|
||||
try:
|
||||
body = resp.json()
|
||||
if body.get("error", {}).get("code") == "conflict":
|
||||
parent = os.path.dirname(path)
|
||||
name = os.path.basename(path)
|
||||
for entry in self.list(parent, depth=1):
|
||||
if entry.name == name:
|
||||
return entry
|
||||
except Exception:
|
||||
pass
|
||||
parsed = MakeDirResponse.model_validate(handle_response(resp))
|
||||
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 resp.status_code == 409:
|
||||
try:
|
||||
body = resp.json()
|
||||
if body.get("error", {}).get("code") == "conflict":
|
||||
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
|
||||
except Exception:
|
||||
pass
|
||||
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,28 +1,22 @@
|
||||
from wrenn.models._generated import (
|
||||
APIKeyResponse,
|
||||
AuthResponse,
|
||||
Capsule,
|
||||
CreateAPIKeyRequest,
|
||||
CreateCapsuleRequest,
|
||||
CreateHostRequest,
|
||||
CreateHostResponse,
|
||||
CreateSandboxRequest,
|
||||
CreateSnapshotRequest,
|
||||
Encoding,
|
||||
Error,
|
||||
Error1,
|
||||
ExecRequest,
|
||||
ExecResponse,
|
||||
FileEntry,
|
||||
Host,
|
||||
ListDirRequest,
|
||||
ListDirResponse,
|
||||
LoginRequest,
|
||||
MakeDirRequest,
|
||||
MakeDirResponse,
|
||||
ReadFileRequest,
|
||||
RegisterHostRequest,
|
||||
RegisterHostResponse,
|
||||
RemoveRequest,
|
||||
Sandbox,
|
||||
SignupRequest,
|
||||
Status,
|
||||
Status1,
|
||||
@ -38,25 +32,19 @@ __all__ = [
|
||||
"CreateAPIKeyRequest",
|
||||
"CreateHostRequest",
|
||||
"CreateHostResponse",
|
||||
"CreateCapsuleRequest",
|
||||
"CreateSandboxRequest",
|
||||
"CreateSnapshotRequest",
|
||||
"Encoding",
|
||||
"Error",
|
||||
"Error1",
|
||||
"ExecRequest",
|
||||
"ExecResponse",
|
||||
"FileEntry",
|
||||
"Host",
|
||||
"ListDirRequest",
|
||||
"ListDirResponse",
|
||||
"LoginRequest",
|
||||
"MakeDirRequest",
|
||||
"MakeDirResponse",
|
||||
"ReadFileRequest",
|
||||
"RegisterHostRequest",
|
||||
"RegisterHostResponse",
|
||||
"RemoveRequest",
|
||||
"Capsule",
|
||||
"Sandbox",
|
||||
"SignupRequest",
|
||||
"Status",
|
||||
"Status1",
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: openapi.yaml
|
||||
# timestamp: 2026-04-22T20:21:34+00:00
|
||||
# timestamp: 2026-04-09T15:01:48+00:00
|
||||
|
||||
from __future__ import annotations
|
||||
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
|
||||
from typing import Annotated
|
||||
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):
|
||||
email: EmailStr
|
||||
password: Annotated[str, Field(min_length=8)]
|
||||
name: Annotated[str, Field(max_length=100)]
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@ -20,13 +20,6 @@ class LoginRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class SignupResponse(BaseModel):
|
||||
message: Annotated[
|
||||
str | None,
|
||||
Field(description="Confirmation message instructing user to check email"),
|
||||
] = None
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
token: Annotated[str | None, Field(description="JWT token (valid for 6 hours)")] = (
|
||||
None
|
||||
@ -34,7 +27,6 @@ class AuthResponse(BaseModel):
|
||||
user_id: str | None = None
|
||||
team_id: str | None = None
|
||||
email: str | None = None
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class CreateAPIKeyRequest(BaseModel):
|
||||
@ -58,89 +50,27 @@ class APIKeyResponse(BaseModel):
|
||||
] = None
|
||||
|
||||
|
||||
class CreateCapsuleRequest(BaseModel):
|
||||
class CreateSandboxRequest(BaseModel):
|
||||
template: str | None = "minimal"
|
||||
vcpus: int | None = 1
|
||||
memory_mb: int | None = 512
|
||||
timeout_sec: Annotated[
|
||||
int | None,
|
||||
Field(
|
||||
description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause.\n"
|
||||
description="Auto-pause TTL in seconds. The sandbox is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause.\n"
|
||||
),
|
||||
] = 0
|
||||
|
||||
|
||||
class Point(BaseModel):
|
||||
date: date_aliased | None = None
|
||||
cpu_minutes: float | None = None
|
||||
ram_mb_minutes: float | None = None
|
||||
|
||||
|
||||
class UsageResponse(BaseModel):
|
||||
from_: Annotated[date_aliased | None, Field(alias="from")] = None
|
||||
to: date_aliased | None = None
|
||||
points: list[Point] | None = None
|
||||
|
||||
|
||||
class Range(StrEnum):
|
||||
field_5m = "5m"
|
||||
field_1h = "1h"
|
||||
field_6h = "6h"
|
||||
field_24h = "24h"
|
||||
field_30d = "30d"
|
||||
|
||||
|
||||
class Current(BaseModel):
|
||||
running_count: int | None = None
|
||||
vcpus_reserved: int | None = None
|
||||
memory_mb_reserved: int | None = None
|
||||
sampled_at: AwareDatetime | None = None
|
||||
|
||||
|
||||
class Peaks(BaseModel):
|
||||
"""
|
||||
Maximum values over the last 30 days.
|
||||
"""
|
||||
|
||||
running_count: int | None = None
|
||||
vcpus: int | None = None
|
||||
memory_mb: int | None = None
|
||||
|
||||
|
||||
class Series(BaseModel):
|
||||
"""
|
||||
Parallel arrays for chart rendering.
|
||||
"""
|
||||
|
||||
labels: list[AwareDatetime] | None = None
|
||||
running: list[int] | None = None
|
||||
vcpus: list[int] | None = None
|
||||
memory_mb: list[int] | None = None
|
||||
|
||||
|
||||
class CapsuleStats(BaseModel):
|
||||
range: Range | None = None
|
||||
current: Current | None = None
|
||||
peaks: Annotated[
|
||||
Peaks | None, Field(description="Maximum values over the last 30 days.")
|
||||
] = None
|
||||
series: Annotated[
|
||||
Series | None, Field(description="Parallel arrays for chart rendering.")
|
||||
] = None
|
||||
|
||||
|
||||
class Status(StrEnum):
|
||||
pending = "pending"
|
||||
starting = "starting"
|
||||
running = "running"
|
||||
paused = "paused"
|
||||
hibernated = "hibernated"
|
||||
stopped = "stopped"
|
||||
missing = "missing"
|
||||
error = "error"
|
||||
|
||||
|
||||
class Capsule(BaseModel):
|
||||
class Sandbox(BaseModel):
|
||||
id: str | None = None
|
||||
status: Status | None = None
|
||||
template: str | None = None
|
||||
@ -157,7 +87,7 @@ class Capsule(BaseModel):
|
||||
|
||||
class CreateSnapshotRequest(BaseModel):
|
||||
sandbox_id: Annotated[
|
||||
str, Field(description="ID of the running capsule to snapshot.")
|
||||
str, Field(description="ID of the running sandbox to snapshot.")
|
||||
]
|
||||
name: Annotated[
|
||||
str | None,
|
||||
@ -182,50 +112,7 @@ class Template(BaseModel):
|
||||
class ExecRequest(BaseModel):
|
||||
cmd: str
|
||||
args: list[str] | None = None
|
||||
timeout_sec: Annotated[
|
||||
int | None,
|
||||
Field(description="Timeout in seconds (foreground exec only, default 30)"),
|
||||
] = 30
|
||||
background: Annotated[
|
||||
bool | None,
|
||||
Field(
|
||||
description="If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202)"
|
||||
),
|
||||
] = False
|
||||
tag: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
description="Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true."
|
||||
),
|
||||
] = None
|
||||
envs: Annotated[
|
||||
dict[str, str] | None,
|
||||
Field(
|
||||
description="Environment variables for the process (background exec only)"
|
||||
),
|
||||
] = None
|
||||
cwd: Annotated[
|
||||
str | None,
|
||||
Field(description="Working directory for the process (background exec only)"),
|
||||
] = None
|
||||
|
||||
|
||||
class BackgroundExecResponse(BaseModel):
|
||||
sandbox_id: str | None = None
|
||||
cmd: str | None = None
|
||||
pid: int | None = None
|
||||
tag: str | None = None
|
||||
|
||||
|
||||
class ProcessEntry(BaseModel):
|
||||
pid: int | None = None
|
||||
tag: str | None = None
|
||||
cmd: str | None = None
|
||||
args: list[str] | None = None
|
||||
|
||||
|
||||
class ProcessListResponse(BaseModel):
|
||||
processes: list[ProcessEntry] | None = None
|
||||
timeout_sec: int | None = 30
|
||||
|
||||
|
||||
class Encoding(StrEnum):
|
||||
@ -253,57 +140,10 @@ class ExecResponse(BaseModel):
|
||||
|
||||
|
||||
class ReadFileRequest(BaseModel):
|
||||
path: Annotated[str, Field(description="Absolute file path inside the capsule")]
|
||||
|
||||
|
||||
class ListDirRequest(BaseModel):
|
||||
path: Annotated[str, Field(description="Directory path inside the capsule")]
|
||||
depth: Annotated[
|
||||
int | None,
|
||||
Field(
|
||||
description="Recursion depth (0 = non-recursive, 1 = immediate children)"
|
||||
),
|
||||
] = 1
|
||||
path: Annotated[str, Field(description="Absolute file path inside the sandbox")]
|
||||
|
||||
|
||||
class Type1(StrEnum):
|
||||
file = "file"
|
||||
directory = "directory"
|
||||
symlink = "symlink"
|
||||
|
||||
|
||||
class FileEntry(BaseModel):
|
||||
name: str | None = None
|
||||
path: str | None = None
|
||||
type: Type1 | None = None
|
||||
size: int | None = None
|
||||
mode: int | None = None
|
||||
permissions: Annotated[
|
||||
str | None, Field(description='Human-readable permissions (e.g. "-rwxr-xr-x")')
|
||||
] = None
|
||||
owner: str | None = None
|
||||
group: str | None = None
|
||||
modified_at: Annotated[
|
||||
int | None, Field(description="Unix timestamp (seconds)")
|
||||
] = None
|
||||
symlink_target: str | None = None
|
||||
|
||||
|
||||
class MakeDirRequest(BaseModel):
|
||||
path: Annotated[
|
||||
str, Field(description="Directory path to create inside the capsule")
|
||||
]
|
||||
|
||||
|
||||
class MakeDirResponse(BaseModel):
|
||||
entry: FileEntry | None = None
|
||||
|
||||
|
||||
class RemoveRequest(BaseModel):
|
||||
path: Annotated[str, Field(description="Path to remove inside the capsule")]
|
||||
|
||||
|
||||
class Type2(StrEnum):
|
||||
"""
|
||||
Host type. Regular hosts are shared; BYOC hosts belong to a team.
|
||||
"""
|
||||
@ -314,7 +154,7 @@ class Type2(StrEnum):
|
||||
|
||||
class CreateHostRequest(BaseModel):
|
||||
type: Annotated[
|
||||
Type2,
|
||||
Type1,
|
||||
Field(
|
||||
description="Host type. Regular hosts are shared; BYOC hosts belong to a team."
|
||||
),
|
||||
@ -342,7 +182,7 @@ class RegisterHostRequest(BaseModel):
|
||||
address: Annotated[str, Field(description="Host agent address (ip:port).")]
|
||||
|
||||
|
||||
class Type3(StrEnum):
|
||||
class Type2(StrEnum):
|
||||
regular = "regular"
|
||||
byoc = "byoc"
|
||||
|
||||
@ -352,12 +192,11 @@ class Status1(StrEnum):
|
||||
online = "online"
|
||||
offline = "offline"
|
||||
draining = "draining"
|
||||
unreachable = "unreachable"
|
||||
|
||||
|
||||
class Host(BaseModel):
|
||||
id: str | None = None
|
||||
type: Type3 | None = None
|
||||
type: Type2 | None = None
|
||||
team_id: str | None = None
|
||||
provider: str | None = None
|
||||
availability_zone: str | None = None
|
||||
@ -373,226 +212,17 @@ class Host(BaseModel):
|
||||
updated_at: AwareDatetime | None = None
|
||||
|
||||
|
||||
class RefreshHostTokenRequest(BaseModel):
|
||||
refresh_token: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="Refresh token obtained from registration or a previous refresh."
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class RefreshHostTokenResponse(BaseModel):
|
||||
host: Host | None = None
|
||||
token: Annotated[
|
||||
str | None, Field(description="New host JWT. Valid for 7 days.")
|
||||
] = None
|
||||
refresh_token: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
description="New refresh token. Valid for 60 days; old token is revoked."
|
||||
),
|
||||
] = None
|
||||
|
||||
|
||||
class HostDeletePreview(BaseModel):
|
||||
host: Host | None = None
|
||||
sandbox_ids: Annotated[
|
||||
list[str] | None,
|
||||
Field(description="IDs of capsulees that would be destroyed on force-delete."),
|
||||
] = None
|
||||
|
||||
|
||||
class Error(BaseModel):
|
||||
code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None
|
||||
message: str | None = None
|
||||
sandbox_ids: Annotated[
|
||||
list[str] | None,
|
||||
Field(description="IDs of active capsulees blocking deletion."),
|
||||
] = None
|
||||
|
||||
|
||||
class HostHasCapsulesError(BaseModel):
|
||||
error: Error | None = None
|
||||
|
||||
|
||||
class AddTagRequest(BaseModel):
|
||||
tag: str
|
||||
|
||||
|
||||
class UserSearchResult(BaseModel):
|
||||
user_id: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class Team(BaseModel):
|
||||
id: str | None = None
|
||||
name: str | None = None
|
||||
slug: Annotated[
|
||||
str | None, Field(description="Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)")
|
||||
] = None
|
||||
created_at: AwareDatetime | None = None
|
||||
|
||||
|
||||
class Role(StrEnum):
|
||||
owner = "owner"
|
||||
admin = "admin"
|
||||
member = "member"
|
||||
|
||||
|
||||
class TeamWithRole(Team):
|
||||
role: Role | None = None
|
||||
|
||||
|
||||
class TeamMember(BaseModel):
|
||||
user_id: str | None = None
|
||||
email: str | None = None
|
||||
role: Role | None = None
|
||||
joined_at: AwareDatetime | None = None
|
||||
|
||||
|
||||
class TeamDetail(BaseModel):
|
||||
team: Team | None = None
|
||||
members: list[TeamMember] | None = None
|
||||
|
||||
|
||||
class Range1(StrEnum):
|
||||
field_5m = "5m"
|
||||
field_10m = "10m"
|
||||
field_1h = "1h"
|
||||
field_2h = "2h"
|
||||
field_6h = "6h"
|
||||
field_12h = "12h"
|
||||
field_24h = "24h"
|
||||
|
||||
|
||||
class MetricPoint(BaseModel):
|
||||
timestamp_unix: int | None = None
|
||||
cpu_pct: Annotated[
|
||||
float | None,
|
||||
Field(
|
||||
description="CPU utilization percentage (0-100), normalized to vCPU count"
|
||||
),
|
||||
] = None
|
||||
mem_bytes: Annotated[
|
||||
int | None,
|
||||
Field(description="Resident memory in bytes (VmRSS of Firecracker process)"),
|
||||
] = None
|
||||
disk_bytes: Annotated[
|
||||
int | None, Field(description="Allocated disk bytes for the CoW sparse file")
|
||||
] = None
|
||||
|
||||
|
||||
class Provider(StrEnum):
|
||||
discord = "discord"
|
||||
slack = "slack"
|
||||
teams = "teams"
|
||||
googlechat = "googlechat"
|
||||
telegram = "telegram"
|
||||
matrix = "matrix"
|
||||
webhook = "webhook"
|
||||
|
||||
|
||||
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"
|
||||
host_up = "host.up"
|
||||
host_down = "host.down"
|
||||
|
||||
|
||||
class CreateChannelRequest(BaseModel):
|
||||
name: Annotated[str, Field(description="Unique channel name within the team.")]
|
||||
provider: Provider
|
||||
config: Annotated[
|
||||
dict[str, str],
|
||||
Field(
|
||||
description='Provider-specific configuration fields. Discord/Slack/Teams/Google Chat: {"webhook_url": "..."}. Telegram: {"bot_token": "...", "chat_id": "..."}. Matrix: {"homeserver_url": "...", "access_token": "...", "room_id": "..."}. Webhook: {"url": "...", "secret": "..."} (secret is auto-generated if omitted).\n'
|
||||
),
|
||||
]
|
||||
events: list[Event]
|
||||
|
||||
|
||||
class TestChannelRequest(BaseModel):
|
||||
provider: Provider
|
||||
config: Annotated[
|
||||
dict[str, str],
|
||||
Field(
|
||||
description="Provider-specific configuration fields (same as CreateChannelRequest.config)."
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class RotateConfigRequest(BaseModel):
|
||||
config: Annotated[
|
||||
dict[str, str],
|
||||
Field(
|
||||
description="New provider configuration fields. Must include all required fields for the channel's provider. Replaces the existing config entirely.\n"
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class UpdateChannelRequest(BaseModel):
|
||||
name: str
|
||||
events: list[Event]
|
||||
|
||||
|
||||
class ChannelResponse(BaseModel):
|
||||
id: str | None = None
|
||||
team_id: str | None = None
|
||||
name: str | None = None
|
||||
provider: Provider | None = None
|
||||
events: list[str] | None = None
|
||||
created_at: AwareDatetime | None = None
|
||||
updated_at: AwareDatetime | None = None
|
||||
secret: Annotated[
|
||||
str | None,
|
||||
Field(description="Webhook secret. Only returned on creation, never again."),
|
||||
] = None
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
name: str | None = None
|
||||
email: EmailStr | None = None
|
||||
has_password: Annotated[
|
||||
bool | None,
|
||||
Field(
|
||||
description="Whether the user has a password set (false for OAuth-only accounts)"
|
||||
),
|
||||
] = None
|
||||
providers: Annotated[
|
||||
list[str] | None,
|
||||
Field(description='List of linked OAuth provider names (e.g. ["github"])'),
|
||||
] = None
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: Annotated[
|
||||
str | None, Field(description="Required when changing an existing password")
|
||||
] = None
|
||||
new_password: Annotated[str, Field(min_length=8)]
|
||||
confirm_password: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
description="Required when adding a password to an OAuth-only account (must match new_password)"
|
||||
),
|
||||
] = None
|
||||
|
||||
|
||||
class Error2(BaseModel):
|
||||
class Error1(BaseModel):
|
||||
code: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class Error1(BaseModel):
|
||||
error: Error2 | None = None
|
||||
|
||||
|
||||
class ListDirResponse(BaseModel):
|
||||
entries: list[FileEntry] | None = None
|
||||
class Error(BaseModel):
|
||||
error: Error1 | None = None
|
||||
|
||||
|
||||
class CreateHostResponse(BaseModel):
|
||||
@ -608,18 +238,8 @@ class CreateHostResponse(BaseModel):
|
||||
class RegisterHostResponse(BaseModel):
|
||||
host: Host | None = None
|
||||
token: Annotated[
|
||||
str | None,
|
||||
Field(description="Host JWT for X-Host-Token header. Valid for 7 days."),
|
||||
] = None
|
||||
refresh_token: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
description="Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use."
|
||||
description="Long-lived host JWT for X-Host-Token header. Valid for 1 year."
|
||||
),
|
||||
] = None
|
||||
|
||||
|
||||
class CapsuleMetrics(BaseModel):
|
||||
sandbox_id: str | None = None
|
||||
range: Range1 | None = None
|
||||
points: list[MetricPoint] | None = None
|
||||
|
||||
308
src/wrenn/pty.py
308
src/wrenn/pty.py
@ -1,308 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
import httpx_ws
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PtyEventType(StrEnum):
|
||||
started = "started"
|
||||
output = "output"
|
||||
exit = "exit"
|
||||
error = "error"
|
||||
ping = "ping"
|
||||
|
||||
|
||||
class PtyEvent(BaseModel):
|
||||
type: PtyEventType
|
||||
pid: int | None = None
|
||||
tag: str | None = None
|
||||
data: bytes | str | None = None
|
||||
exit_code: int | None = None
|
||||
fatal: bool | None = None
|
||||
|
||||
|
||||
def _parse_pty_event(raw: dict[str, Any]) -> PtyEvent:
|
||||
msg_type = raw.get("type", "")
|
||||
if msg_type == "started":
|
||||
return PtyEvent(
|
||||
type=PtyEventType.started,
|
||||
pid=raw.get("pid"),
|
||||
tag=raw.get("tag"),
|
||||
)
|
||||
if msg_type == "output":
|
||||
raw_data = raw.get("data", "")
|
||||
decoded = base64.b64decode(raw_data) if raw_data else b""
|
||||
return PtyEvent(type=PtyEventType.output, data=decoded)
|
||||
if msg_type == "exit":
|
||||
return PtyEvent(type=PtyEventType.exit, exit_code=raw.get("exit_code", -1))
|
||||
if msg_type == "error":
|
||||
return PtyEvent(
|
||||
type=PtyEventType.error,
|
||||
data=raw.get("data", ""),
|
||||
fatal=raw.get("fatal", False),
|
||||
)
|
||||
if msg_type == "ping":
|
||||
return PtyEvent(type=PtyEventType.ping)
|
||||
return PtyEvent(type=PtyEventType(msg_type) if msg_type else PtyEventType.ping)
|
||||
|
||||
|
||||
class PtySession:
|
||||
"""Interactive PTY session backed by a WebSocket.
|
||||
|
||||
Use as a context manager and iterate over events::
|
||||
|
||||
with sb.pty(cmd="/bin/bash") 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
|
||||
"""
|
||||
|
||||
def __init__(self, ws: httpx_ws.WebSocketSession, capsule_id: str) -> None:
|
||||
self._ws = ws
|
||||
self._capsule_id = capsule_id
|
||||
self._tag: str | None = None
|
||||
self._pid: int | None = None
|
||||
self._done = False
|
||||
|
||||
@property
|
||||
def tag(self) -> str | None:
|
||||
"""Session tag. Available after the ``started`` event."""
|
||||
return self._tag
|
||||
|
||||
@property
|
||||
def pid(self) -> int | None:
|
||||
"""Process PID. Available after the ``started`` event."""
|
||||
return self._pid
|
||||
|
||||
def _send_start(
|
||||
self,
|
||||
cmd: str = "/bin/bash",
|
||||
args: list[str] | None = None,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
envs: dict[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
) -> None:
|
||||
msg: dict[str, Any] = {
|
||||
"type": "start",
|
||||
"cmd": cmd,
|
||||
"cols": cols or 80,
|
||||
"rows": rows or 24,
|
||||
}
|
||||
if args:
|
||||
msg["args"] = args
|
||||
if envs:
|
||||
msg["envs"] = envs
|
||||
if cwd:
|
||||
msg["cwd"] = cwd
|
||||
self._ws.send_text(json.dumps(msg))
|
||||
|
||||
def _send_connect(self, tag: str) -> None:
|
||||
self._ws.send_text(json.dumps({"type": "connect", "tag": tag}))
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
"""Send raw bytes to the PTY stdin.
|
||||
|
||||
Args:
|
||||
data: Raw bytes to send. Base64-encoded internally.
|
||||
"""
|
||||
encoded = base64.b64encode(data).decode("ascii")
|
||||
self._ws.send_text(json.dumps({"type": "input", "data": encoded}))
|
||||
|
||||
def resize(self, cols: int, rows: int) -> None:
|
||||
"""Resize the PTY terminal.
|
||||
|
||||
Args:
|
||||
cols: New column count. Must be > 0.
|
||||
rows: New row count. Must be > 0.
|
||||
|
||||
Raises:
|
||||
ValueError: If cols or rows is 0.
|
||||
"""
|
||||
if cols <= 0 or rows <= 0:
|
||||
raise ValueError("cols and rows must be greater than 0")
|
||||
self._ws.send_text(json.dumps({"type": "resize", "cols": cols, "rows": rows}))
|
||||
|
||||
def kill(self) -> None:
|
||||
"""Send SIGKILL to the PTY process."""
|
||||
self._ws.send_text(json.dumps({"type": "kill"}))
|
||||
|
||||
def __iter__(self) -> Iterator[PtyEvent]:
|
||||
return self
|
||||
|
||||
def __next__(self) -> PtyEvent:
|
||||
if self._done:
|
||||
raise StopIteration
|
||||
try:
|
||||
raw = self._ws.receive_text()
|
||||
except httpx_ws.WebSocketDisconnect:
|
||||
raise StopIteration
|
||||
event = _parse_pty_event(json.loads(raw))
|
||||
if event.type == PtyEventType.started:
|
||||
if event.tag is not None:
|
||||
self._tag = event.tag
|
||||
if event.pid is not None:
|
||||
self._pid = event.pid
|
||||
if event.type == PtyEventType.exit:
|
||||
self._done = True
|
||||
return event
|
||||
if event.type == PtyEventType.error and event.fatal:
|
||||
self._done = True
|
||||
return event
|
||||
return event
|
||||
|
||||
def __enter__(self) -> PtySession:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: object,
|
||||
) -> None:
|
||||
try:
|
||||
self.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class AsyncPtySession:
|
||||
"""Async interactive PTY session backed by a WebSocket.
|
||||
|
||||
Use as an async context manager and async iterate over events::
|
||||
|
||||
async with sb.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)
|
||||
elif event.type == "exit":
|
||||
break
|
||||
"""
|
||||
|
||||
def __init__(self, ws: httpx_ws.AsyncWebSocketSession, capsule_id: str) -> None:
|
||||
self._ws = ws
|
||||
self._capsule_id = capsule_id
|
||||
self._tag: str | None = None
|
||||
self._pid: int | None = None
|
||||
self._done = False
|
||||
|
||||
@property
|
||||
def tag(self) -> str | None:
|
||||
"""Session tag. Available after the ``started`` event."""
|
||||
return self._tag
|
||||
|
||||
@property
|
||||
def pid(self) -> int | None:
|
||||
"""Process PID. Available after the ``started`` event."""
|
||||
return self._pid
|
||||
|
||||
async def _send_start(
|
||||
self,
|
||||
cmd: str = "/bin/bash",
|
||||
args: list[str] | None = None,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
envs: dict[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
) -> None:
|
||||
msg: dict[str, Any] = {
|
||||
"type": "start",
|
||||
"cmd": cmd,
|
||||
"cols": cols or 80,
|
||||
"rows": rows or 24,
|
||||
}
|
||||
if args:
|
||||
msg["args"] = args
|
||||
if envs:
|
||||
msg["envs"] = envs
|
||||
if cwd:
|
||||
msg["cwd"] = cwd
|
||||
await self._ws.send_text(json.dumps(msg))
|
||||
|
||||
async def _send_connect(self, tag: str) -> None:
|
||||
await self._ws.send_text(json.dumps({"type": "connect", "tag": tag}))
|
||||
|
||||
async def write(self, data: bytes) -> None:
|
||||
"""Send raw bytes to the PTY stdin.
|
||||
|
||||
Args:
|
||||
data: Raw bytes to send. Base64-encoded internally.
|
||||
"""
|
||||
encoded = base64.b64encode(data).decode("ascii")
|
||||
await self._ws.send_text(json.dumps({"type": "input", "data": encoded}))
|
||||
|
||||
async def resize(self, cols: int, rows: int) -> None:
|
||||
"""Resize the PTY terminal.
|
||||
|
||||
Args:
|
||||
cols: New column count. Must be > 0.
|
||||
rows: New row count. Must be > 0.
|
||||
|
||||
Raises:
|
||||
ValueError: If cols or rows is 0.
|
||||
"""
|
||||
if cols <= 0 or rows <= 0:
|
||||
raise ValueError("cols and rows must be greater than 0")
|
||||
await self._ws.send_text(
|
||||
json.dumps({"type": "resize", "cols": cols, "rows": rows})
|
||||
)
|
||||
|
||||
async def kill(self) -> None:
|
||||
"""Send SIGKILL to the PTY process."""
|
||||
await self._ws.send_text(json.dumps({"type": "kill"}))
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[PtyEvent]:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> PtyEvent:
|
||||
if self._done:
|
||||
raise StopAsyncIteration
|
||||
try:
|
||||
raw = await self._ws.receive_text()
|
||||
except httpx_ws.WebSocketDisconnect:
|
||||
raise StopAsyncIteration
|
||||
event = _parse_pty_event(json.loads(raw))
|
||||
if event.type == PtyEventType.started:
|
||||
if event.tag is not None:
|
||||
self._tag = event.tag
|
||||
if event.pid is not None:
|
||||
self._pid = event.pid
|
||||
if event.type == PtyEventType.exit:
|
||||
self._done = True
|
||||
return event
|
||||
if event.type == PtyEventType.error and event.fatal:
|
||||
self._done = True
|
||||
return event
|
||||
return event
|
||||
|
||||
async def __aenter__(self) -> AsyncPtySession:
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: object,
|
||||
) -> None:
|
||||
try:
|
||||
await self.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await self._ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
@ -1,22 +1,928 @@
|
||||
import warnings as _warnings
|
||||
from __future__ import annotations
|
||||
|
||||
from wrenn.capsule import Capsule # noqa: F401
|
||||
from wrenn.commands import ( # noqa: F401
|
||||
StreamErrorEvent,
|
||||
StreamEvent,
|
||||
StreamExitEvent,
|
||||
StreamStartEvent,
|
||||
StreamStderrEvent,
|
||||
StreamStdoutEvent,
|
||||
)
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import httpx_ws
|
||||
|
||||
from wrenn.exceptions import WrennAuthenticationError
|
||||
from wrenn.models import ExecResponse, Status
|
||||
from wrenn.models import Sandbox as SandboxModel
|
||||
|
||||
|
||||
def __getattr__(name: str) -> type:
|
||||
if name == "Sandbox":
|
||||
_warnings.warn(
|
||||
"'Sandbox' is deprecated, use 'Capsule' instead",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
class ExecResult:
|
||||
"""Typed result from a synchronous exec call."""
|
||||
|
||||
__slots__ = ("stdout", "stderr", "exit_code", "duration_ms", "encoding")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stdout: str,
|
||||
stderr: str,
|
||||
exit_code: int,
|
||||
duration_ms: int | None,
|
||||
encoding: str | None,
|
||||
) -> None:
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self.exit_code = exit_code
|
||||
self.duration_ms = duration_ms
|
||||
self.encoding = encoding
|
||||
|
||||
|
||||
class CodeResult:
|
||||
"""Typed result from stateful code execution (``run_code``).
|
||||
|
||||
Attributes:
|
||||
text: text/plain representation of the result.
|
||||
data: rich MIME bundle (e.g. ``{"image/png": "..."}``).
|
||||
stdout: accumulated stdout output.
|
||||
stderr: accumulated stderr output.
|
||||
error: language-specific error/traceback string.
|
||||
"""
|
||||
|
||||
__slots__ = ("text", "data", "stdout", "stderr", "error")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: str | None = None,
|
||||
data: dict[str, str] | None = None,
|
||||
stdout: str = "",
|
||||
stderr: str = "",
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
self.text = text
|
||||
self.data = data
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self.error = error
|
||||
|
||||
|
||||
class StreamEvent:
|
||||
"""Base class for streaming exec events."""
|
||||
|
||||
__slots__ = ("type",)
|
||||
|
||||
def __init__(self, type: str) -> None:
|
||||
self.type = type
|
||||
|
||||
|
||||
class StreamStartEvent(StreamEvent):
|
||||
"""Process started."""
|
||||
|
||||
__slots__ = ("pid",)
|
||||
|
||||
def __init__(self, pid: int) -> None:
|
||||
super().__init__("start")
|
||||
self.pid = pid
|
||||
|
||||
|
||||
class StreamStdoutEvent(StreamEvent):
|
||||
"""Stdout data received."""
|
||||
|
||||
__slots__ = ("data",)
|
||||
|
||||
def __init__(self, data: str) -> None:
|
||||
super().__init__("stdout")
|
||||
self.data = data
|
||||
|
||||
|
||||
class StreamStderrEvent(StreamEvent):
|
||||
"""Stderr data received."""
|
||||
|
||||
__slots__ = ("data",)
|
||||
|
||||
def __init__(self, data: str) -> None:
|
||||
super().__init__("stderr")
|
||||
self.data = data
|
||||
|
||||
|
||||
class StreamExitEvent(StreamEvent):
|
||||
"""Process exited."""
|
||||
|
||||
__slots__ = ("exit_code",)
|
||||
|
||||
def __init__(self, exit_code: int) -> None:
|
||||
super().__init__("exit")
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
class StreamErrorEvent(StreamEvent):
|
||||
"""Error occurred."""
|
||||
|
||||
__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 _build_proxy_url(base_url: str, sandbox_id: str | None, port: int) -> str:
|
||||
parsed = httpx.URL(base_url)
|
||||
host = parsed.host
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
scheme = "ws" if parsed.scheme == "http" else "wss"
|
||||
return f"{scheme}://{port}-{sandbox_id}.{host}"
|
||||
|
||||
|
||||
class Sandbox(SandboxModel):
|
||||
"""Developer-facing sandbox interface wrapping the generated Sandbox model.
|
||||
|
||||
Provides data-plane methods (exec, file I/O, lifecycle), sandbox proxy
|
||||
helpers, and context-manager support for automatic cleanup.
|
||||
"""
|
||||
|
||||
_http: httpx.Client | None
|
||||
_async_http: httpx.AsyncClient | None
|
||||
_base_url: str
|
||||
_api_key: str | None
|
||||
_token: str | None
|
||||
_proxy_client: httpx.Client | None
|
||||
_async_proxy_client: httpx.AsyncClient | None
|
||||
_kernel_id: str | None
|
||||
_jupyter_ws: Any
|
||||
_async_jupyter_ws: Any
|
||||
|
||||
def _bind(
|
||||
self,
|
||||
http: httpx.Client | httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str | None = None,
|
||||
token: str | None = None,
|
||||
) -> None:
|
||||
self._base_url = base_url
|
||||
self._api_key = api_key
|
||||
self._token = token
|
||||
self._proxy_client = None
|
||||
self._async_proxy_client = None
|
||||
self._kernel_id = None
|
||||
self._jupyter_ws = None
|
||||
self._async_jupyter_ws = None
|
||||
if isinstance(http, httpx.Client):
|
||||
self._http = http
|
||||
self._async_http = None
|
||||
else:
|
||||
self._http = None # type: ignore[assignment]
|
||||
self._async_http = http
|
||||
|
||||
def _require_api_key(self) -> str:
|
||||
if not self._api_key:
|
||||
raise WrennAuthenticationError(
|
||||
code="unauthorized",
|
||||
message="Proxy requires an API key. JWT-only clients cannot use proxy routes.",
|
||||
status_code=401,
|
||||
)
|
||||
return Capsule
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
return self._api_key
|
||||
|
||||
def _clear_content_type(self) -> dict[str, str]:
|
||||
assert self._http is not None
|
||||
headers = dict(self._http.headers)
|
||||
headers.pop("Content-Type", None)
|
||||
return headers
|
||||
|
||||
def _async_clear_content_type(self) -> dict[str, str]:
|
||||
assert self._async_http is not None
|
||||
headers = dict(self._async_http.headers)
|
||||
headers.pop("Content-Type", None)
|
||||
return headers
|
||||
|
||||
def get_url(self, port: int) -> str:
|
||||
"""Construct the proxy URL for a port inside this sandbox.
|
||||
|
||||
Args:
|
||||
port: Port number of the service running inside the sandbox.
|
||||
|
||||
Returns:
|
||||
A URL string like ``http://8888-cl-abc123.api.wrenn.dev``.
|
||||
|
||||
Raises:
|
||||
WrennAuthenticationError: If the client was constructed with JWT only.
|
||||
"""
|
||||
self._require_api_key()
|
||||
return _build_proxy_url(self._base_url, self.id, port)
|
||||
|
||||
@property
|
||||
def http_client(self) -> httpx.Client:
|
||||
"""A pre-configured ``httpx.Client`` targeting the sandbox proxy on port 8888.
|
||||
|
||||
The client has the ``X-API-Key`` header set and ``base_url`` pointing to
|
||||
the proxy URL for port 8888. Closed automatically when the sandbox exits.
|
||||
|
||||
Raises:
|
||||
WrennAuthenticationError: If the client was constructed with JWT only.
|
||||
"""
|
||||
self._require_api_key()
|
||||
if self._proxy_client is None:
|
||||
url = (
|
||||
_build_proxy_url(self._base_url, self.id, 8888)
|
||||
.replace("ws://", "http://")
|
||||
.replace("wss://", "https://")
|
||||
)
|
||||
self._proxy_client = httpx.Client(
|
||||
base_url=url,
|
||||
headers={"X-API-Key": self._api_key}, # type: ignore[dict-item, arg-type]
|
||||
)
|
||||
return self._proxy_client
|
||||
|
||||
def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None:
|
||||
"""Block until the sandbox status is ``running``.
|
||||
|
||||
Args:
|
||||
timeout: Maximum seconds to wait.
|
||||
interval: Seconds between polls.
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the sandbox does not become ready in time.
|
||||
"""
|
||||
assert self._http is not None
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
resp = self._http.get(f"/v1/sandboxes/{self.id}")
|
||||
data = resp.json()
|
||||
status = data.get("status")
|
||||
if status == Status.running:
|
||||
self.status = Status.running
|
||||
return
|
||||
if status in (Status.error, Status.stopped):
|
||||
raise RuntimeError(f"Sandbox entered {status} state while waiting")
|
||||
time.sleep(interval)
|
||||
raise TimeoutError(f"Sandbox {self.id} did not become ready within {timeout}s")
|
||||
|
||||
async def async_wait_ready(
|
||||
self, timeout: float = 30, interval: float = 0.5
|
||||
) -> None:
|
||||
"""Async version of ``wait_ready``."""
|
||||
assert self._async_http is not None
|
||||
import asyncio
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
resp = await self._async_http.get(f"/v1/sandboxes/{self.id}")
|
||||
data = resp.json()
|
||||
status = data.get("status")
|
||||
if status == Status.running:
|
||||
self.status = Status.running
|
||||
return
|
||||
if status in (Status.error, Status.stopped):
|
||||
raise RuntimeError(f"Sandbox entered {status} state while waiting")
|
||||
await asyncio.sleep(interval)
|
||||
raise TimeoutError(f"Sandbox {self.id} did not become ready within {timeout}s")
|
||||
|
||||
def exec(
|
||||
self,
|
||||
cmd: str,
|
||||
args: list[str] | None = None,
|
||||
timeout_sec: int | None = 30,
|
||||
) -> ExecResult:
|
||||
"""Execute a command synchronously inside the sandbox.
|
||||
|
||||
Args:
|
||||
cmd: Command to run.
|
||||
args: Optional positional arguments.
|
||||
timeout_sec: Execution timeout in seconds.
|
||||
|
||||
Returns:
|
||||
An ``ExecResult`` with ``stdout``, ``stderr``, ``exit_code``, ``duration_ms``.
|
||||
"""
|
||||
assert self._http is not None
|
||||
payload: dict = {"cmd": cmd}
|
||||
if args is not None:
|
||||
payload["args"] = args
|
||||
if timeout_sec is not None:
|
||||
payload["timeout_sec"] = timeout_sec
|
||||
resp = self._http.post(f"/v1/sandboxes/{self.id}/exec", json=payload)
|
||||
resp.raise_for_status()
|
||||
er = ExecResponse.model_validate(resp.json())
|
||||
stdout = er.stdout or ""
|
||||
stderr = er.stderr or ""
|
||||
if er.encoding == "base64":
|
||||
stdout = base64.b64decode(stdout).decode("utf-8", errors="replace")
|
||||
if stderr:
|
||||
stderr = base64.b64decode(stderr).decode("utf-8", errors="replace")
|
||||
return ExecResult(
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
exit_code=er.exit_code if er.exit_code is not None else -1,
|
||||
duration_ms=er.duration_ms,
|
||||
encoding=er.encoding,
|
||||
)
|
||||
|
||||
async def async_exec(
|
||||
self,
|
||||
cmd: str,
|
||||
args: list[str] | None = None,
|
||||
timeout_sec: int | None = 30,
|
||||
) -> ExecResult:
|
||||
"""Async version of ``exec``."""
|
||||
assert self._async_http is not None
|
||||
payload: dict = {"cmd": cmd}
|
||||
if args is not None:
|
||||
payload["args"] = args
|
||||
if timeout_sec is not None:
|
||||
payload["timeout_sec"] = timeout_sec
|
||||
resp = await self._async_http.post(
|
||||
f"/v1/sandboxes/{self.id}/exec", json=payload
|
||||
)
|
||||
resp.raise_for_status()
|
||||
er = ExecResponse.model_validate(resp.json())
|
||||
stdout = er.stdout or ""
|
||||
stderr = er.stderr or ""
|
||||
if er.encoding == "base64":
|
||||
stdout = base64.b64decode(stdout).decode("utf-8", errors="replace")
|
||||
if stderr:
|
||||
stderr = base64.b64decode(stderr).decode("utf-8", errors="replace")
|
||||
return ExecResult(
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
exit_code=er.exit_code if er.exit_code is not None else -1,
|
||||
duration_ms=er.duration_ms,
|
||||
encoding=er.encoding,
|
||||
)
|
||||
|
||||
def exec_stream(
|
||||
self,
|
||||
cmd: str,
|
||||
args: list[str] | None = None,
|
||||
) -> Iterator[StreamEvent]:
|
||||
"""Execute a command via WebSocket, yielding ``StreamEvent`` objects.
|
||||
|
||||
Args:
|
||||
cmd: Command to run.
|
||||
args: Optional positional arguments.
|
||||
|
||||
Yields:
|
||||
``StreamStartEvent``, ``StreamStdoutEvent``, ``StreamStderrEvent``,
|
||||
``StreamExitEvent``, or ``StreamErrorEvent``.
|
||||
"""
|
||||
assert self._http is not None
|
||||
with httpx_ws.ws_connect( # type: ignore[attr-defined]
|
||||
f"/v1/sandboxes/{self.id}/exec/stream",
|
||||
self._http,
|
||||
) as ws:
|
||||
start_msg: dict = {"type": "start", "cmd": cmd}
|
||||
if args:
|
||||
start_msg["args"] = args
|
||||
ws.send(json.dumps(start_msg))
|
||||
for raw_msg in ws:
|
||||
event = _parse_stream_event(json.loads(raw_msg))
|
||||
yield event
|
||||
if event.type in ("exit", "error"):
|
||||
break
|
||||
|
||||
async def async_exec_stream(
|
||||
self, cmd: str, args: list[str] | None = None
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""Async version of ``exec_stream``."""
|
||||
assert self._async_http is not None
|
||||
async with httpx_ws.aconnect_ws( # type: ignore[attr-defined, var-annotated]
|
||||
f"/v1/sandboxes/{self.id}/exec/stream", self._async_http
|
||||
) as ws:
|
||||
start_msg: dict = {"type": "start", "cmd": cmd}
|
||||
if args:
|
||||
start_msg["args"] = args
|
||||
await ws.send_text(json.dumps(start_msg))
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw_data = await ws.receive_json()
|
||||
event = _parse_stream_event(raw_data)
|
||||
yield event
|
||||
|
||||
if event.type in ("exit", "error"):
|
||||
break
|
||||
except httpx_ws.WebSocketDisconnect:
|
||||
pass
|
||||
|
||||
def upload(self, path: str, data: bytes) -> None:
|
||||
"""Upload a small file to the sandbox.
|
||||
|
||||
Args:
|
||||
path: Absolute destination path inside the sandbox.
|
||||
data: File contents as bytes.
|
||||
"""
|
||||
assert self._http is not None
|
||||
original_ct = self._http.headers.pop("Content-Type", None)
|
||||
try:
|
||||
resp = self._http.post(
|
||||
f"/v1/sandboxes/{self.id}/files/write",
|
||||
files={"file": ("upload", data)},
|
||||
data={"path": path},
|
||||
)
|
||||
finally:
|
||||
if original_ct is not None:
|
||||
self._http.headers["content-type"] = original_ct
|
||||
|
||||
resp.raise_for_status()
|
||||
|
||||
async def async_upload(self, path: str, data: bytes) -> None:
|
||||
"""Async version of ``upload``."""
|
||||
assert self._async_http is not None
|
||||
original_ct = self._async_http.headers.pop("Content-Type", None)
|
||||
try:
|
||||
resp = await self._async_http.post(
|
||||
f"/v1/sandboxes/{self.id}/files/write",
|
||||
files={"file": ("upload", data)},
|
||||
data={"path": path},
|
||||
)
|
||||
finally:
|
||||
if original_ct is not None:
|
||||
self._async_http.headers["Content-Type"] = original_ct
|
||||
|
||||
resp.raise_for_status()
|
||||
|
||||
def download(self, path: str) -> bytes:
|
||||
"""Download a small file from the sandbox.
|
||||
|
||||
Args:
|
||||
path: Absolute file path inside the sandbox.
|
||||
|
||||
Returns:
|
||||
File contents as bytes.
|
||||
"""
|
||||
assert self._http is not None
|
||||
resp = self._http.post(
|
||||
f"/v1/sandboxes/{self.id}/files/read",
|
||||
json={"path": path},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
async def async_download(self, path: str) -> bytes:
|
||||
"""Async version of ``download``."""
|
||||
assert self._async_http is not None
|
||||
resp = await self._async_http.post(
|
||||
f"/v1/sandboxes/{self.id}/files/read",
|
||||
json={"path": path},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
def stream_upload(self, path: str, stream: Iterator[bytes]) -> None:
|
||||
"""Streaming upload for large files.
|
||||
|
||||
Args:
|
||||
path: Absolute destination path inside the sandbox.
|
||||
stream: An iterator yielding byte chunks.
|
||||
"""
|
||||
assert self._http is not None
|
||||
|
||||
def _gen() -> Iterator[bytes]:
|
||||
yield from stream
|
||||
|
||||
original_ct = self._http.headers.pop("Content-Type", None)
|
||||
try:
|
||||
resp = self._http.post(
|
||||
f"/v1/sandboxes/{self.id}/files/stream/write",
|
||||
files={"file": ("upload", _gen())}, # type: ignore[dict-item]
|
||||
data={"path": path},
|
||||
)
|
||||
finally:
|
||||
if original_ct is not None:
|
||||
self._http.headers["Content-Type"] = original_ct
|
||||
|
||||
resp.raise_for_status()
|
||||
|
||||
async def async_stream_upload(
|
||||
self, path: str, stream: AsyncIterator[bytes]
|
||||
) -> None:
|
||||
"""Async version of ``stream_upload``."""
|
||||
assert self._async_http is not None
|
||||
|
||||
async def _gen() -> AsyncIterator[bytes]:
|
||||
async for chunk in stream:
|
||||
yield chunk
|
||||
|
||||
original_ct = self._async_http.headers.pop("Content-Type", None)
|
||||
try:
|
||||
resp = await self._async_http.post(
|
||||
f"/v1/sandboxes/{self.id}/files/stream/write",
|
||||
files={"file": ("upload", _gen())}, # type: ignore[dict-item]
|
||||
data={"path": path},
|
||||
)
|
||||
finally:
|
||||
if original_ct is not None:
|
||||
self._async_http.headers["Content-Type"] = original_ct
|
||||
|
||||
resp.raise_for_status()
|
||||
|
||||
def stream_download(self, path: str) -> Iterator[bytes]:
|
||||
"""Streaming download for large files.
|
||||
|
||||
Args:
|
||||
path: Absolute file path inside the sandbox.
|
||||
|
||||
Yields:
|
||||
Byte chunks.
|
||||
"""
|
||||
assert self._http is not None
|
||||
with self._http.stream(
|
||||
"POST",
|
||||
f"/v1/sandboxes/{self.id}/files/stream/read",
|
||||
json={"path": path},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
yield from resp.iter_bytes()
|
||||
|
||||
async def async_stream_download(self, path: str) -> AsyncIterator[bytes]:
|
||||
"""Async version of ``stream_download``."""
|
||||
assert self._async_http is not None
|
||||
async with self._async_http.stream(
|
||||
"POST",
|
||||
f"/v1/sandboxes/{self.id}/files/stream/read",
|
||||
json={"path": path},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
async for chunk in resp.aiter_bytes():
|
||||
yield chunk
|
||||
|
||||
def ping(self) -> None:
|
||||
"""Reset the sandbox inactivity timer."""
|
||||
assert self._http is not None
|
||||
resp = self._http.post(f"/v1/sandboxes/{self.id}/ping")
|
||||
resp.raise_for_status()
|
||||
|
||||
async def async_ping(self) -> None:
|
||||
"""Async version of ``ping``."""
|
||||
assert self._async_http is not None
|
||||
resp = await self._async_http.post(f"/v1/sandboxes/{self.id}/ping")
|
||||
resp.raise_for_status()
|
||||
|
||||
def pause(self) -> Sandbox:
|
||||
"""Pause the sandbox (snapshot and release resources).
|
||||
|
||||
Returns:
|
||||
Updated ``Sandbox`` with new status.
|
||||
"""
|
||||
assert self._http is not None
|
||||
resp = self._http.post(f"/v1/sandboxes/{self.id}/pause")
|
||||
resp.raise_for_status()
|
||||
updated = Sandbox.model_validate(resp.json())
|
||||
self.status = updated.status
|
||||
return self
|
||||
|
||||
async def async_pause(self) -> Sandbox:
|
||||
"""Async version of ``pause``."""
|
||||
assert self._async_http is not None
|
||||
resp = await self._async_http.post(f"/v1/sandboxes/{self.id}/pause")
|
||||
resp.raise_for_status()
|
||||
updated = Sandbox.model_validate(resp.json())
|
||||
self.status = updated.status
|
||||
return self
|
||||
|
||||
def resume(self) -> Sandbox:
|
||||
"""Resume a paused sandbox from its snapshot.
|
||||
|
||||
Returns:
|
||||
Updated ``Sandbox`` with new status.
|
||||
"""
|
||||
assert self._http is not None
|
||||
resp = self._http.post(f"/v1/sandboxes/{self.id}/resume")
|
||||
resp.raise_for_status()
|
||||
updated = Sandbox.model_validate(resp.json())
|
||||
self.status = updated.status
|
||||
return self
|
||||
|
||||
async def async_resume(self) -> Sandbox:
|
||||
"""Async version of ``resume``."""
|
||||
assert self._async_http is not None
|
||||
resp = await self._async_http.post(f"/v1/sandboxes/{self.id}/resume")
|
||||
resp.raise_for_status()
|
||||
updated = Sandbox.model_validate(resp.json())
|
||||
self.status = updated.status
|
||||
return self
|
||||
|
||||
def destroy(self) -> None:
|
||||
"""Tear down the sandbox."""
|
||||
assert self._http is not None
|
||||
resp = self._http.delete(f"/v1/sandboxes/{self.id}")
|
||||
resp.raise_for_status()
|
||||
|
||||
async def async_destroy(self) -> None:
|
||||
"""Async version of ``destroy``."""
|
||||
assert self._async_http is not None
|
||||
resp = await self._async_http.delete(f"/v1/sandboxes/{self.id}")
|
||||
resp.raise_for_status()
|
||||
|
||||
def _ensure_kernel(self, jupyter_timeout: float = 30) -> str:
|
||||
"""Ensure a Jupyter kernel is running, creating one if needed.
|
||||
|
||||
Polls the Jupyter server until it responds, then creates a kernel.
|
||||
|
||||
Args:
|
||||
jupyter_timeout: Maximum seconds to wait for Jupyter to become available.
|
||||
|
||||
Returns:
|
||||
The kernel ID.
|
||||
|
||||
Raises:
|
||||
TimeoutError: If Jupyter doesn't respond within the timeout.
|
||||
"""
|
||||
current_kernel = self._kernel_id
|
||||
if current_kernel is not None:
|
||||
return current_kernel
|
||||
deadline = time.monotonic() + jupyter_timeout
|
||||
last_exc: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
resp = self.http_client.post("/api/kernels")
|
||||
if resp.status_code < 500:
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self._kernel_id = data["id"]
|
||||
return str(self._kernel_id)
|
||||
last_exc = httpx.HTTPStatusError(
|
||||
f"Jupyter returned {resp.status_code}",
|
||||
request=resp.request,
|
||||
response=resp,
|
||||
)
|
||||
except (httpx.HTTPStatusError, WrennAuthenticationError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
time.sleep(0.5)
|
||||
raise TimeoutError(
|
||||
f"Jupyter not available within {jupyter_timeout}s: {last_exc}"
|
||||
)
|
||||
|
||||
async def _async_ensure_kernel(self, jupyter_timeout: float = 30) -> str:
|
||||
"""Async version of ``_ensure_kernel``."""
|
||||
import asyncio
|
||||
|
||||
current_kernel = self._kernel_id
|
||||
if current_kernel is not None:
|
||||
return current_kernel
|
||||
|
||||
self._require_api_key()
|
||||
if self._async_proxy_client is None:
|
||||
url = (
|
||||
_build_proxy_url(self._base_url, self.id, 8888)
|
||||
.replace("ws://", "http://")
|
||||
.replace("wss://", "https://")
|
||||
)
|
||||
self._async_proxy_client = httpx.AsyncClient(
|
||||
base_url=url,
|
||||
headers={"X-API-Key": self._api_key}, # type: ignore[dict-item, arg-type]
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + jupyter_timeout
|
||||
last_exc: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
resp = await self._async_proxy_client.post("/api/kernels")
|
||||
if resp.status_code < 500:
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self._kernel_id = data["id"]
|
||||
return str(self._kernel_id)
|
||||
last_exc = httpx.HTTPStatusError(
|
||||
f"Jupyter returned {resp.status_code}",
|
||||
request=resp.request,
|
||||
response=resp,
|
||||
)
|
||||
except httpx.HTTPStatusError:
|
||||
raise
|
||||
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._base_url, self.id, 8888)
|
||||
return f"{proxy}/api/kernels/{kernel_id}/channels"
|
||||
|
||||
def _jupyter_execute_request(self, 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",
|
||||
"msg_id": msg_id,
|
||||
"msg_type": "execute_request",
|
||||
}
|
||||
|
||||
def run_code(
|
||||
self,
|
||||
code: str,
|
||||
language: str = "python",
|
||||
timeout: float = 30,
|
||||
jupyter_timeout: float = 30,
|
||||
) -> CodeResult:
|
||||
"""Execute code in a persistent kernel inside the sandbox.
|
||||
|
||||
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.
|
||||
|
||||
Returns:
|
||||
A ``CodeResult`` with ``.text``, ``.data``, ``.stdout``, ``.stderr``, ``.error``.
|
||||
|
||||
Raises:
|
||||
WrennAuthenticationError: If the client was constructed with JWT only.
|
||||
"""
|
||||
assert self._http is not None
|
||||
kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout)
|
||||
ws_url = self._jupyter_ws_url(kernel_id)
|
||||
api_key = self._require_api_key()
|
||||
|
||||
msg = self._jupyter_execute_request(code)
|
||||
msg_id = msg["msg_id"]
|
||||
|
||||
result = CodeResult()
|
||||
deadline = time.monotonic() + timeout
|
||||
|
||||
headers = {"X-API-Key": api_key}
|
||||
if self._token:
|
||||
headers["Authorization"] = f"Bearer {self._token}"
|
||||
|
||||
with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: ignore[attr-defined, var-annotated]
|
||||
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 (TimeoutError, 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":
|
||||
name = content.get("name", "stdout")
|
||||
if name == "stderr":
|
||||
result.stderr += content.get("text", "")
|
||||
else:
|
||||
result.stdout += content.get("text", "")
|
||||
elif msg_type == "execute_result":
|
||||
bundle = content.get("data", {})
|
||||
result.text = bundle.get("text/plain")
|
||||
result.data = bundle
|
||||
elif msg_type == "error":
|
||||
traceback = content.get("traceback", [])
|
||||
result.error = "\n".join(traceback)
|
||||
elif msg_type == "status" and content.get("execution_state") == "idle":
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
async def async_run_code(
|
||||
self,
|
||||
code: str,
|
||||
language: str = "python",
|
||||
timeout: float = 30,
|
||||
jupyter_timeout: float = 30,
|
||||
) -> CodeResult:
|
||||
"""Async version of ``run_code``."""
|
||||
assert self._async_http is not None
|
||||
kernel_id = await self._async_ensure_kernel(jupyter_timeout=jupyter_timeout)
|
||||
ws_url = self._jupyter_ws_url(kernel_id)
|
||||
api_key = self._require_api_key()
|
||||
|
||||
msg = self._jupyter_execute_request(code)
|
||||
msg_id = msg["msg_id"]
|
||||
|
||||
result = CodeResult()
|
||||
deadline = time.monotonic() + timeout
|
||||
|
||||
headers = {"X-API-Key": api_key}
|
||||
if self._token:
|
||||
headers["Authorization"] = f"Bearer {self._token}"
|
||||
|
||||
async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: ignore[attr-defined, var-annotated]
|
||||
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) # type: ignore[misc]
|
||||
except (asyncio.TimeoutError, 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":
|
||||
name = content.get("name", "stdout")
|
||||
if name == "stderr":
|
||||
result.stderr += content.get("text", "")
|
||||
else:
|
||||
result.stdout += content.get("text", "")
|
||||
elif msg_type == "execute_result":
|
||||
bundle = content.get("data", {})
|
||||
result.text = bundle.get("text/plain")
|
||||
result.data = bundle
|
||||
elif msg_type == "error":
|
||||
traceback = content.get("traceback", [])
|
||||
result.error = "\n".join(traceback)
|
||||
elif msg_type == "status" and content.get("execution_state") == "idle":
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
if self._proxy_client is not None:
|
||||
try:
|
||||
self._proxy_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._proxy_client = None
|
||||
|
||||
async def _async_cleanup(self) -> None:
|
||||
if self._async_proxy_client is not None:
|
||||
try:
|
||||
await self._async_proxy_client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
self._async_proxy_client = None
|
||||
|
||||
def __enter__(self) -> Sandbox:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: object,
|
||||
) -> None:
|
||||
try:
|
||||
self.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
self._cleanup()
|
||||
|
||||
async def __aenter__(self) -> Sandbox:
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: object,
|
||||
) -> None:
|
||||
try:
|
||||
await self.async_destroy()
|
||||
except Exception:
|
||||
pass
|
||||
await self._async_cleanup()
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
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,197 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import respx
|
||||
|
||||
from wrenn.capsule import Capsule, _build_proxy_url
|
||||
from wrenn.code_interpreter.models import Execution, ExecutionError, Logs, Result
|
||||
|
||||
BASE = "https://app.wrenn.dev/api"
|
||||
|
||||
|
||||
class TestBuildProxyUrl:
|
||||
def test_https_production(self):
|
||||
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)
|
||||
assert url == "ws://3000-cl-abc123.localhost:8080"
|
||||
|
||||
def test_https_custom_port(self):
|
||||
url = _build_proxy_url("https://api.example.com:9443", "sb-1", 8080)
|
||||
assert url == "wss://8080-sb-1.api.example.com:9443"
|
||||
|
||||
def test_http_no_port(self):
|
||||
url = _build_proxy_url("http://192.168.1.1", "sb-2", 5000)
|
||||
assert url == "ws://5000-sb-2.192.168.1.1"
|
||||
|
||||
|
||||
class TestCapsuleCreate:
|
||||
@respx.mock
|
||||
def test_capsule_constructor_creates(self):
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201, json={"id": "cl-1", "status": "pending", "template": "minimal"}
|
||||
)
|
||||
cap = Capsule(template="minimal", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
assert cap.capsule_id == "cl-1"
|
||||
assert hasattr(cap, "commands")
|
||||
assert hasattr(cap, "files")
|
||||
|
||||
@respx.mock
|
||||
def test_capsule_create_classmethod(self):
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201, json={"id": "cl-2", "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(
|
||||
201, json={"id": "cl-1", "status": "pending"}
|
||||
)
|
||||
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
|
||||
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(
|
||||
201, json={"id": "cl-3", "status": "pending"}
|
||||
)
|
||||
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(204)
|
||||
Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
def test_static_pause(self):
|
||||
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
|
||||
200, json={"id": "cl-1", "status": "paused"}
|
||||
)
|
||||
info = Capsule._static_pause("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
assert info.status.value == "paused"
|
||||
|
||||
@respx.mock
|
||||
def test_static_list(self):
|
||||
respx.get(f"{BASE}/v1/capsules").respond(
|
||||
200, json=[{"id": "cl-1", "status": "running"}]
|
||||
)
|
||||
items = Capsule.list(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
assert len(items) == 1
|
||||
assert items[0].id == "cl-1"
|
||||
|
||||
@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):
|
||||
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
|
||||
200, json={"id": "cl-1", "status": "paused"}
|
||||
)
|
||||
respx.post(f"{BASE}/v1/capsules/cl-1/resume").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"
|
||||
|
||||
|
||||
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.png == "base64data"
|
||||
assert r.is_main_result is True
|
||||
|
||||
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"}
|
||||
|
||||
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_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_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
|
||||
fw = [x for x in w if issubclass(x.category, FutureWarning)]
|
||||
assert len(fw) >= 1
|
||||
assert "Sandbox" in str(fw[0].message)
|
||||
@ -8,34 +8,99 @@ from wrenn.exceptions import (
|
||||
WrennAgentError,
|
||||
WrennAuthenticationError,
|
||||
WrennConflictError,
|
||||
WrennForbiddenError,
|
||||
WrennHostHasSandboxesError,
|
||||
WrennInternalError,
|
||||
WrennNotFoundError,
|
||||
WrennValidationError,
|
||||
)
|
||||
from wrenn.models import (
|
||||
Capsule,
|
||||
APIKeyResponse,
|
||||
AuthResponse,
|
||||
CreateHostResponse,
|
||||
Host,
|
||||
Sandbox,
|
||||
Status,
|
||||
Template,
|
||||
)
|
||||
|
||||
BASE = "https://app.wrenn.dev/api"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as c:
|
||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def async_client():
|
||||
return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678")
|
||||
|
||||
|
||||
class TestCapsules:
|
||||
class TestAuth:
|
||||
@respx.mock
|
||||
def test_signup(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/auth/signup").respond(
|
||||
201,
|
||||
json={
|
||||
"token": "jwt-token",
|
||||
"user_id": "u-1",
|
||||
"team_id": "t-1",
|
||||
"email": "a@b.com",
|
||||
},
|
||||
)
|
||||
resp = client.auth.signup("a@b.com", "password123")
|
||||
assert isinstance(resp, AuthResponse)
|
||||
assert resp.token == "jwt-token"
|
||||
assert resp.user_id == "u-1"
|
||||
|
||||
@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(f"{BASE}/v1/capsules").respond(
|
||||
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
|
||||
|
||||
|
||||
class TestSandboxes:
|
||||
@respx.mock
|
||||
def test_create(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
|
||||
201,
|
||||
json={
|
||||
"id": "sb-1",
|
||||
@ -45,88 +110,66 @@ class TestCapsules:
|
||||
"memory_mb": 1024,
|
||||
},
|
||||
)
|
||||
resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024)
|
||||
assert isinstance(resp, Capsule)
|
||||
resp = client.sandboxes.create(template="base-python", vcpus=2, memory_mb=1024)
|
||||
assert isinstance(resp, Sandbox)
|
||||
assert resp.id == "sb-1"
|
||||
assert resp.status == Status.pending
|
||||
|
||||
@respx.mock
|
||||
def test_create_defaults(self, client):
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
|
||||
201, json={"id": "sb-2", "status": "pending"}
|
||||
)
|
||||
resp = client.capsules.create()
|
||||
resp = client.sandboxes.create()
|
||||
assert resp.id == "sb-2"
|
||||
|
||||
@respx.mock
|
||||
def test_list(self, client):
|
||||
respx.get(f"{BASE}/v1/capsules").respond(
|
||||
respx.get("https://api.wrenn.dev/v1/sandboxes").respond(
|
||||
200, json=[{"id": "sb-1", "status": "running"}]
|
||||
)
|
||||
boxes = client.capsules.list()
|
||||
boxes = client.sandboxes.list()
|
||||
assert len(boxes) == 1
|
||||
assert boxes[0].status == Status.running
|
||||
|
||||
@respx.mock
|
||||
def test_get(self, client):
|
||||
respx.get(f"{BASE}/v1/capsules/sb-1").respond(
|
||||
respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond(
|
||||
200, json={"id": "sb-1", "status": "running"}
|
||||
)
|
||||
resp = client.capsules.get("sb-1")
|
||||
resp = client.sandboxes.get("sb-1")
|
||||
assert resp.id == "sb-1"
|
||||
|
||||
@respx.mock
|
||||
def test_destroy(self, client):
|
||||
route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(204)
|
||||
client.capsules.destroy("sb-1")
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
def test_pause(self, client):
|
||||
respx.post(f"{BASE}/v1/capsules/sb-1/pause").respond(
|
||||
200, json={"id": "sb-1", "status": "paused"}
|
||||
)
|
||||
resp = client.capsules.pause("sb-1")
|
||||
assert resp.status == Status.paused
|
||||
|
||||
@respx.mock
|
||||
def test_resume(self, client):
|
||||
respx.post(f"{BASE}/v1/capsules/sb-1/resume").respond(
|
||||
200, json={"id": "sb-1", "status": "running"}
|
||||
)
|
||||
resp = client.capsules.resume("sb-1")
|
||||
assert resp.status == Status.running
|
||||
|
||||
@respx.mock
|
||||
def test_ping(self, client):
|
||||
route = respx.post(f"{BASE}/v1/capsules/sb-1/ping").respond(204)
|
||||
client.capsules.ping("sb-1")
|
||||
route = respx.delete("https://api.wrenn.dev/v1/sandboxes/sb-1").respond(204)
|
||||
client.sandboxes.destroy("sb-1")
|
||||
assert route.called
|
||||
|
||||
|
||||
class TestSnapshots:
|
||||
@respx.mock
|
||||
def test_create(self, client):
|
||||
respx.post(f"{BASE}/v1/snapshots").respond(
|
||||
respx.post("https://api.wrenn.dev/v1/snapshots").respond(
|
||||
201,
|
||||
json={"name": "snap-1", "type": "snapshot", "vcpus": 1},
|
||||
)
|
||||
resp = client.snapshots.create(capsule_id="sb-1", name="snap-1")
|
||||
resp = client.snapshots.create(sandbox_id="sb-1", name="snap-1")
|
||||
assert isinstance(resp, Template)
|
||||
assert resp.name == "snap-1"
|
||||
|
||||
@respx.mock
|
||||
def test_create_with_overwrite(self, client):
|
||||
route = respx.post(f"{BASE}/v1/snapshots").respond(
|
||||
route = respx.post("https://api.wrenn.dev/v1/snapshots").respond(
|
||||
201, json={"name": "snap-1", "type": "snapshot"}
|
||||
)
|
||||
client.snapshots.create(capsule_id="sb-1", overwrite=True)
|
||||
client.snapshots.create(sandbox_id="sb-1", overwrite=True)
|
||||
req = route.calls[0].request
|
||||
assert "overwrite=true" in str(req.url)
|
||||
|
||||
@respx.mock
|
||||
def test_list(self, client):
|
||||
respx.get(f"{BASE}/v1/snapshots").respond(
|
||||
respx.get("https://api.wrenn.dev/v1/snapshots").respond(
|
||||
200, json=[{"name": "base-python", "type": "base"}]
|
||||
)
|
||||
snaps = client.snapshots.list()
|
||||
@ -134,85 +177,180 @@ class TestSnapshots:
|
||||
|
||||
@respx.mock
|
||||
def test_list_with_filter(self, client):
|
||||
route = respx.get(f"{BASE}/v1/snapshots").respond(200, json=[])
|
||||
route = respx.get("https://api.wrenn.dev/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(f"{BASE}/v1/snapshots/snap-1").respond(204)
|
||||
route = respx.delete("https://api.wrenn.dev/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(f"{BASE}/v1/capsules").respond(
|
||||
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
|
||||
400,
|
||||
json={"error": {"code": "invalid_request", "message": "bad input"}},
|
||||
)
|
||||
with pytest.raises(WrennValidationError) as exc_info:
|
||||
client.capsules.create()
|
||||
client.sandboxes.create()
|
||||
assert exc_info.value.code == "invalid_request"
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
@respx.mock
|
||||
def test_auth_error(self, client):
|
||||
respx.get(f"{BASE}/v1/capsules").respond(
|
||||
respx.get("https://api.wrenn.dev/v1/sandboxes").respond(
|
||||
401,
|
||||
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
||||
)
|
||||
with pytest.raises(WrennAuthenticationError):
|
||||
client.capsules.list()
|
||||
client.sandboxes.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(f"{BASE}/v1/capsules/nope").respond(
|
||||
respx.get("https://api.wrenn.dev/v1/sandboxes/nope").respond(
|
||||
404,
|
||||
json={"error": {"code": "not_found", "message": "capsule not found"}},
|
||||
json={"error": {"code": "not_found", "message": "sandbox not found"}},
|
||||
)
|
||||
with pytest.raises(WrennNotFoundError):
|
||||
client.capsules.get("nope")
|
||||
client.sandboxes.get("nope")
|
||||
|
||||
@respx.mock
|
||||
def test_conflict_error(self, client):
|
||||
respx.get(f"{BASE}/v1/capsules/sb-1").respond(
|
||||
respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond(
|
||||
409,
|
||||
json={"error": {"code": "invalid_state", "message": "not running"}},
|
||||
)
|
||||
with pytest.raises(WrennConflictError):
|
||||
client.capsules.get("sb-1")
|
||||
client.sandboxes.get("sb-1")
|
||||
|
||||
@respx.mock
|
||||
def test_host_has_sandboxes_error(self, client):
|
||||
respx.delete("https://api.wrenn.dev/v1/hosts/h-1").respond(
|
||||
409,
|
||||
json={
|
||||
"error": {
|
||||
"code": "host_has_sandboxes",
|
||||
"message": "host has running sandboxes",
|
||||
},
|
||||
"sandbox_ids": ["sb-1", "sb-2"],
|
||||
},
|
||||
)
|
||||
with pytest.raises(WrennHostHasSandboxesError) as exc_info:
|
||||
client.hosts.delete("h-1")
|
||||
assert exc_info.value.sandbox_ids == ["sb-1", "sb-2"]
|
||||
|
||||
@respx.mock
|
||||
def test_agent_error(self, client):
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
|
||||
502,
|
||||
json={"error": {"code": "agent_error", "message": "host agent failed"}},
|
||||
)
|
||||
with pytest.raises(WrennAgentError):
|
||||
client.capsules.create()
|
||||
client.sandboxes.create()
|
||||
|
||||
@respx.mock
|
||||
def test_internal_error(self, client):
|
||||
respx.get(f"{BASE}/v1/capsules/sb-1").respond(
|
||||
respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond(
|
||||
500,
|
||||
json={"error": {"code": "internal_error", "message": "oops"}},
|
||||
)
|
||||
with pytest.raises(WrennInternalError):
|
||||
client.capsules.get("sb-1")
|
||||
client.sandboxes.get("sb-1")
|
||||
|
||||
@respx.mock
|
||||
def test_unknown_error_code_falls_back(self, client):
|
||||
respx.get(f"{BASE}/v1/capsules/sb-1").respond(
|
||||
respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond(
|
||||
418,
|
||||
json={"error": {"code": "teapot", "message": "I'm a teapot"}},
|
||||
)
|
||||
from wrenn.exceptions import WrennError
|
||||
|
||||
with pytest.raises(WrennError) as exc_info:
|
||||
client.capsules.get("sb-1")
|
||||
client.sandboxes.get("sb-1")
|
||||
assert exc_info.value.code == "teapot"
|
||||
|
||||
|
||||
@ -221,43 +359,59 @@ class TestAuthModes:
|
||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
||||
assert c._http.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
|
||||
|
||||
def test_no_auth_raises(self, monkeypatch):
|
||||
monkeypatch.delenv("WRENN_API_KEY", raising=False)
|
||||
with pytest.raises(ValueError, match="No API key"):
|
||||
def test_token_header(self):
|
||||
with WrennClient(token="jwt-token-abc") as c:
|
||||
assert c._http.headers["Authorization"] == "Bearer jwt-token-abc"
|
||||
|
||||
def test_no_auth_raises(self):
|
||||
with pytest.raises(ValueError, match="Either api_key or token"):
|
||||
WrennClient()
|
||||
|
||||
def test_env_var_fallback(self, monkeypatch):
|
||||
monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env")
|
||||
with WrennClient() as c:
|
||||
assert c._http.headers["X-API-Key"] == "wrn_from_env"
|
||||
@respx.mock
|
||||
def test_jwt_auth_on_api_keys(self):
|
||||
route = respx.get("https://api.wrenn.dev/v1/api-keys").respond(200, json=[])
|
||||
with WrennClient(token="jwt-abc") as c:
|
||||
c.api_keys.list()
|
||||
req = route.calls[0].request
|
||||
assert req.headers["Authorization"] == "Bearer jwt-abc"
|
||||
|
||||
|
||||
class TestAsyncClient:
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_capsules_create(self, async_client):
|
||||
async def test_async_sandboxes_create(self, async_client):
|
||||
async with async_client:
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
|
||||
201, json={"id": "sb-1", "status": "pending"}
|
||||
)
|
||||
resp = await async_client.capsules.create(template="base-python")
|
||||
resp = await async_client.sandboxes.create(template="base-python")
|
||||
assert resp.id == "sb-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_capsules_list(self, async_client):
|
||||
async def test_async_sandboxes_list(self, async_client):
|
||||
async with async_client:
|
||||
respx.get(f"{BASE}/v1/capsules").respond(200, json=[{"id": "sb-1"}])
|
||||
boxes = await async_client.capsules.list()
|
||||
respx.get("https://api.wrenn.dev/v1/sandboxes").respond(
|
||||
200, json=[{"id": "sb-1"}]
|
||||
)
|
||||
boxes = await async_client.sandboxes.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(f"{BASE}/v1/capsules/nope").respond(
|
||||
respx.get("https://api.wrenn.dev/v1/sandboxes/nope").respond(
|
||||
404,
|
||||
json={"error": {"code": "not_found", "message": "not found"}},
|
||||
)
|
||||
with pytest.raises(WrennNotFoundError):
|
||||
await async_client.capsules.get("nope")
|
||||
await async_client.sandboxes.get("nope")
|
||||
|
||||
@ -1,494 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from wrenn.capsule import Capsule
|
||||
from wrenn.models import FileEntry
|
||||
from wrenn.pty import (
|
||||
AsyncPtySession,
|
||||
PtyEventType,
|
||||
PtySession,
|
||||
_parse_pty_event,
|
||||
)
|
||||
|
||||
BASE = "https://app.wrenn.dev/api"
|
||||
|
||||
|
||||
def _make_capsule(cap_id: str = "cl-abc") -> Capsule:
|
||||
respx.post(f"{BASE}/v1/capsules").respond(
|
||||
201, json={"id": cap_id, "status": "running"}
|
||||
)
|
||||
return Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||
|
||||
|
||||
class TestFilesRead:
|
||||
@respx.mock
|
||||
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": [
|
||||
{
|
||||
"name": "main.py",
|
||||
"path": "/home/user/main.py",
|
||||
"type": "file",
|
||||
"size": 1024,
|
||||
"mode": 33188,
|
||||
"permissions": "-rw-r--r--",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"modified_at": 1712899200,
|
||||
"symlink_target": None,
|
||||
},
|
||||
{
|
||||
"name": "config",
|
||||
"path": "/home/user/config",
|
||||
"type": "directory",
|
||||
"size": 4096,
|
||||
"mode": 16877,
|
||||
"permissions": "drwxr-xr-x",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"modified_at": 1712899100,
|
||||
"symlink_target": None,
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
entries = cap.files.list("/home/user")
|
||||
assert len(entries) == 2
|
||||
assert isinstance(entries[0], FileEntry)
|
||||
assert entries[0].name == "main.py"
|
||||
assert entries[0].type == "file"
|
||||
assert entries[1].name == "config"
|
||||
assert entries[1].type == "directory"
|
||||
|
||||
@respx.mock
|
||||
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_empty(self):
|
||||
cap = _make_capsule()
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||
200, json={"entries": []}
|
||||
)
|
||||
entries = cap.files.list("/empty")
|
||||
assert entries == []
|
||||
|
||||
|
||||
class TestFilesMakeDir:
|
||||
@respx.mock
|
||||
def test_make_dir_returns_entry(self):
|
||||
cap = _make_capsule()
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/mkdir").respond(
|
||||
200,
|
||||
json={
|
||||
"entry": {
|
||||
"name": "data",
|
||||
"path": "/home/user/data",
|
||||
"type": "directory",
|
||||
"size": 4096,
|
||||
"mode": 16877,
|
||||
"permissions": "drwxr-xr-x",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"modified_at": 1712899200,
|
||||
"symlink_target": None,
|
||||
}
|
||||
},
|
||||
)
|
||||
entry = cap.files.make_dir("/home/user/data")
|
||||
assert isinstance(entry, FileEntry)
|
||||
assert entry.name == "data"
|
||||
assert entry.type == "directory"
|
||||
|
||||
@respx.mock
|
||||
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(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||
200,
|
||||
json={
|
||||
"entries": [
|
||||
{
|
||||
"name": "data",
|
||||
"path": "/home/user/data",
|
||||
"type": "directory",
|
||||
"size": 4096,
|
||||
"mode": 16877,
|
||||
"permissions": "drwxr-xr-x",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"modified_at": 1712899200,
|
||||
"symlink_target": None,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
entry = cap.files.make_dir("/home/user/data")
|
||||
assert entry.name == "data"
|
||||
|
||||
|
||||
class TestFilesRemove:
|
||||
@respx.mock
|
||||
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):
|
||||
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 TestFilesExists:
|
||||
@respx.mock
|
||||
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_exists_false(self):
|
||||
cap = _make_capsule()
|
||||
respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
|
||||
200, json={"entries": []}
|
||||
)
|
||||
assert cap.files.exists("/tmp/nope.txt") is False
|
||||
|
||||
|
||||
class TestPtyEventParsing:
|
||||
def test_started_event(self):
|
||||
raw = {"type": "started", "tag": "pty-a1b2c3d4", "pid": 42}
|
||||
event = _parse_pty_event(raw)
|
||||
assert event.type == PtyEventType.started
|
||||
assert event.pid == 42
|
||||
assert event.tag == "pty-a1b2c3d4"
|
||||
|
||||
def test_output_event_base64(self):
|
||||
encoded = base64.b64encode(b"ls -la\n").decode()
|
||||
raw = {"type": "output", "data": encoded}
|
||||
event = _parse_pty_event(raw)
|
||||
assert event.type == PtyEventType.output
|
||||
assert event.data == b"ls -la\n"
|
||||
|
||||
def test_output_event_empty(self):
|
||||
raw = {"type": "output", "data": ""}
|
||||
event = _parse_pty_event(raw)
|
||||
assert event.data == b""
|
||||
|
||||
def test_exit_event(self):
|
||||
raw = {"type": "exit", "exit_code": 0}
|
||||
event = _parse_pty_event(raw)
|
||||
assert event.type == PtyEventType.exit
|
||||
assert event.exit_code == 0
|
||||
|
||||
def test_error_event(self):
|
||||
raw = {"type": "error", "data": "process not found", "fatal": True}
|
||||
event = _parse_pty_event(raw)
|
||||
assert event.type == PtyEventType.error
|
||||
assert event.data == "process not found"
|
||||
assert event.fatal is True
|
||||
|
||||
def test_ping_event(self):
|
||||
raw = {"type": "ping"}
|
||||
event = _parse_pty_event(raw)
|
||||
assert event.type == PtyEventType.ping
|
||||
|
||||
|
||||
class TestPtySessionWrite:
|
||||
def test_write_sends_base64_input(self):
|
||||
ws = MagicMock()
|
||||
session = PtySession(ws, "cl-abc")
|
||||
session.write(b"ls -la\n")
|
||||
sent = json.loads(ws.send_text.call_args[0][0])
|
||||
assert sent["type"] == "input"
|
||||
assert base64.b64decode(sent["data"]) == b"ls -la\n"
|
||||
|
||||
|
||||
class TestPtySessionResize:
|
||||
def test_resize_sends_dimensions(self):
|
||||
ws = MagicMock()
|
||||
session = PtySession(ws, "cl-abc")
|
||||
session.resize(120, 40)
|
||||
sent = json.loads(ws.send_text.call_args[0][0])
|
||||
assert sent["type"] == "resize"
|
||||
assert sent["cols"] == 120
|
||||
assert sent["rows"] == 40
|
||||
|
||||
def test_resize_zero_raises(self):
|
||||
ws = MagicMock()
|
||||
session = PtySession(ws, "cl-abc")
|
||||
with pytest.raises(ValueError, match="greater than 0"):
|
||||
session.resize(0, 40)
|
||||
with pytest.raises(ValueError, match="greater than 0"):
|
||||
session.resize(80, 0)
|
||||
|
||||
|
||||
class TestPtySessionKill:
|
||||
def test_kill_sends_message(self):
|
||||
ws = MagicMock()
|
||||
session = PtySession(ws, "cl-abc")
|
||||
session.kill()
|
||||
sent = json.loads(ws.send_text.call_args[0][0])
|
||||
assert sent["type"] == "kill"
|
||||
|
||||
|
||||
class TestPtySessionIteration:
|
||||
def test_iter_yields_events_until_exit(self):
|
||||
ws = MagicMock()
|
||||
messages = [
|
||||
json.dumps({"type": "started", "tag": "pty-abc12345", "pid": 1}),
|
||||
json.dumps({"type": "output", "data": base64.b64encode(b"hello").decode()}),
|
||||
json.dumps({"type": "exit", "exit_code": 0}),
|
||||
]
|
||||
ws.receive_text.side_effect = messages
|
||||
session = PtySession(ws, "cl-abc")
|
||||
events = list(session)
|
||||
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()
|
||||
messages = [
|
||||
json.dumps({"type": "error", "data": "fatal", "fatal": True}),
|
||||
]
|
||||
ws.receive_text.side_effect = messages
|
||||
session = PtySession(ws, "cl-abc")
|
||||
events = list(session)
|
||||
assert len(events) == 1
|
||||
assert events[0].type == PtyEventType.error
|
||||
|
||||
def test_iter_stops_on_disconnect(self):
|
||||
import httpx_ws
|
||||
|
||||
ws = MagicMock()
|
||||
ws.receive_text.side_effect = httpx_ws.WebSocketDisconnect()
|
||||
session = PtySession(ws, "cl-abc")
|
||||
events = list(session)
|
||||
assert events == []
|
||||
|
||||
|
||||
class TestPtySessionContextManager:
|
||||
def test_exit_kills_and_closes(self):
|
||||
ws = MagicMock()
|
||||
session = PtySession(ws, "cl-abc")
|
||||
with session:
|
||||
pass
|
||||
ws.send_text.assert_called()
|
||||
ws.close.assert_called()
|
||||
|
||||
def test_exit_ignores_errors(self):
|
||||
ws = MagicMock()
|
||||
ws.send_text.side_effect = Exception("already closed")
|
||||
session = PtySession(ws, "cl-abc")
|
||||
with session:
|
||||
pass
|
||||
|
||||
|
||||
class TestPtySessionSendStart:
|
||||
def test_send_start_with_defaults(self):
|
||||
ws = MagicMock()
|
||||
session = PtySession(ws, "cl-abc")
|
||||
session._send_start()
|
||||
sent = json.loads(ws.send_text.call_args[0][0])
|
||||
assert sent["type"] == "start"
|
||||
assert sent["cmd"] == "/bin/bash"
|
||||
assert sent["cols"] == 80
|
||||
assert sent["rows"] == 24
|
||||
|
||||
def test_send_start_with_all_params(self):
|
||||
ws = MagicMock()
|
||||
session = PtySession(ws, "cl-abc")
|
||||
session._send_start(
|
||||
cmd="/bin/zsh",
|
||||
args=["-l"],
|
||||
cols=120,
|
||||
rows=40,
|
||||
envs={"TERM": "xterm-256color"},
|
||||
cwd="/home/user",
|
||||
)
|
||||
sent = json.loads(ws.send_text.call_args[0][0])
|
||||
assert sent["cmd"] == "/bin/zsh"
|
||||
assert sent["args"] == ["-l"]
|
||||
assert sent["cols"] == 120
|
||||
|
||||
|
||||
class TestPtySessionSendConnect:
|
||||
def test_send_connect(self):
|
||||
ws = MagicMock()
|
||||
session = PtySession(ws, "cl-abc")
|
||||
session._send_connect("pty-abc12345")
|
||||
sent = json.loads(ws.send_text.call_args[0][0])
|
||||
assert sent["type"] == "connect"
|
||||
assert sent["tag"] == "pty-abc12345"
|
||||
|
||||
|
||||
class TestAsyncPtySession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_write_sends_base64(self):
|
||||
ws = AsyncMock()
|
||||
session = AsyncPtySession(ws, "cl-abc")
|
||||
await session.write(b"hello")
|
||||
sent = json.loads(ws.send_text.call_args[0][0])
|
||||
assert sent["type"] == "input"
|
||||
assert base64.b64decode(sent["data"]) == b"hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_resize(self):
|
||||
ws = AsyncMock()
|
||||
session = AsyncPtySession(ws, "cl-abc")
|
||||
await session.resize(100, 30)
|
||||
sent = json.loads(ws.send_text.call_args[0][0])
|
||||
assert sent["type"] == "resize"
|
||||
assert sent["cols"] == 100
|
||||
assert sent["rows"] == 30
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_resize_zero_raises(self):
|
||||
ws = AsyncMock()
|
||||
session = AsyncPtySession(ws, "cl-abc")
|
||||
with pytest.raises(ValueError):
|
||||
await session.resize(0, 10)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_kill(self):
|
||||
ws = AsyncMock()
|
||||
session = AsyncPtySession(ws, "cl-abc")
|
||||
await session.kill()
|
||||
sent = json.loads(ws.send_text.call_args[0][0])
|
||||
assert sent["type"] == "kill"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_context_manager(self):
|
||||
ws = AsyncMock()
|
||||
session = AsyncPtySession(ws, "cl-abc")
|
||||
async with session:
|
||||
pass
|
||||
ws.send_text.assert_called()
|
||||
ws.close.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_send_start(self):
|
||||
ws = AsyncMock()
|
||||
session = AsyncPtySession(ws, "cl-abc")
|
||||
await session._send_start(cmd="/bin/zsh", cols=100, rows=30)
|
||||
sent = json.loads(ws.send_text.call_args[0][0])
|
||||
assert sent["type"] == "start"
|
||||
assert sent["cmd"] == "/bin/zsh"
|
||||
assert sent["cols"] == 100
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_iteration(self):
|
||||
ws = AsyncMock()
|
||||
messages = [
|
||||
json.dumps({"type": "started", "tag": "pty-xyz", "pid": 5}),
|
||||
json.dumps({"type": "output", "data": base64.b64encode(b"hi").decode()}),
|
||||
json.dumps({"type": "exit", "exit_code": 0}),
|
||||
]
|
||||
ws.receive_text.side_effect = messages
|
||||
session = AsyncPtySession(ws, "cl-abc")
|
||||
events = []
|
||||
async for event in session:
|
||||
events.append(event)
|
||||
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:
|
||||
def test_file_entry_importable(self):
|
||||
from wrenn import FileEntry as FE
|
||||
|
||||
assert FE is not None
|
||||
|
||||
def test_pty_session_importable(self):
|
||||
from wrenn import PtySession as PS
|
||||
|
||||
assert PS is not None
|
||||
|
||||
def test_async_pty_session_importable(self):
|
||||
from wrenn import AsyncPtySession as APS
|
||||
|
||||
assert APS is not None
|
||||
|
||||
def test_pty_event_importable(self):
|
||||
from wrenn import PtyEvent as PE
|
||||
from wrenn import PtyEventType as PET
|
||||
|
||||
assert PE is not None
|
||||
assert PET is not None
|
||||
1137
tests/test_git.py
1137
tests/test_git.py
File diff suppressed because it is too large
Load Diff
@ -1,405 +1,289 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
import pytest
|
||||
|
||||
from wrenn import Capsule, CommandResult
|
||||
from wrenn.commands import CommandHandle, ProcessInfo
|
||||
from wrenn.models import Capsule as CapsuleModel, FileEntry, Status
|
||||
from wrenn.client import AsyncWrennClient, WrennClient
|
||||
from wrenn.exceptions import WrennNotFoundError, WrennValidationError
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_env_loaded = False
|
||||
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 _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
|
||||
def _has_auth() -> bool:
|
||||
return bool(WRENN_API_KEY or WRENN_TOKEN)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
info = Capsule.get_info(capsule_id)
|
||||
assert info.status in (Status.stopped, Status.missing)
|
||||
|
||||
def test_create_with_wait(self):
|
||||
capsule = Capsule(wait=True)
|
||||
try:
|
||||
assert capsule.info is not None
|
||||
assert capsule.info.status == Status.running
|
||||
finally:
|
||||
capsule.destroy()
|
||||
|
||||
def test_context_manager_destroys(self):
|
||||
with Capsule(wait=True) as capsule:
|
||||
capsule_id = capsule.capsule_id
|
||||
assert capsule.is_running()
|
||||
|
||||
info = Capsule.get_info(capsule_id)
|
||||
assert info.status in (Status.stopped, Status.missing)
|
||||
|
||||
def test_get_info(self):
|
||||
capsule = Capsule(wait=True)
|
||||
try:
|
||||
info = capsule.get_info()
|
||||
assert isinstance(info, CapsuleModel)
|
||||
assert info.id == capsule.capsule_id
|
||||
assert info.status == Status.running
|
||||
finally:
|
||||
capsule.destroy()
|
||||
|
||||
def test_pause_and_resume(self):
|
||||
capsule = Capsule(wait=True)
|
||||
try:
|
||||
paused = capsule.pause()
|
||||
assert paused.status == Status.paused
|
||||
assert not capsule.is_running()
|
||||
|
||||
resumed = capsule.resume()
|
||||
assert resumed.status == Status.running
|
||||
finally:
|
||||
capsule.destroy()
|
||||
|
||||
def test_static_destroy(self):
|
||||
capsule = Capsule(wait=True)
|
||||
capsule_id = capsule.capsule_id
|
||||
try:
|
||||
Capsule.destroy(capsule_id)
|
||||
except Exception:
|
||||
capsule.destroy()
|
||||
raise
|
||||
|
||||
info = Capsule.get_info(capsule_id)
|
||||
assert info.status in (Status.stopped, Status.missing)
|
||||
|
||||
def test_connect_to_existing(self):
|
||||
capsule = Capsule(wait=True)
|
||||
try:
|
||||
connected = Capsule.connect(capsule.capsule_id)
|
||||
assert connected.capsule_id == capsule.capsule_id
|
||||
assert connected.info is not None
|
||||
assert connected.info.status == Status.running
|
||||
finally:
|
||||
capsule.destroy()
|
||||
|
||||
def test_connect_resumes_paused(self):
|
||||
capsule = Capsule(wait=True)
|
||||
try:
|
||||
capsule.pause()
|
||||
connected = Capsule.connect(capsule.capsule_id)
|
||||
assert connected.info is not None
|
||||
assert connected.info.status == Status.running
|
||||
finally:
|
||||
capsule.destroy()
|
||||
|
||||
def test_list_capsules(self):
|
||||
capsule = Capsule(wait=True)
|
||||
try:
|
||||
capsules = Capsule.list()
|
||||
assert isinstance(capsules, list)
|
||||
ids = [c.id for c in capsules]
|
||||
assert capsule.capsule_id in ids
|
||||
finally:
|
||||
capsule.destroy()
|
||||
|
||||
def test_wait_ready(self):
|
||||
capsule = Capsule()
|
||||
try:
|
||||
capsule.wait_ready(timeout=60)
|
||||
assert capsule.is_running()
|
||||
finally:
|
||||
capsule.destroy()
|
||||
|
||||
def test_ping(self):
|
||||
capsule = Capsule(wait=True)
|
||||
try:
|
||||
capsule.ping()
|
||||
finally:
|
||||
capsule.destroy()
|
||||
requires_auth = pytest.mark.skipif(
|
||||
not _has_auth(),
|
||||
reason="Set WRENN_API_KEY or WRENN_TOKEN to run integration tests",
|
||||
)
|
||||
|
||||
|
||||
class TestCommands:
|
||||
"""Shared capsule for command execution 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
|
||||
|
||||
capsule: Capsule
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
_ensure_env()
|
||||
cls.capsule = Capsule(wait=True)
|
||||
@pytest.fixture
|
||||
def async_client() -> AsyncWrennClient:
|
||||
return AsyncWrennClient(
|
||||
api_key=WRENN_API_KEY,
|
||||
token=WRENN_TOKEN,
|
||||
base_url=WRENN_BASE_URL,
|
||||
)
|
||||
|
||||
@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)
|
||||
@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, token=WRENN_TOKEN, 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"
|
||||
)
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestSandboxLifecycle:
|
||||
def test_create_exec_destroy(self, client):
|
||||
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
result = sb.exec("echo", args=["hello"])
|
||||
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_exec_with_args(self, client):
|
||||
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
result = sb.exec("echo", args=["hello", "world"])
|
||||
assert result.exit_code == 0
|
||||
assert "hello world" in result.stdout
|
||||
|
||||
def test_run_exit_code(self):
|
||||
result = self.capsule.commands.run("exit 42")
|
||||
def test_exec_nonzero_exit(self, client):
|
||||
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
result = sb.exec("sh", args=["-c", "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'")
|
||||
def test_exec_stderr(self, client):
|
||||
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
result = sb.exec("sh", args=["-c", "echo err>&2"])
|
||||
assert result.exit_code == 0
|
||||
lines = result.stdout.strip().splitlines()
|
||||
assert len(lines) == 3
|
||||
assert "err" in result.stderr
|
||||
|
||||
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
|
||||
def test_context_manager_cleanup(self, client):
|
||||
sb = client.sandboxes.create(template="minimal", timeout_sec=120)
|
||||
sb_id = sb.id
|
||||
|
||||
self.capsule.commands.kill(handle.pid)
|
||||
with sb:
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
|
||||
def test_list_processes(self):
|
||||
handle = self.capsule.commands.run("sleep 30", background=True, tag="list-test")
|
||||
try:
|
||||
time.sleep(0.5)
|
||||
processes = self.capsule.commands.list()
|
||||
assert isinstance(processes, list)
|
||||
pids = [p.pid for p in processes]
|
||||
assert handle.pid in pids
|
||||
|
||||
proc = next(p for p in processes if p.pid == handle.pid)
|
||||
assert isinstance(proc, ProcessInfo)
|
||||
finally:
|
||||
self.capsule.commands.kill(handle.pid)
|
||||
|
||||
def test_kill_process(self):
|
||||
handle = self.capsule.commands.run("sleep 30", background=True)
|
||||
self.capsule.commands.kill(handle.pid)
|
||||
time.sleep(0.5)
|
||||
|
||||
processes = self.capsule.commands.list()
|
||||
pids = [p.pid for p in processes]
|
||||
assert handle.pid not in pids
|
||||
|
||||
def test_run_duration_ms(self):
|
||||
result = self.capsule.commands.run("sleep 1")
|
||||
assert result.duration_ms is None or result.duration_ms >= 900
|
||||
fetched = client.sandboxes.get(sb_id)
|
||||
assert fetched.status in ("stopped", "destroyed")
|
||||
|
||||
|
||||
class TestFiles:
|
||||
"""Shared capsule for filesystem tests."""
|
||||
@requires_auth
|
||||
class TestFileIO:
|
||||
def test_upload_and_download(self, client):
|
||||
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
content = b"Hello from integration test!"
|
||||
sb.upload("/tmp/test_file.txt", content)
|
||||
downloaded = sb.download("/tmp/test_file.txt")
|
||||
assert downloaded == content
|
||||
|
||||
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"
|
||||
def test_download_nonexistent_file(self, client):
|
||||
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
with pytest.raises(Exception):
|
||||
sb.download("/tmp/no_such_file_12345")
|
||||
|
||||
|
||||
class TestGit:
|
||||
"""Shared capsule for git operation tests.
|
||||
@requires_auth
|
||||
class TestPauseResume:
|
||||
def test_pause_and_resume(self, client):
|
||||
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
sb.pause()
|
||||
assert sb.status == "paused"
|
||||
|
||||
Initializes a repo at /root (default cwd) since the exec API
|
||||
does not support the cwd parameter.
|
||||
"""
|
||||
sb.resume()
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
|
||||
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")
|
||||
result = sb.exec("echo", args=["resumed"])
|
||||
assert result.exit_code == 0
|
||||
assert "resumed" in result.stdout
|
||||
|
||||
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")
|
||||
@requires_auth
|
||||
class TestPing:
|
||||
def test_ping_resets_timer(self, client):
|
||||
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
sb.ping()
|
||||
result = sb.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, client):
|
||||
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
url = sb.get_url(8888)
|
||||
assert sb.id in url
|
||||
assert "8888" in url
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestListAndGet:
|
||||
def test_list_sandboxes(self, client):
|
||||
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
boxes = client.sandboxes.list()
|
||||
ids = [b.id for b in boxes]
|
||||
assert sb.id in ids
|
||||
|
||||
def test_get_existing_sandbox(self, client):
|
||||
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb:
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
fetched = client.sandboxes.get(sb.id)
|
||||
assert fetched.id == sb.id
|
||||
assert fetched.status == "running"
|
||||
|
||||
def test_get_nonexistent_sandbox(self, client):
|
||||
with pytest.raises((WrennNotFoundError, WrennValidationError)):
|
||||
client.sandboxes.get("cl-nonexistent00000000000000000")
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestSnapshots:
|
||||
def test_list_templates(self, client):
|
||||
templates = client.snapshots.list()
|
||||
assert isinstance(templates, list)
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestAPIKeys:
|
||||
def test_create_list_delete(self, bearer_client):
|
||||
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:
|
||||
status = self.capsule.git.status()
|
||||
assert not status.is_clean
|
||||
paths = [f.path for f in status.files]
|
||||
assert "dirty.txt" in paths
|
||||
keys = bearer_client.api_keys.list()
|
||||
ids = [k.id for k in keys]
|
||||
assert key_resp.id in ids
|
||||
finally:
|
||||
self.capsule.files.remove("/root/dirty.txt")
|
||||
bearer_client.api_keys.delete(key_resp.id)
|
||||
|
||||
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
|
||||
@requires_auth
|
||||
class TestRunCode:
|
||||
def test_basic_execution(self, client):
|
||||
with client.sandboxes.create(
|
||||
template="python-interpreter-v0-beta", timeout_sec=120
|
||||
) as sb:
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
|
||||
current = [b for b in branches if b.is_current]
|
||||
assert current[0].name == "feature-1"
|
||||
r = sb.run_code("x = 42")
|
||||
assert r.error is None
|
||||
|
||||
self.capsule.git.checkout_branch("main")
|
||||
r = sb.run_code("x * 2")
|
||||
assert r.text == "84"
|
||||
|
||||
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")
|
||||
def test_state_persists(self, client):
|
||||
with client.sandboxes.create(
|
||||
template="python-interpreter-v0-beta", timeout_sec=120
|
||||
) as sb:
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
|
||||
branches = self.capsule.git.branches()
|
||||
names = [b.name for b in branches]
|
||||
assert "to-delete" not in names
|
||||
sb.run_code("def greet(name): return f'hello {name}'")
|
||||
r = sb.run_code("greet('sandbox')")
|
||||
assert "hello sandbox" in (r.text or "")
|
||||
|
||||
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_error_traceback(self, client):
|
||||
with client.sandboxes.create(
|
||||
template="python-interpreter-v0-beta", timeout_sec=120
|
||||
) as sb:
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
|
||||
def test_get_config_missing_returns_none(self):
|
||||
value = self.capsule.git.get_config("nonexistent.key")
|
||||
assert value is None
|
||||
r = sb.run_code("1/0")
|
||||
assert r.error is not None
|
||||
assert "ZeroDivisionError" in r.error
|
||||
|
||||
def test_stdout_capture(self, client):
|
||||
with client.sandboxes.create(
|
||||
template="python-interpreter-v0-beta", timeout_sec=120
|
||||
) as sb:
|
||||
sb.wait_ready(timeout=60, interval=1)
|
||||
|
||||
r = sb.run_code("print('hello from kernel')")
|
||||
assert "hello from kernel" in r.stdout
|
||||
|
||||
|
||||
@requires_auth
|
||||
class TestAsyncSandboxLifecycle:
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_create_exec_destroy(self, async_client):
|
||||
async with async_client:
|
||||
sb = await async_client.sandboxes.create(
|
||||
template="minimal", timeout_sec=120
|
||||
)
|
||||
try:
|
||||
await sb.async_wait_ready(timeout=60, interval=1)
|
||||
result = await sb.async_exec("echo", args=["async_hello"])
|
||||
assert result.exit_code == 0
|
||||
assert "async_hello" in result.stdout
|
||||
finally:
|
||||
await sb.async_destroy()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_upload_download(self, async_client):
|
||||
async with async_client:
|
||||
sb = await async_client.sandboxes.create(
|
||||
template="minimal", timeout_sec=120
|
||||
)
|
||||
try:
|
||||
await sb.async_wait_ready(timeout=60, interval=1)
|
||||
content = b"Async upload test"
|
||||
await sb.async_upload("/tmp/async_test.txt", content)
|
||||
downloaded = await sb.async_download("/tmp/async_test.txt")
|
||||
assert downloaded == content
|
||||
finally:
|
||||
await sb.async_destroy()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_run_code(self, async_client):
|
||||
async with async_client:
|
||||
sb = await async_client.sandboxes.create(
|
||||
template="python-interpreter-v0-beta", timeout_sec=120
|
||||
)
|
||||
try:
|
||||
await sb.async_wait_ready(timeout=60, interval=1)
|
||||
r = await sb.async_run_code("42 * 2")
|
||||
assert r.text == "84"
|
||||
finally:
|
||||
await sb.async_destroy()
|
||||
|
||||
175
tests/test_sandbox_features.py
Normal file
175
tests/test_sandbox_features.py
Normal file
@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from wrenn.client import WrennClient
|
||||
from wrenn.exceptions import WrennAuthenticationError
|
||||
from wrenn.sandbox import CodeResult, Sandbox, _build_proxy_url
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
|
||||
yield c
|
||||
|
||||
|
||||
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"
|
||||
|
||||
def test_http_localhost(self):
|
||||
url = _build_proxy_url("http://localhost:8080", "cl-abc123", 3000)
|
||||
assert url == "ws://3000-cl-abc123.localhost:8080"
|
||||
|
||||
def test_https_custom_port(self):
|
||||
url = _build_proxy_url("https://api.example.com:9443", "sb-1", 8080)
|
||||
assert url == "wss://8080-sb-1.api.example.com:9443"
|
||||
|
||||
def test_http_no_port(self):
|
||||
url = _build_proxy_url("http://192.168.1.1", "sb-2", 5000)
|
||||
assert url == "ws://5000-sb-2.192.168.1.1"
|
||||
|
||||
|
||||
class TestSandboxGetUrl:
|
||||
@respx.mock
|
||||
def test_get_url_returns_proxy_url(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
|
||||
201, json={"id": "cl-abc", "status": "pending"}
|
||||
)
|
||||
sb = client.sandboxes.create(template="minimal")
|
||||
url = sb.get_url(8888)
|
||||
assert url == "wss://8888-cl-abc.api.wrenn.dev"
|
||||
|
||||
@respx.mock
|
||||
def test_get_url_localhost(self):
|
||||
with WrennClient(
|
||||
api_key="wrn_test1234567890abcdef12345678",
|
||||
base_url="http://localhost:8080",
|
||||
) as c:
|
||||
respx.post("http://localhost:8080/v1/sandboxes").respond(
|
||||
201, json={"id": "cl-xyz", "status": "pending"}
|
||||
)
|
||||
sb = c.sandboxes.create()
|
||||
url = sb.get_url(3000)
|
||||
assert url == "ws://3000-cl-xyz.localhost:8080"
|
||||
|
||||
|
||||
class TestProxyAuthGuard:
|
||||
def test_jwt_only_get_url_raises(self):
|
||||
with WrennClient(token="jwt-abc") as c:
|
||||
sb = Sandbox(id="cl-abc")
|
||||
sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc")
|
||||
with pytest.raises(WrennAuthenticationError):
|
||||
sb.get_url(8888)
|
||||
|
||||
def test_jwt_only_http_client_raises(self):
|
||||
with WrennClient(token="jwt-abc") as c:
|
||||
sb = Sandbox(id="cl-abc")
|
||||
sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc")
|
||||
with pytest.raises(WrennAuthenticationError):
|
||||
_ = sb.http_client
|
||||
|
||||
|
||||
class TestSandboxHttpClient:
|
||||
@respx.mock
|
||||
def test_http_client_has_api_key_header(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
|
||||
201, json={"id": "cl-abc", "status": "pending"}
|
||||
)
|
||||
sb = client.sandboxes.create()
|
||||
hc = sb.http_client
|
||||
assert hc.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
|
||||
|
||||
@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=[]
|
||||
)
|
||||
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
|
||||
201, json={"id": "cl-abc", "status": "pending"}
|
||||
)
|
||||
sb = client.sandboxes.create()
|
||||
resp = sb.http_client.get("/api/kernels")
|
||||
assert resp.status_code == 200
|
||||
assert route.called
|
||||
|
||||
|
||||
class TestCreateReturnsBoundSandbox:
|
||||
@respx.mock
|
||||
def test_create_returns_sandbox_subclass(self, client):
|
||||
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
|
||||
201, json={"id": "cl-1", "status": "pending", "template": "minimal"}
|
||||
)
|
||||
sb = client.sandboxes.create(template="minimal")
|
||||
assert isinstance(sb, Sandbox)
|
||||
assert sb.id == "cl-1"
|
||||
assert hasattr(sb, "exec")
|
||||
assert hasattr(sb, "run_code")
|
||||
assert hasattr(sb, "get_url")
|
||||
|
||||
@respx.mock
|
||||
def test_create_context_manager(self, client):
|
||||
route = respx.delete("https://api.wrenn.dev/v1/sandboxes/cl-1").respond(204)
|
||||
respx.post("https://api.wrenn.dev/v1/sandboxes").respond(
|
||||
201, json={"id": "cl-1", "status": "pending"}
|
||||
)
|
||||
sb = client.sandboxes.create()
|
||||
with sb:
|
||||
assert sb.id == "cl-1"
|
||||
assert route.called
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
assert r.text == "84"
|
||||
assert r.data["text/plain"] == "84"
|
||||
|
||||
def test_error_result(self):
|
||||
r = CodeResult(error="ZeroDivisionError: division by zero\n...")
|
||||
assert r.error is not None
|
||||
assert "ZeroDivisionError" in r.error
|
||||
|
||||
|
||||
class TestRunCodeAuthGuard:
|
||||
def test_jwt_only_run_code_raises(self):
|
||||
with WrennClient(token="jwt-abc") as c:
|
||||
sb = Sandbox(id="cl-abc")
|
||||
sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc")
|
||||
with pytest.raises(WrennAuthenticationError):
|
||||
sb.run_code("print(1)")
|
||||
|
||||
|
||||
class TestJupyterMessageFormat:
|
||||
def test_execute_request_structure(self):
|
||||
sb = Sandbox(id="test")
|
||||
msg = sb._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_execute_request_unique_ids(self):
|
||||
sb = Sandbox(id="test")
|
||||
m1 = sb._jupyter_execute_request("a")
|
||||
m2 = sb._jupyter_execute_request("b")
|
||||
assert m1["msg_id"] != m2["msg_id"]
|
||||
472
uv.lock
generated
472
uv.lock
generated
@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 2
|
||||
requires-python = ">=3.13"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
@ -72,72 +72,6 @@ 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"
|
||||
@ -159,46 +93,6 @@ 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"
|
||||
@ -218,32 +112,6 @@ 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"
|
||||
@ -253,40 +121,6 @@ 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"
|
||||
@ -300,15 +134,6 @@ 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"
|
||||
@ -370,15 +195,6 @@ 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"
|
||||
@ -584,46 +400,6 @@ 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"
|
||||
@ -660,22 +436,6 @@ 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"
|
||||
@ -744,31 +504,6 @@ 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"
|
||||
@ -806,19 +541,6 @@ 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"
|
||||
@ -879,21 +601,6 @@ 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"
|
||||
@ -931,63 +638,6 @@ 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"
|
||||
@ -1021,107 +671,9 @@ 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.1"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "email-validator" },
|
||||
@ -1132,10 +684,8 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "datamodel-code-generator", extra = ["ruff"] },
|
||||
{ name = "datamodel-code-generator" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pydoc-markdown" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "respx" },
|
||||
@ -1152,10 +702,8 @@ requires-dist = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" },
|
||||
{ name = "datamodel-code-generator", 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" },
|
||||
@ -1173,15 +721,3 @@ 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