forked from wrenn/python-sdk
Compare commits
30 Commits
dev
...
feat/modul
| Author | SHA1 | Date | |
|---|---|---|---|
| abd1fd0f68 | |||
| 027d062bfe | |||
| aa9477ffe8 | |||
| 2bb3dbd71d | |||
| d46a715839 | |||
| 3f26a2fbcf | |||
| 2faf0dd0ae | |||
| 68c7d0de42 | |||
| ad64c85393 | |||
| bab53aedbe | |||
| 82e181dd7e | |||
| ee1f55635f | |||
| 6bdf28e2ae | |||
| 61bc040098 | |||
| 7b35ffb60c | |||
| 42bcc792d6 | |||
| 3f97c73b2f | |||
| 7e7ecbd48a | |||
| 7b9a06d1b5 | |||
| 3d0eda5c60 | |||
| eecf1dc65b | |||
| 3cced768a4 | |||
| 0ac9bf79ee | |||
| bf5914c0a8 | |||
| 976af9a209 | |||
| f3fd6865f9 | |||
| 340ed46df6 | |||
| a5bf66c199 | |||
| f51a962fff | |||
| 2ad8cdebc1 |
24
.github/workflows/release.yml
vendored
Normal file
24
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
name: Publish to PyPI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pypi-publish:
|
||||||
|
name: Upload release to PyPI
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: pypi
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: astral-sh/setup-uv@v6
|
||||||
|
|
||||||
|
- name: Build package
|
||||||
|
run: uv build
|
||||||
|
|
||||||
|
- name: Publish package distributions to PyPI
|
||||||
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -174,3 +174,7 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
CODE_EXECUTION.md
|
||||||
|
|
||||||
|
.claude/
|
||||||
|
.opencode/
|
||||||
|
|||||||
24
.woodpecker/check.yml
Normal file
24
.woodpecker/check.yml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
when:
|
||||||
|
event: push
|
||||||
|
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
|
||||||
56
AGENTS.md
Normal file
56
AGENTS.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
Wrenn Python SDK — a client library for the Wrenn microVM platform. e2b drop-in replacement.
|
||||||
|
Package name: `wrenn`. Python 3.13+, managed with [uv](https://docs.astral.sh/uv/).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync # install deps
|
||||||
|
make lint # ruff check + format check (no auto-fix)
|
||||||
|
make test # unit tests only (tests/test_client.py)
|
||||||
|
make test-integration # all tests including integration (needs live server)
|
||||||
|
make generate # regenerate models from OpenAPI spec (fetches from remote)
|
||||||
|
make check # lint + unit test
|
||||||
|
```
|
||||||
|
|
||||||
|
- `make test` only runs `tests/test_client.py`, not all unit tests. To run a specific test file: `uv run pytest tests/test_capsule_features.py -v`
|
||||||
|
- No typecheck step in Makefile or CI. `mypy` is a dev dependency but not wired up — do not assume it runs.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `src/wrenn/` — the library package
|
||||||
|
- `capsule.py` / `async_capsule.py` — high-level `Capsule` / `AsyncCapsule` (main user-facing classes)
|
||||||
|
- `client.py` — low-level `WrennClient` / `AsyncWrennClient`
|
||||||
|
- `commands.py` — command execution and streaming
|
||||||
|
- `files.py` — filesystem operations
|
||||||
|
- `pty.py` — interactive terminal (PTY) over WebSocket
|
||||||
|
- `exceptions.py` — typed error hierarchy (`WrennError` base)
|
||||||
|
- `models/_generated.py` — **auto-generated** from OpenAPI spec via `datamodel-codegen` (never edit directly; run `make generate`)
|
||||||
|
- `sandbox.py` — deprecated `Sandbox` alias for `Capsule`
|
||||||
|
- `code_interpreter/` — specialized capsule for stateful Jupyter kernel execution
|
||||||
|
- `tests/` — unit tests use `respx` to mock `httpx`; integration tests are in `tests/integration/`
|
||||||
|
- `api/openapi.yaml` — downloaded OpenAPI spec used for code generation
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- Generated code lives in `src/wrenn/models/_generated.py`. Never edit it. Run `make generate` to update.
|
||||||
|
- `Sandbox` is a deprecated alias for `Capsule`. New code should use `Capsule` / `AsyncCapsule`.
|
||||||
|
- Dual sync/async API: every major class has an `Async` counterpart.
|
||||||
|
- Uses `httpx` for HTTP, `httpx-ws` for WebSockets, `pydantic` for models.
|
||||||
|
- `__init__.py` uses `__getattr__` for lazy deprecated aliases (`Sandbox`, `WrennHostHasSandboxesError`).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit tests mock HTTP via `respx` (httpx mocking library).
|
||||||
|
- Integration tests require env vars: `WRENN_API_KEY` (or `WRENN_TOKEN`), optionally `WRENN_BASE_URL`.
|
||||||
|
- Integration test fixtures in `tests/integration/conftest.py` create real capsules and clean them up.
|
||||||
|
- `pytest` marker: `@pytest.mark.integration` for tests needing a live server.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
Woodpecker CI (`.woodpecker/check.yml`) runs on push to `main` and `dev`:
|
||||||
|
1. `make lint`
|
||||||
|
2. `make test` (unit tests only — integration tests are not in CI)
|
||||||
20
LICENSE
20
LICENSE
@ -1,18 +1,18 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 wrenn
|
Copyright (c) 2026 M/S Omukk, Bangladesh
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||||
following conditions:
|
following conditions:
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||||
portions of the Software.
|
portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|||||||
24
Makefile
24
Makefile
@ -1,8 +1,8 @@
|
|||||||
# Makefile
|
# Makefile
|
||||||
.PHONY: generate
|
.PHONY: generate lint test check test-integration
|
||||||
|
|
||||||
# Variables
|
# Variables
|
||||||
SPEC_URL = "https://git.omukk.dev/wrenn/sandbox/raw/branch/main/internal/api/openapi.yaml"
|
SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml"
|
||||||
SPEC_PATH = "api/openapi.yaml"
|
SPEC_PATH = "api/openapi.yaml"
|
||||||
|
|
||||||
generate:
|
generate:
|
||||||
@ -21,4 +21,22 @@ generate:
|
|||||||
--use-schema-description \
|
--use-schema-description \
|
||||||
--target-python-version 3.13 \
|
--target-python-version 3.13 \
|
||||||
--use-annotated \
|
--use-annotated \
|
||||||
--openapi-scopes schemas
|
--openapi-scopes schemas \
|
||||||
|
--formatters ruff-format ruff-check \
|
||||||
|
--input-file-type openapi
|
||||||
|
|
||||||
|
lint:
|
||||||
|
uv run ruff check src/
|
||||||
|
uv run ruff format --check src/
|
||||||
|
|
||||||
|
test:
|
||||||
|
uv run pytest tests/test_client.py -v
|
||||||
|
|
||||||
|
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,3 +1,623 @@
|
|||||||
# python-sdk
|
# Wrenn Python SDK
|
||||||
|
|
||||||
Python SDK for wrenn
|
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
|
||||||
|
|||||||
2052
api/openapi.yaml
2052
api/openapi.yaml
File diff suppressed because it is too large
Load Diff
4274
docs/reference.md
Normal file
4274
docs/reference.md
Normal file
File diff suppressed because it is too large
Load Diff
12
pydoc-markdown.yml
Normal file
12
pydoc-markdown.yml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
loaders:
|
||||||
|
- type: python
|
||||||
|
search_path: [src]
|
||||||
|
|
||||||
|
processors:
|
||||||
|
- type: google # Use Google-style docstring parser
|
||||||
|
- type: filter
|
||||||
|
- type: crossref
|
||||||
|
|
||||||
|
renderer:
|
||||||
|
type: markdown
|
||||||
|
escape_html_in_docstring: false
|
||||||
@ -1,14 +1,30 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "wrenn"
|
name = "wrenn"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Add your description here"
|
description = "Python SDK for Wrenn"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
license = "MIT"
|
||||||
|
license-files = ["LICENSE"]
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Tasnim Kabir Sadik", email = "tksadik92@gmail.com" }
|
{ name = "Rafeed M. Bhuiyan", email = "rafeed@omukk.dev" },
|
||||||
|
{ name = "Tasnim Kabir Sadik", email = "tksadik@omukk.dev" },
|
||||||
]
|
]
|
||||||
requires-python = ">=3.13"
|
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 = [
|
dependencies = [
|
||||||
|
"email-validator>=2.3.0",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
|
"httpx-ws>=0.9.0",
|
||||||
"pydantic>=2.12.5",
|
"pydantic>=2.12.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -18,9 +34,20 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"datamodel-code-generator>=0.56.0",
|
"datamodel-code-generator[ruff]>=0.56.0",
|
||||||
"mypy>=1.20.0",
|
"mypy>=1.20.0",
|
||||||
|
"pydoc-markdown>=4.8.2",
|
||||||
"pytest>=9.0.3",
|
"pytest>=9.0.3",
|
||||||
"pytest-asyncio>=1.3.0",
|
"pytest-asyncio>=1.3.0",
|
||||||
|
"respx>=0.23.1",
|
||||||
"ruff>=0.15.10",
|
"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,2 +1,107 @@
|
|||||||
def hello() -> str:
|
from wrenn._git import (
|
||||||
return "Hello from wrenn!"
|
AsyncGit,
|
||||||
|
FileStatus,
|
||||||
|
Git,
|
||||||
|
GitAuthError,
|
||||||
|
GitBranch,
|
||||||
|
GitCommandError,
|
||||||
|
GitError,
|
||||||
|
GitStatus,
|
||||||
|
)
|
||||||
|
from wrenn.async_capsule import AsyncCapsule
|
||||||
|
from wrenn.capsule import Capsule
|
||||||
|
from wrenn.client import AsyncWrennClient, WrennClient
|
||||||
|
from wrenn.commands import (
|
||||||
|
CommandHandle,
|
||||||
|
CommandResult,
|
||||||
|
ProcessInfo,
|
||||||
|
StreamErrorEvent,
|
||||||
|
StreamEvent,
|
||||||
|
StreamExitEvent,
|
||||||
|
StreamStartEvent,
|
||||||
|
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",
|
||||||
|
"Sandbox",
|
||||||
|
"StreamErrorEvent",
|
||||||
|
"StreamEvent",
|
||||||
|
"StreamExitEvent",
|
||||||
|
"StreamStartEvent",
|
||||||
|
"StreamStderrEvent",
|
||||||
|
"StreamStdoutEvent",
|
||||||
|
"WrennAgentError",
|
||||||
|
"WrennAuthenticationError",
|
||||||
|
"WrennClient",
|
||||||
|
"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}")
|
||||||
|
|||||||
33
src/wrenn/_config.py
Normal file
33
src/wrenn/_config.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
DEFAULT_BASE_URL = "https://app.wrenn.dev/api"
|
||||||
|
ENV_API_KEY = "WRENN_API_KEY"
|
||||||
|
ENV_BASE_URL = "WRENN_BASE_URL"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ConnectionConfig:
|
||||||
|
"""Resolved credentials and base URL for Wrenn API calls."""
|
||||||
|
|
||||||
|
api_key: str
|
||||||
|
base_url: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(
|
||||||
|
cls,
|
||||||
|
api_key: str | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
|
) -> ConnectionConfig:
|
||||||
|
resolved_key = api_key or os.environ.get(ENV_API_KEY)
|
||||||
|
if not resolved_key:
|
||||||
|
raise ValueError(
|
||||||
|
f"No API key provided. Pass api_key= or set the {ENV_API_KEY} environment variable."
|
||||||
|
)
|
||||||
|
resolved_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL)
|
||||||
|
return cls(api_key=resolved_key, base_url=resolved_url)
|
||||||
|
|
||||||
|
def auth_headers(self) -> dict[str, str]:
|
||||||
|
return {"X-API-Key": self.api_key}
|
||||||
1435
src/wrenn/_git/__init__.py
Normal file
1435
src/wrenn/_git/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
104
src/wrenn/_git/_auth.py
Normal file
104
src/wrenn/_git/_auth.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shlex
|
||||||
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
|
|
||||||
|
def embed_credentials(url: str, username: str, password: str) -> str:
|
||||||
|
"""Embed HTTP(S) credentials into a git URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Git repository URL.
|
||||||
|
username: Username for authentication.
|
||||||
|
password: Password or personal access token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
URL with ``username:password@`` embedded in the netloc.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the URL scheme is not ``http`` or ``https``.
|
||||||
|
"""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
raise ValueError("Only http(s) URLs support embedded credentials.")
|
||||||
|
netloc = f"{username}:{password}@{parsed.hostname}"
|
||||||
|
if parsed.port:
|
||||||
|
netloc = f"{netloc}:{parsed.port}"
|
||||||
|
return urlunparse(parsed._replace(netloc=netloc))
|
||||||
|
|
||||||
|
|
||||||
|
def strip_credentials(url: str) -> str:
|
||||||
|
"""Remove embedded credentials from a git URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Git repository URL, possibly with credentials.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
URL with credentials removed. Non-HTTP(S) URLs are returned
|
||||||
|
unchanged.
|
||||||
|
"""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
return url
|
||||||
|
if not parsed.username and not parsed.password:
|
||||||
|
return url
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
if parsed.port:
|
||||||
|
host = f"{host}:{parsed.port}"
|
||||||
|
return urlunparse(parsed._replace(netloc=host))
|
||||||
|
|
||||||
|
|
||||||
|
def is_auth_error(stderr: str) -> bool:
|
||||||
|
"""Check whether git stderr indicates an authentication failure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stderr: Combined stderr output from a git command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` if any known auth-failure pattern is found.
|
||||||
|
"""
|
||||||
|
lower = stderr.lower()
|
||||||
|
patterns = (
|
||||||
|
"authentication failed",
|
||||||
|
"terminal prompts disabled",
|
||||||
|
"could not read username",
|
||||||
|
"invalid username or password",
|
||||||
|
"access denied",
|
||||||
|
"permission denied",
|
||||||
|
"not authorized",
|
||||||
|
)
|
||||||
|
return any(p in lower for p in patterns)
|
||||||
|
|
||||||
|
|
||||||
|
def build_credential_approve_cmd(
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
host: str = "github.com",
|
||||||
|
protocol: str = "https",
|
||||||
|
) -> str:
|
||||||
|
"""Build a shell command that pipes credentials into ``git credential approve``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Git username.
|
||||||
|
password: Password or personal access token.
|
||||||
|
host: Target host. Defaults to ``"github.com"``.
|
||||||
|
protocol: Protocol. Defaults to ``"https"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A shell command string safe to pass to ``commands.run()``.
|
||||||
|
"""
|
||||||
|
if "\n" in username or "\n" in password:
|
||||||
|
raise ValueError("Credentials must not contain newline characters.")
|
||||||
|
target_host = host.strip() or "github.com"
|
||||||
|
target_protocol = protocol.strip() or "https"
|
||||||
|
credential_input = "\n".join(
|
||||||
|
[
|
||||||
|
f"protocol={target_protocol}",
|
||||||
|
f"host={target_host}",
|
||||||
|
f"username={username}",
|
||||||
|
f"password={password}",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return f"printf %s {shlex.quote(credential_input)} | git credential approve"
|
||||||
499
src/wrenn/_git/_cmd.py
Normal file
499
src/wrenn/_git/_cmd.py
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
"""Pure functions that build git argument lists and parse git output.
|
||||||
|
|
||||||
|
No I/O, no network, no imports from ``wrenn``. Every ``build_*`` function
|
||||||
|
returns a ``list[str]`` suitable for ``shlex.join()``. Every ``parse_*``
|
||||||
|
function takes raw stdout and returns a typed structure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
# ── Data types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileStatus:
|
||||||
|
"""A single entry from ``git status --porcelain=v1``.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
path (str): File path relative to the repository root.
|
||||||
|
index_status (str): Index (staged) status character.
|
||||||
|
work_tree_status (str): Working-tree status character.
|
||||||
|
renamed_from (str | None): Original path when status is a rename.
|
||||||
|
"""
|
||||||
|
|
||||||
|
path: str
|
||||||
|
index_status: str
|
||||||
|
work_tree_status: str
|
||||||
|
renamed_from: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def staged(self) -> bool:
|
||||||
|
"""Whether the change is staged in the index."""
|
||||||
|
return self.index_status not in (" ", "?")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
"""Normalized human-readable status label."""
|
||||||
|
return _derive_status(self.index_status, self.work_tree_status)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GitStatus:
|
||||||
|
"""Parsed output of ``git status --porcelain=v1 --branch``.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
branch (str | None): Current branch name, or ``None`` if detached.
|
||||||
|
upstream (str | None): Upstream tracking branch.
|
||||||
|
ahead (int): Commits ahead of upstream.
|
||||||
|
behind (int): Commits behind upstream.
|
||||||
|
detached (bool): Whether HEAD is detached.
|
||||||
|
files (list[FileStatus]): Per-file status entries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
branch: str | None = None
|
||||||
|
upstream: str | None = None
|
||||||
|
ahead: int = 0
|
||||||
|
behind: int = 0
|
||||||
|
detached: bool = False
|
||||||
|
files: list[FileStatus] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_clean(self) -> bool:
|
||||||
|
"""``True`` when there are no changed or untracked files."""
|
||||||
|
return len(self.files) == 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_staged(self) -> bool:
|
||||||
|
"""``True`` when at least one file has staged changes."""
|
||||||
|
return any(f.staged for f in self.files)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_untracked(self) -> bool:
|
||||||
|
"""``True`` when at least one file is untracked."""
|
||||||
|
return any(f.status == "untracked" for f in self.files)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_conflicts(self) -> bool:
|
||||||
|
"""``True`` when at least one file has merge conflicts."""
|
||||||
|
return any(f.status == "conflict" for f in self.files)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GitBranch:
|
||||||
|
"""A single branch entry.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name (str): Branch name (short ref).
|
||||||
|
is_current (bool): Whether this is the checked-out branch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
is_current: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Argument builders ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def build_clone(
|
||||||
|
url: str,
|
||||||
|
dest: str | None = None,
|
||||||
|
*,
|
||||||
|
branch: str | None = None,
|
||||||
|
depth: int | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build ``git clone`` arguments."""
|
||||||
|
args = ["git", "clone"]
|
||||||
|
if branch:
|
||||||
|
args.extend(["--branch", branch, "--single-branch"])
|
||||||
|
if depth is not None:
|
||||||
|
args.extend(["--depth", str(depth)])
|
||||||
|
args.append(url)
|
||||||
|
if dest:
|
||||||
|
args.append(dest)
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def build_init(
|
||||||
|
path: str = ".",
|
||||||
|
*,
|
||||||
|
bare: bool = False,
|
||||||
|
initial_branch: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build ``git init`` arguments."""
|
||||||
|
args = ["git", "init"]
|
||||||
|
if initial_branch:
|
||||||
|
args.extend(["--initial-branch", initial_branch])
|
||||||
|
if bare:
|
||||||
|
args.append("--bare")
|
||||||
|
args.append(path)
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def build_add(
|
||||||
|
paths: list[str] | None = None,
|
||||||
|
*,
|
||||||
|
all: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build ``git add`` arguments."""
|
||||||
|
args = ["git", "add"]
|
||||||
|
if not paths:
|
||||||
|
args.append("-A" if all else ".")
|
||||||
|
else:
|
||||||
|
args.append("--")
|
||||||
|
args.extend(paths)
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def build_commit(
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
allow_empty: bool = False,
|
||||||
|
author_name: str | None = None,
|
||||||
|
author_email: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build ``git commit`` arguments."""
|
||||||
|
args = ["git"]
|
||||||
|
if author_name:
|
||||||
|
args.extend(["-c", f"user.name={author_name}"])
|
||||||
|
if author_email:
|
||||||
|
args.extend(["-c", f"user.email={author_email}"])
|
||||||
|
args.extend(["commit", "-m", message])
|
||||||
|
if allow_empty:
|
||||||
|
args.append("--allow-empty")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def build_push(
|
||||||
|
remote: str = "origin",
|
||||||
|
branch: str | None = None,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
set_upstream: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build ``git push`` arguments."""
|
||||||
|
args = ["git", "push"]
|
||||||
|
if force:
|
||||||
|
args.append("--force")
|
||||||
|
if set_upstream:
|
||||||
|
args.append("--set-upstream")
|
||||||
|
args.append(remote)
|
||||||
|
if branch:
|
||||||
|
args.append(branch)
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def build_pull(
|
||||||
|
remote: str = "origin",
|
||||||
|
branch: str | None = None,
|
||||||
|
*,
|
||||||
|
rebase: bool = False,
|
||||||
|
ff_only: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build ``git pull`` arguments."""
|
||||||
|
args = ["git", "pull"]
|
||||||
|
if rebase:
|
||||||
|
args.append("--rebase")
|
||||||
|
if ff_only:
|
||||||
|
args.append("--ff-only")
|
||||||
|
args.append(remote)
|
||||||
|
if branch:
|
||||||
|
args.append(branch)
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def build_status() -> list[str]:
|
||||||
|
"""Build ``git status`` arguments for porcelain parsing."""
|
||||||
|
return ["git", "status", "--porcelain=v1", "--branch"]
|
||||||
|
|
||||||
|
|
||||||
|
def build_branches() -> list[str]:
|
||||||
|
"""Build ``git branch`` arguments for structured parsing."""
|
||||||
|
return ["git", "branch", "--format=%(refname:short)\t%(HEAD)"]
|
||||||
|
|
||||||
|
|
||||||
|
def build_create_branch(
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
start_point: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build ``git checkout -b`` arguments."""
|
||||||
|
args = ["git", "checkout", "-b", name]
|
||||||
|
if start_point:
|
||||||
|
args.append(start_point)
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def build_checkout(name: str) -> list[str]:
|
||||||
|
"""Build ``git checkout`` arguments."""
|
||||||
|
return ["git", "checkout", name]
|
||||||
|
|
||||||
|
|
||||||
|
def build_delete_branch(
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build ``git branch -d/-D`` arguments."""
|
||||||
|
return ["git", "branch", "-D" if force else "-d", name]
|
||||||
|
|
||||||
|
|
||||||
|
def build_remote_add(name: str, url: str, *, fetch: bool = False) -> list[str]:
|
||||||
|
"""Build ``git remote add`` arguments."""
|
||||||
|
args = ["git", "remote", "add"]
|
||||||
|
if fetch:
|
||||||
|
args.append("-f")
|
||||||
|
args.extend([name, url])
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def build_remote_get_url(name: str = "origin") -> list[str]:
|
||||||
|
"""Build ``git remote get-url`` arguments."""
|
||||||
|
return ["git", "remote", "get-url", name]
|
||||||
|
|
||||||
|
|
||||||
|
def build_remote_set_url(name: str, url: str) -> list[str]:
|
||||||
|
"""Build ``git remote set-url`` arguments."""
|
||||||
|
return ["git", "remote", "set-url", name, url]
|
||||||
|
|
||||||
|
|
||||||
|
def build_reset(
|
||||||
|
*,
|
||||||
|
mode: str | None = None,
|
||||||
|
ref: str | None = None,
|
||||||
|
paths: list[str] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build ``git reset`` arguments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: Reset mode (``soft``, ``mixed``, ``hard``, ``merge``, ``keep``).
|
||||||
|
ref: Commit, branch, or ref to reset to.
|
||||||
|
paths: Paths to reset (mutually exclusive with ``mode``).
|
||||||
|
"""
|
||||||
|
_ALLOWED_MODES = {"soft", "mixed", "hard", "merge", "keep"}
|
||||||
|
if mode and mode not in _ALLOWED_MODES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Reset mode must be one of {', '.join(sorted(_ALLOWED_MODES))}."
|
||||||
|
)
|
||||||
|
args = ["git", "reset"]
|
||||||
|
if mode:
|
||||||
|
args.append(f"--{mode}")
|
||||||
|
if ref:
|
||||||
|
args.append(ref)
|
||||||
|
if paths:
|
||||||
|
args.append("--")
|
||||||
|
args.extend(paths)
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def build_restore(
|
||||||
|
paths: list[str],
|
||||||
|
*,
|
||||||
|
staged: bool = False,
|
||||||
|
worktree: bool = False,
|
||||||
|
source: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build ``git restore`` arguments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
paths: Paths to restore.
|
||||||
|
staged: Restore the index (unstage).
|
||||||
|
worktree: Restore working-tree files.
|
||||||
|
source: Commit or ref to restore from.
|
||||||
|
"""
|
||||||
|
if not paths:
|
||||||
|
raise ValueError("At least one path is required.")
|
||||||
|
if not staged and not worktree:
|
||||||
|
worktree = True
|
||||||
|
args = ["git", "restore"]
|
||||||
|
if worktree:
|
||||||
|
args.append("--worktree")
|
||||||
|
if staged:
|
||||||
|
args.append("--staged")
|
||||||
|
if source:
|
||||||
|
args.extend(["--source", source])
|
||||||
|
args.append("--")
|
||||||
|
args.extend(paths)
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def build_config_set(
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
*,
|
||||||
|
scope: str = "local",
|
||||||
|
repo_path: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build ``git config`` set arguments."""
|
||||||
|
scope_flag = _resolve_scope_flag(scope)
|
||||||
|
args = ["git"]
|
||||||
|
if scope == "local" and repo_path:
|
||||||
|
args.extend(["-C", repo_path])
|
||||||
|
args.extend(["config", scope_flag, key, value])
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def build_config_get(
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
scope: str = "local",
|
||||||
|
repo_path: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build ``git config --get`` arguments."""
|
||||||
|
scope_flag = _resolve_scope_flag(scope)
|
||||||
|
args = ["git"]
|
||||||
|
if scope == "local" and repo_path:
|
||||||
|
args.extend(["-C", repo_path])
|
||||||
|
args.extend(["config", scope_flag, "--get", key])
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def build_has_upstream() -> list[str]:
|
||||||
|
"""Build arguments to check if current branch has upstream tracking."""
|
||||||
|
return ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Parsers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def parse_status(stdout: str) -> GitStatus:
|
||||||
|
"""Parse ``git status --porcelain=v1 --branch`` output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stdout: Raw stdout from the git status command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed :class:`GitStatus`.
|
||||||
|
"""
|
||||||
|
lines = [line for line in stdout.split("\n") if line.rstrip()]
|
||||||
|
if not lines:
|
||||||
|
return GitStatus()
|
||||||
|
|
||||||
|
status = GitStatus()
|
||||||
|
|
||||||
|
branch_line = lines[0]
|
||||||
|
if branch_line.startswith("## "):
|
||||||
|
_parse_branch_line(branch_line[3:], status)
|
||||||
|
|
||||||
|
for line in lines[1:]:
|
||||||
|
if line.startswith("?? "):
|
||||||
|
status.files.append(
|
||||||
|
FileStatus(
|
||||||
|
path=line[3:],
|
||||||
|
index_status="?",
|
||||||
|
work_tree_status="?",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(line) < 4:
|
||||||
|
continue
|
||||||
|
|
||||||
|
idx = line[0]
|
||||||
|
wt = line[1]
|
||||||
|
path = line[3:]
|
||||||
|
renamed_from = None
|
||||||
|
if " -> " in path:
|
||||||
|
renamed_from, path = path.split(" -> ", 1)
|
||||||
|
|
||||||
|
status.files.append(
|
||||||
|
FileStatus(
|
||||||
|
path=path,
|
||||||
|
index_status=idx,
|
||||||
|
work_tree_status=wt,
|
||||||
|
renamed_from=renamed_from,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def parse_branches(stdout: str) -> list[GitBranch]:
|
||||||
|
"""Parse ``git branch --format=%(refname:short)\\t%(HEAD)`` output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stdout: Raw stdout from the git branch command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of :class:`GitBranch`.
|
||||||
|
"""
|
||||||
|
branches: list[GitBranch] = []
|
||||||
|
for line in stdout.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split("\t")
|
||||||
|
name = parts[0]
|
||||||
|
is_current = len(parts) > 1 and parts[1] == "*"
|
||||||
|
branches.append(GitBranch(name=name, is_current=is_current))
|
||||||
|
return branches
|
||||||
|
|
||||||
|
|
||||||
|
# ── Internal helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_scope_flag(scope: str) -> str:
|
||||||
|
"""Convert a scope name to a git config flag."""
|
||||||
|
scope = scope.strip().lower()
|
||||||
|
if scope == "local":
|
||||||
|
return "--local"
|
||||||
|
if scope == "global":
|
||||||
|
return "--global"
|
||||||
|
if scope == "system":
|
||||||
|
return "--system"
|
||||||
|
raise ValueError("Git config scope must be one of: local, global, system.")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_branch_line(info: str, status: GitStatus) -> None:
|
||||||
|
"""Parse the ``## branch...upstream [ahead N, behind M]`` header."""
|
||||||
|
ahead_start = info.find(" [")
|
||||||
|
branch_part = info if ahead_start == -1 else info[:ahead_start]
|
||||||
|
ahead_part = None if ahead_start == -1 else info[ahead_start + 2 : -1]
|
||||||
|
|
||||||
|
if branch_part.startswith("HEAD (detached at "):
|
||||||
|
status.detached = True
|
||||||
|
status.branch = branch_part[18:].rstrip(")")
|
||||||
|
elif "detached" in branch_part or branch_part.startswith("HEAD"):
|
||||||
|
status.detached = True
|
||||||
|
elif "..." in branch_part:
|
||||||
|
local, remote = branch_part.split("...", 1)
|
||||||
|
status.branch = local or None
|
||||||
|
status.upstream = remote or None
|
||||||
|
else:
|
||||||
|
name = branch_part.replace("No commits yet on ", "").replace(
|
||||||
|
"Initial commit on ", ""
|
||||||
|
)
|
||||||
|
status.branch = name or None
|
||||||
|
|
||||||
|
if ahead_part:
|
||||||
|
m = re.search(r"ahead (\d+)", ahead_part)
|
||||||
|
if m:
|
||||||
|
status.ahead = int(m.group(1))
|
||||||
|
m = re.search(r"behind (\d+)", ahead_part)
|
||||||
|
if m:
|
||||||
|
status.behind = int(m.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_status(index_status: str, work_tree_status: str) -> str:
|
||||||
|
"""Derive a normalized status label from porcelain XY characters."""
|
||||||
|
chars = {index_status, work_tree_status}
|
||||||
|
if "U" in chars:
|
||||||
|
return "conflict"
|
||||||
|
if "R" in chars:
|
||||||
|
return "renamed"
|
||||||
|
if "C" in chars:
|
||||||
|
return "copied"
|
||||||
|
if "D" in chars:
|
||||||
|
return "deleted"
|
||||||
|
if "A" in chars:
|
||||||
|
return "added"
|
||||||
|
if "M" in chars:
|
||||||
|
return "modified"
|
||||||
|
if "T" in chars:
|
||||||
|
return "typechange"
|
||||||
|
if "?" in chars:
|
||||||
|
return "untracked"
|
||||||
|
return "unknown"
|
||||||
28
src/wrenn/_git/exceptions.py
Normal file
28
src/wrenn/_git/exceptions.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class GitError(Exception):
|
||||||
|
"""Base exception for all git operations inside a capsule.
|
||||||
|
|
||||||
|
Not a subclass of :class:`WrennError` because git errors originate
|
||||||
|
from a process exit code, not an HTTP response.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
message (str): Human-readable error description.
|
||||||
|
stderr (str): Raw stderr output from the git process.
|
||||||
|
exit_code (int): Process exit code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, stderr: str = "", exit_code: int = -1) -> None:
|
||||||
|
self.message = message
|
||||||
|
self.stderr = stderr
|
||||||
|
self.exit_code = exit_code
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class GitCommandError(GitError):
|
||||||
|
"""A git command exited with a non-zero exit code."""
|
||||||
|
|
||||||
|
|
||||||
|
class GitAuthError(GitError):
|
||||||
|
"""Authentication failed when communicating with a remote."""
|
||||||
395
src/wrenn/async_capsule.py
Normal file
395
src/wrenn/async_capsule.py
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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, Status.paused):
|
||||||
|
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
||||||
|
|
||||||
|
async def is_running(self) -> bool:
|
||||||
|
"""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: 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:
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await self._client.aclose()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
470
src/wrenn/capsule.py
Normal file
470
src/wrenn/capsule.py
Normal file
@ -0,0 +1,470 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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:
|
||||||
|
# Internal construction path (from create/connect classmethods)
|
||||||
|
assert _client is not None
|
||||||
|
self._id = _capsule_id
|
||||||
|
self._client = _client
|
||||||
|
self._info = _info
|
||||||
|
else:
|
||||||
|
# Public construction: create a capsule immediately
|
||||||
|
self._client = WrennClient(api_key=api_key, base_url=base_url)
|
||||||
|
self._info = self._client.capsules.create(
|
||||||
|
template=template,
|
||||||
|
vcpus=vcpus,
|
||||||
|
memory_mb=memory_mb,
|
||||||
|
timeout_sec=timeout,
|
||||||
|
)
|
||||||
|
self._id = self._info.id
|
||||||
|
|
||||||
|
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, Status.paused):
|
||||||
|
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
|
||||||
|
time.sleep(interval)
|
||||||
|
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""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: 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:
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self._client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
471
src/wrenn/client.py
Normal file
471
src/wrenn/client.py
Normal file
@ -0,0 +1,471 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from wrenn._config import DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL
|
||||||
|
from wrenn.exceptions import handle_response
|
||||||
|
from wrenn.models import (
|
||||||
|
Template,
|
||||||
|
)
|
||||||
|
from wrenn.models import (
|
||||||
|
Capsule as CapsuleModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
class CapsulesResource:
|
||||||
|
"""Sync capsule control-plane operations."""
|
||||||
|
|
||||||
|
def __init__(self, http: httpx.Client) -> None:
|
||||||
|
self._http = http
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
payload: dict = {}
|
||||||
|
if template is not None:
|
||||||
|
payload["template"] = template
|
||||||
|
if vcpus is not None:
|
||||||
|
payload["vcpus"] = vcpus
|
||||||
|
if memory_mb is not None:
|
||||||
|
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))
|
||||||
|
|
||||||
|
def list(self) -> list[CapsuleModel]:
|
||||||
|
"""List all capsules for the authenticated team.
|
||||||
|
|
||||||
|
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 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")
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncCapsulesResource:
|
||||||
|
"""Async capsule control-plane operations."""
|
||||||
|
|
||||||
|
def __init__(self, http: httpx.AsyncClient) -> None:
|
||||||
|
self._http = http
|
||||||
|
|
||||||
|
async 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.
|
||||||
|
"""
|
||||||
|
payload: dict = {}
|
||||||
|
if template is not None:
|
||||||
|
payload["template"] = template
|
||||||
|
if vcpus is not None:
|
||||||
|
payload["vcpus"] = vcpus
|
||||||
|
if memory_mb is not None:
|
||||||
|
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))
|
||||||
|
|
||||||
|
async def list(self) -> list[CapsuleModel]:
|
||||||
|
"""List all capsules for the authenticated team.
|
||||||
|
|
||||||
|
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 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")
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotsResource:
|
||||||
|
"""Sync snapshot operations."""
|
||||||
|
|
||||||
|
def __init__(self, http: httpx.Client) -> None:
|
||||||
|
self._http = http
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
capsule_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}
|
||||||
|
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)
|
||||||
|
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)]
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncSnapshotsResource:
|
||||||
|
"""Async snapshot operations."""
|
||||||
|
|
||||||
|
def __init__(self, http: httpx.AsyncClient) -> None:
|
||||||
|
self._http = http
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
capsule_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}
|
||||||
|
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)
|
||||||
|
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)]
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class WrennClient:
|
||||||
|
"""Synchronous client for the Wrenn API.
|
||||||
|
|
||||||
|
Authenticates with an API key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var.
|
||||||
|
base_url: Wrenn API base URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: str | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
|
) -> 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},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.capsules = CapsulesResource(self._http)
|
||||||
|
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
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the underlying HTTP connection pool."""
|
||||||
|
self._http.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> WrennClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc_val: BaseException | None,
|
||||||
|
exc_tb: object,
|
||||||
|
) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncWrennClient:
|
||||||
|
"""Asynchronous client for the Wrenn API.
|
||||||
|
|
||||||
|
Authenticates with an API key.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: str | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
|
) -> 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},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.capsules = AsyncCapsulesResource(self._http)
|
||||||
|
self.snapshots = AsyncSnapshotsResource(self._http)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def http(self) -> httpx.AsyncClient:
|
||||||
|
"""The underlying httpx.AsyncClient."""
|
||||||
|
return self._http
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
"""Close the underlying async HTTP connection pool."""
|
||||||
|
await self._http.aclose()
|
||||||
|
|
||||||
|
async def __aenter__(self) -> AsyncWrennClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc_val: BaseException | None,
|
||||||
|
exc_tb: object,
|
||||||
|
) -> None:
|
||||||
|
await self.aclose()
|
||||||
35
src/wrenn/code_interpreter/__init__.py
Normal file
35
src/wrenn/code_interpreter/__init__.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
from wrenn.code_interpreter.async_capsule import AsyncCapsule
|
||||||
|
from wrenn.code_interpreter.capsule import Capsule
|
||||||
|
from wrenn.code_interpreter.models import (
|
||||||
|
Execution,
|
||||||
|
ExecutionError,
|
||||||
|
Logs,
|
||||||
|
Result,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AsyncCapsule",
|
||||||
|
"Capsule",
|
||||||
|
"Execution",
|
||||||
|
"ExecutionError",
|
||||||
|
"Logs",
|
||||||
|
"Result",
|
||||||
|
"Sandbox",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> type:
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
_module = sys.modules[__name__]
|
||||||
|
|
||||||
|
if name == "Sandbox":
|
||||||
|
warnings.warn(
|
||||||
|
"'Sandbox' is deprecated, use 'Capsule' instead",
|
||||||
|
FutureWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
setattr(_module, name, Capsule)
|
||||||
|
return Capsule
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
270
src/wrenn/code_interpreter/async_capsule.py
Normal file
270
src/wrenn/code_interpreter/async_capsule.py
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
@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:
|
||||||
|
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._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",
|
||||||
|
"msg_id": msg_id,
|
||||||
|
"msg_type": "execute_request",
|
||||||
|
}
|
||||||
|
|
||||||
|
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["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:
|
||||||
|
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 (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":
|
||||||
|
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)
|
||||||
296
src/wrenn/code_interpreter/capsule.py
Normal file
296
src/wrenn/code_interpreter/capsule.py
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
@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:
|
||||||
|
raise
|
||||||
|
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",
|
||||||
|
"msg_id": msg_id,
|
||||||
|
"msg_type": "execute_request",
|
||||||
|
}
|
||||||
|
|
||||||
|
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["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:
|
||||||
|
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":
|
||||||
|
text = content.get("text", "")
|
||||||
|
name = content.get("name", "stdout")
|
||||||
|
if name == "stderr":
|
||||||
|
execution.logs.stderr.append(text)
|
||||||
|
if on_stderr is not None:
|
||||||
|
on_stderr(text)
|
||||||
|
else:
|
||||||
|
execution.logs.stdout.append(text)
|
||||||
|
if on_stdout is not None:
|
||||||
|
on_stdout(text)
|
||||||
|
elif msg_type in ("execute_result", "display_data"):
|
||||||
|
bundle = content.get("data", {})
|
||||||
|
is_main = msg_type == "execute_result"
|
||||||
|
result = Result.from_bundle(bundle, is_main_result=is_main)
|
||||||
|
execution.results.append(result)
|
||||||
|
if is_main:
|
||||||
|
execution.execution_count = content.get("execution_count")
|
||||||
|
if on_result is not None:
|
||||||
|
on_result(result)
|
||||||
|
elif msg_type == "error":
|
||||||
|
err = ExecutionError(
|
||||||
|
name=content.get("ename", ""),
|
||||||
|
value=content.get("evalue", ""),
|
||||||
|
traceback="\n".join(content.get("traceback", [])),
|
||||||
|
)
|
||||||
|
execution.error = err
|
||||||
|
if on_error is not None:
|
||||||
|
on_error(err)
|
||||||
|
elif msg_type == "status" and content.get("execution_state") == "idle":
|
||||||
|
break
|
||||||
|
|
||||||
|
return execution
|
||||||
|
|
||||||
|
def __exit__(self, *args) -> None:
|
||||||
|
if self._proxy_client is not None:
|
||||||
|
try:
|
||||||
|
self._proxy_client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
super().__exit__(*args)
|
||||||
156
src/wrenn/code_interpreter/models.py
Normal file
156
src/wrenn/code_interpreter/models.py
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
_MIME_MAP: dict[str, str] = {
|
||||||
|
"text/plain": "text",
|
||||||
|
"text/html": "html",
|
||||||
|
"text/markdown": "markdown",
|
||||||
|
"image/svg+xml": "svg",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/jpeg": "jpeg",
|
||||||
|
"application/pdf": "pdf",
|
||||||
|
"text/latex": "latex",
|
||||||
|
"application/json": "json",
|
||||||
|
"application/javascript": "javascript",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExecutionError:
|
||||||
|
"""Error raised during code execution.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Exception class name (e.g. ``"NameError"``).
|
||||||
|
value: Exception message.
|
||||||
|
traceback: Full traceback string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
|
value: str = ""
|
||||||
|
traceback: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Logs:
|
||||||
|
"""Captured stdout/stderr streams.
|
||||||
|
|
||||||
|
Each element in the list is one chunk of text as it arrived from
|
||||||
|
the kernel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
stdout: list[str] = field(default_factory=list)
|
||||||
|
stderr: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Result:
|
||||||
|
"""A single rich output from code execution.
|
||||||
|
|
||||||
|
Jupyter cells can produce multiple outputs — one ``execute_result``
|
||||||
|
(the expression value) and zero or more ``display_data`` messages
|
||||||
|
(from ``plt.show()``, ``display()``, etc.). Each becomes a
|
||||||
|
``Result``.
|
||||||
|
|
||||||
|
Known MIME types are unpacked into named attributes; anything else
|
||||||
|
lands in :pyattr:`extra`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# --- MIME type fields ---
|
||||||
|
text: str | None = None
|
||||||
|
"""``text/plain`` representation."""
|
||||||
|
html: str | None = None
|
||||||
|
"""``text/html`` representation."""
|
||||||
|
markdown: str | None = None
|
||||||
|
"""``text/markdown`` representation."""
|
||||||
|
svg: str | None = None
|
||||||
|
"""``image/svg+xml`` representation."""
|
||||||
|
png: str | None = None
|
||||||
|
"""``image/png`` — base64-encoded."""
|
||||||
|
jpeg: str | None = None
|
||||||
|
"""``image/jpeg`` — base64-encoded."""
|
||||||
|
pdf: str | None = None
|
||||||
|
"""``application/pdf`` — base64-encoded."""
|
||||||
|
latex: str | None = None
|
||||||
|
"""``text/latex`` representation."""
|
||||||
|
json: dict | None = None
|
||||||
|
"""``application/json`` representation."""
|
||||||
|
javascript: str | None = None
|
||||||
|
"""``application/javascript`` representation."""
|
||||||
|
extra: dict[str, str] | None = None
|
||||||
|
"""MIME types not covered by the named fields above."""
|
||||||
|
|
||||||
|
is_main_result: bool = False
|
||||||
|
"""``True`` when this came from an ``execute_result`` message
|
||||||
|
(i.e. the value of the last expression in the cell). ``False``
|
||||||
|
for ``display_data`` outputs."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bundle(
|
||||||
|
cls, bundle: dict[str, str], *, is_main_result: bool = False
|
||||||
|
) -> Result:
|
||||||
|
"""Build a ``Result`` from a Jupyter MIME bundle dict."""
|
||||||
|
kwargs: dict = {"is_main_result": is_main_result}
|
||||||
|
extra: dict[str, str] = {}
|
||||||
|
for mime, value in bundle.items():
|
||||||
|
attr = _MIME_MAP.get(mime)
|
||||||
|
if attr is not None:
|
||||||
|
kwargs[attr] = value
|
||||||
|
else:
|
||||||
|
extra[mime] = value
|
||||||
|
if extra:
|
||||||
|
kwargs["extra"] = extra
|
||||||
|
# Strip surrounding quotes from text/plain (Jupyter repr artefact)
|
||||||
|
text = kwargs.get("text")
|
||||||
|
if isinstance(text, str) and len(text) >= 2:
|
||||||
|
if (text[0] == text[-1]) and text[0] in ("'", '"'):
|
||||||
|
kwargs["text"] = text[1:-1]
|
||||||
|
return cls(**kwargs)
|
||||||
|
|
||||||
|
def formats(self) -> list[str]:
|
||||||
|
"""Return names of non-``None`` MIME-type fields."""
|
||||||
|
out: list[str] = []
|
||||||
|
for attr in (
|
||||||
|
"text",
|
||||||
|
"html",
|
||||||
|
"markdown",
|
||||||
|
"svg",
|
||||||
|
"png",
|
||||||
|
"jpeg",
|
||||||
|
"pdf",
|
||||||
|
"latex",
|
||||||
|
"json",
|
||||||
|
"javascript",
|
||||||
|
):
|
||||||
|
if getattr(self, attr) is not None:
|
||||||
|
out.append(attr)
|
||||||
|
if self.extra:
|
||||||
|
out.extend(self.extra)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Execution:
|
||||||
|
"""Complete result of a ``run_code`` call.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
results: All rich outputs produced by the cell — charts, tables,
|
||||||
|
images, expression values, etc.
|
||||||
|
logs: Captured stdout/stderr text.
|
||||||
|
error: Populated when the cell raised an exception.
|
||||||
|
execution_count: Jupyter execution counter (the ``[N]`` number).
|
||||||
|
"""
|
||||||
|
|
||||||
|
results: list[Result] = field(default_factory=list)
|
||||||
|
logs: Logs = field(default_factory=Logs)
|
||||||
|
error: ExecutionError | None = None
|
||||||
|
execution_count: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str | None:
|
||||||
|
"""Convenience — ``text/plain`` of the main ``execute_result``,
|
||||||
|
or ``None`` if the cell had no expression value."""
|
||||||
|
for r in self.results:
|
||||||
|
if r.is_main_result:
|
||||||
|
return r.text
|
||||||
|
return None
|
||||||
480
src/wrenn/commands.py
Normal file
480
src/wrenn/commands.py
Normal file
@ -0,0 +1,480 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from collections.abc import AsyncIterator, Iterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import overload, Literal
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
resp = self._http.post(f"/v1/capsules/{self._capsule_id}/exec", json=payload)
|
||||||
|
data = handle_response(resp)
|
||||||
|
|
||||||
|
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)
|
||||||
|
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:
|
||||||
|
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: 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:
|
||||||
|
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
|
||||||
|
|
||||||
|
resp = await self._http.post(
|
||||||
|
f"/v1/capsules/{self._capsule_id}/exec", json=payload
|
||||||
|
)
|
||||||
|
data = handle_response(resp)
|
||||||
|
|
||||||
|
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)
|
||||||
|
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:
|
||||||
|
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: 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:
|
||||||
|
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
|
||||||
155
src/wrenn/exceptions.py
Normal file
155
src/wrenn/exceptions.py
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class WrennValidationError(WrennError):
|
||||||
|
"""400 — Invalid request parameters."""
|
||||||
|
|
||||||
|
|
||||||
|
class WrennAuthenticationError(WrennError):
|
||||||
|
"""401 — Invalid or missing authentication."""
|
||||||
|
|
||||||
|
|
||||||
|
class WrennForbiddenError(WrennError):
|
||||||
|
"""403 — Authenticated but not authorized."""
|
||||||
|
|
||||||
|
|
||||||
|
class WrennNotFoundError(WrennError):
|
||||||
|
"""404 — Resource not found."""
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, code: str, message: str, status_code: int, capsule_ids: list[str]
|
||||||
|
) -> None:
|
||||||
|
"""Initialize a WrennHostHasCapsulesError.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code (str): Machine-readable error code.
|
||||||
|
message (str): Human-readable error description.
|
||||||
|
status_code (int): HTTP status code of the response.
|
||||||
|
capsule_ids (list[str]): IDs of capsules still on the host.
|
||||||
|
"""
|
||||||
|
self.capsule_ids = capsule_ids
|
||||||
|
super().__init__(code, message, status_code)
|
||||||
|
|
||||||
|
@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."""
|
||||||
|
|
||||||
|
|
||||||
|
class WrennAgentError(WrennError):
|
||||||
|
"""502 — Host agent returned an error."""
|
||||||
|
|
||||||
|
|
||||||
|
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 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 WrennHostHasCapsulesError:
|
||||||
|
raise WrennHostHasCapsulesError(
|
||||||
|
code=code,
|
||||||
|
message=message,
|
||||||
|
status_code=resp.status_code,
|
||||||
|
capsule_ids=body.get("sandbox_ids", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
raise exc_cls(
|
||||||
|
code=code,
|
||||||
|
message=message,
|
||||||
|
status_code=resp.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
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}")
|
||||||
404
src/wrenn/files.py
Normal file
404
src/wrenn/files.py
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import AsyncIterator, Iterator
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from wrenn.exceptions import WrennNotFoundError, 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},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
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')}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
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')}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
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
|
||||||
67
src/wrenn/models/__init__.py
Normal file
67
src/wrenn/models/__init__.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
from wrenn.models._generated import (
|
||||||
|
APIKeyResponse,
|
||||||
|
AuthResponse,
|
||||||
|
Capsule,
|
||||||
|
CreateAPIKeyRequest,
|
||||||
|
CreateCapsuleRequest,
|
||||||
|
CreateHostRequest,
|
||||||
|
CreateHostResponse,
|
||||||
|
CreateSnapshotRequest,
|
||||||
|
Encoding,
|
||||||
|
Error,
|
||||||
|
Error1,
|
||||||
|
ExecRequest,
|
||||||
|
ExecResponse,
|
||||||
|
FileEntry,
|
||||||
|
Host,
|
||||||
|
ListDirRequest,
|
||||||
|
ListDirResponse,
|
||||||
|
LoginRequest,
|
||||||
|
MakeDirRequest,
|
||||||
|
MakeDirResponse,
|
||||||
|
ReadFileRequest,
|
||||||
|
RegisterHostRequest,
|
||||||
|
RegisterHostResponse,
|
||||||
|
RemoveRequest,
|
||||||
|
SignupRequest,
|
||||||
|
Status,
|
||||||
|
Status1,
|
||||||
|
Template,
|
||||||
|
Type,
|
||||||
|
Type1,
|
||||||
|
Type2,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"APIKeyResponse",
|
||||||
|
"AuthResponse",
|
||||||
|
"CreateAPIKeyRequest",
|
||||||
|
"CreateHostRequest",
|
||||||
|
"CreateHostResponse",
|
||||||
|
"CreateCapsuleRequest",
|
||||||
|
"CreateSnapshotRequest",
|
||||||
|
"Encoding",
|
||||||
|
"Error",
|
||||||
|
"Error1",
|
||||||
|
"ExecRequest",
|
||||||
|
"ExecResponse",
|
||||||
|
"FileEntry",
|
||||||
|
"Host",
|
||||||
|
"ListDirRequest",
|
||||||
|
"ListDirResponse",
|
||||||
|
"LoginRequest",
|
||||||
|
"MakeDirRequest",
|
||||||
|
"MakeDirResponse",
|
||||||
|
"ReadFileRequest",
|
||||||
|
"RegisterHostRequest",
|
||||||
|
"RegisterHostResponse",
|
||||||
|
"RemoveRequest",
|
||||||
|
"Capsule",
|
||||||
|
"SignupRequest",
|
||||||
|
"Status",
|
||||||
|
"Status1",
|
||||||
|
"Template",
|
||||||
|
"Type",
|
||||||
|
"Type1",
|
||||||
|
"Type2",
|
||||||
|
]
|
||||||
625
src/wrenn/models/_generated.py
Normal file
625
src/wrenn/models/_generated.py
Normal file
@ -0,0 +1,625 @@
|
|||||||
|
# generated by datamodel-codegen:
|
||||||
|
# filename: openapi.yaml
|
||||||
|
# timestamp: 2026-04-22T20:21:34+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
|
||||||
|
|
||||||
|
|
||||||
|
class SignupRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: Annotated[str, Field(min_length=8)]
|
||||||
|
name: Annotated[str, Field(max_length=100)]
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class SignupResponse(BaseModel):
|
||||||
|
message: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Confirmation message instructing user to check email"),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthResponse(BaseModel):
|
||||||
|
token: Annotated[str | None, Field(description="JWT token (valid for 6 hours)")] = (
|
||||||
|
None
|
||||||
|
)
|
||||||
|
user_id: str | None = None
|
||||||
|
team_id: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateAPIKeyRequest(BaseModel):
|
||||||
|
name: str | None = "Unnamed API Key"
|
||||||
|
|
||||||
|
|
||||||
|
class APIKeyResponse(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
team_id: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
key_prefix: Annotated[
|
||||||
|
str | None, Field(description='Display prefix (e.g. "wrn_ab12cd34...")')
|
||||||
|
] = None
|
||||||
|
created_at: AwareDatetime | None = None
|
||||||
|
last_used: AwareDatetime | None = None
|
||||||
|
key: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="Full plaintext key. Only returned on creation, never again."
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateCapsuleRequest(BaseModel):
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
] = 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):
|
||||||
|
id: str | None = None
|
||||||
|
status: Status | None = None
|
||||||
|
template: str | None = None
|
||||||
|
vcpus: int | None = None
|
||||||
|
memory_mb: int | None = None
|
||||||
|
timeout_sec: int | None = None
|
||||||
|
guest_ip: str | None = None
|
||||||
|
host_ip: str | None = None
|
||||||
|
created_at: AwareDatetime | None = None
|
||||||
|
started_at: AwareDatetime | None = None
|
||||||
|
last_active_at: AwareDatetime | None = None
|
||||||
|
last_updated: AwareDatetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateSnapshotRequest(BaseModel):
|
||||||
|
sandbox_id: Annotated[
|
||||||
|
str, Field(description="ID of the running capsule to snapshot.")
|
||||||
|
]
|
||||||
|
name: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Name for the snapshot template. Auto-generated if omitted."),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Type(StrEnum):
|
||||||
|
base = "base"
|
||||||
|
snapshot = "snapshot"
|
||||||
|
|
||||||
|
|
||||||
|
class Template(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
type: Type | None = None
|
||||||
|
vcpus: int | None = None
|
||||||
|
memory_mb: int | None = None
|
||||||
|
size_bytes: int | None = None
|
||||||
|
created_at: AwareDatetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class Encoding(StrEnum):
|
||||||
|
"""
|
||||||
|
Output encoding. "base64" when stdout/stderr contain binary data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
utf_8 = "utf-8"
|
||||||
|
base64 = "base64"
|
||||||
|
|
||||||
|
|
||||||
|
class ExecResponse(BaseModel):
|
||||||
|
sandbox_id: str | None = None
|
||||||
|
cmd: str | None = None
|
||||||
|
stdout: str | None = None
|
||||||
|
stderr: str | None = None
|
||||||
|
exit_code: int | None = None
|
||||||
|
duration_ms: int | None = None
|
||||||
|
encoding: Annotated[
|
||||||
|
Encoding | None,
|
||||||
|
Field(
|
||||||
|
description='Output encoding. "base64" when stdout/stderr contain binary data.'
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReadFileRequest(BaseModel):
|
||||||
|
path: Annotated[str, Field(description="Absolute file path inside the capsule")]
|
||||||
|
|
||||||
|
|
||||||
|
class ListDirRequest(BaseModel):
|
||||||
|
path: Annotated[str, Field(description="Directory path inside the capsule")]
|
||||||
|
depth: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(
|
||||||
|
description="Recursion depth (0 = non-recursive, 1 = immediate children)"
|
||||||
|
),
|
||||||
|
] = 1
|
||||||
|
|
||||||
|
|
||||||
|
class Type1(StrEnum):
|
||||||
|
file = "file"
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
regular = "regular"
|
||||||
|
byoc = "byoc"
|
||||||
|
|
||||||
|
|
||||||
|
class CreateHostRequest(BaseModel):
|
||||||
|
type: Annotated[
|
||||||
|
Type2,
|
||||||
|
Field(
|
||||||
|
description="Host type. Regular hosts are shared; BYOC hosts belong to a team."
|
||||||
|
),
|
||||||
|
]
|
||||||
|
team_id: Annotated[str | None, Field(description="Required for BYOC hosts.")] = None
|
||||||
|
provider: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Cloud provider (e.g. aws, gcp, hetzner, bare-metal)."),
|
||||||
|
] = None
|
||||||
|
availability_zone: Annotated[
|
||||||
|
str | None, Field(description="Availability zone (e.g. us-east, eu-west).")
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterHostRequest(BaseModel):
|
||||||
|
token: Annotated[
|
||||||
|
str, Field(description="One-time registration token from POST /v1/hosts.")
|
||||||
|
]
|
||||||
|
arch: Annotated[
|
||||||
|
str | None, Field(description="CPU architecture (e.g. x86_64, aarch64).")
|
||||||
|
] = None
|
||||||
|
cpu_cores: int | None = None
|
||||||
|
memory_mb: int | None = None
|
||||||
|
disk_gb: int | None = None
|
||||||
|
address: Annotated[str, Field(description="Host agent address (ip:port).")]
|
||||||
|
|
||||||
|
|
||||||
|
class Type3(StrEnum):
|
||||||
|
regular = "regular"
|
||||||
|
byoc = "byoc"
|
||||||
|
|
||||||
|
|
||||||
|
class Status1(StrEnum):
|
||||||
|
pending = "pending"
|
||||||
|
online = "online"
|
||||||
|
offline = "offline"
|
||||||
|
draining = "draining"
|
||||||
|
unreachable = "unreachable"
|
||||||
|
|
||||||
|
|
||||||
|
class Host(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
type: Type3 | None = None
|
||||||
|
team_id: str | None = None
|
||||||
|
provider: str | None = None
|
||||||
|
availability_zone: str | None = None
|
||||||
|
arch: str | None = None
|
||||||
|
cpu_cores: int | None = None
|
||||||
|
memory_mb: int | None = None
|
||||||
|
disk_gb: int | None = None
|
||||||
|
address: str | None = None
|
||||||
|
status: Status1 | None = None
|
||||||
|
last_heartbeat_at: AwareDatetime | None = None
|
||||||
|
created_by: str | None = None
|
||||||
|
created_at: AwareDatetime | None = None
|
||||||
|
updated_at: AwareDatetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshHostTokenRequest(BaseModel):
|
||||||
|
refresh_token: Annotated[
|
||||||
|
str,
|
||||||
|
Field(
|
||||||
|
description="Refresh token obtained from registration or a previous refresh."
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshHostTokenResponse(BaseModel):
|
||||||
|
host: Host | None = None
|
||||||
|
token: Annotated[
|
||||||
|
str | None, Field(description="New host JWT. Valid for 7 days.")
|
||||||
|
] = None
|
||||||
|
refresh_token: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="New refresh token. Valid for 60 days; old token is revoked."
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class HostDeletePreview(BaseModel):
|
||||||
|
host: Host | None = None
|
||||||
|
sandbox_ids: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(description="IDs of 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):
|
||||||
|
code: str | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Error1(BaseModel):
|
||||||
|
error: Error2 | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ListDirResponse(BaseModel):
|
||||||
|
entries: list[FileEntry] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateHostResponse(BaseModel):
|
||||||
|
host: Host | None = None
|
||||||
|
registration_token: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="One-time registration token for the host agent. Expires in 1 hour."
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterHostResponse(BaseModel):
|
||||||
|
host: Host | None = None
|
||||||
|
token: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Host JWT for X-Host-Token header. Valid for 7 days."),
|
||||||
|
] = None
|
||||||
|
refresh_token: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description="Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use."
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapsuleMetrics(BaseModel):
|
||||||
|
sandbox_id: str | None = None
|
||||||
|
range: Range1 | None = None
|
||||||
|
points: list[MetricPoint] | None = None
|
||||||
306
src/wrenn/pty.py
Normal file
306
src/wrenn/pty.py
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
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:
|
||||||
|
raise StopIteration
|
||||||
|
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:
|
||||||
|
raise StopAsyncIteration
|
||||||
|
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
|
||||||
22
src/wrenn/sandbox.py
Normal file
22
src/wrenn/sandbox.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import warnings as _warnings
|
||||||
|
|
||||||
|
from wrenn.capsule import Capsule # noqa: F401
|
||||||
|
from wrenn.commands import ( # noqa: F401
|
||||||
|
StreamErrorEvent,
|
||||||
|
StreamEvent,
|
||||||
|
StreamExitEvent,
|
||||||
|
StreamStartEvent,
|
||||||
|
StreamStderrEvent,
|
||||||
|
StreamStdoutEvent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> type:
|
||||||
|
if name == "Sandbox":
|
||||||
|
_warnings.warn(
|
||||||
|
"'Sandbox' is deprecated, use 'Capsule' instead",
|
||||||
|
FutureWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return Capsule
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
37
tests/conftest.py
Normal file
37
tests/conftest.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ENV_FILE = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_env_file() -> dict[str, str]:
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
if not ENV_FILE.exists():
|
||||||
|
return result
|
||||||
|
for line in ENV_FILE.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip("\"'")
|
||||||
|
if key:
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_collection_modifyitems(
|
||||||
|
config: pytest.Config, items: list[pytest.Item]
|
||||||
|
) -> None:
|
||||||
|
env_vars = _read_env_file()
|
||||||
|
has_key = bool(os.environ.get("WRENN_API_KEY") or env_vars.get("WRENN_API_KEY"))
|
||||||
|
if has_key:
|
||||||
|
return
|
||||||
|
skip = pytest.mark.skip(reason="WRENN_API_KEY not set")
|
||||||
|
for item in items:
|
||||||
|
if "integration" in item.keywords:
|
||||||
|
item.add_marker(skip)
|
||||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
104
tests/integration/conftest.py
Normal file
104
tests/integration/conftest.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import AsyncGenerator, Generator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
from wrenn.async_capsule import AsyncCapsule
|
||||||
|
from wrenn.capsule import Capsule
|
||||||
|
from wrenn.client import AsyncWrennClient, WrennClient
|
||||||
|
|
||||||
|
WRENN_API_KEY = os.environ.get("WRENN_API_KEY")
|
||||||
|
WRENN_BASE_URL = os.environ.get("WRENN_BASE_URL", "http://localhost:8080")
|
||||||
|
|
||||||
|
_env_loaded = False
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_env() -> None:
|
||||||
|
global _env_loaded
|
||||||
|
if _env_loaded:
|
||||||
|
return
|
||||||
|
_env_loaded = True
|
||||||
|
env_file = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
if not env_file.exists():
|
||||||
|
return
|
||||||
|
for line in env_file.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key, value = key.strip(), value.strip().strip("\"'")
|
||||||
|
if key and key not in os.environ:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _load_env():
|
||||||
|
_ensure_env()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_auth() -> bool:
|
||||||
|
return bool(WRENN_API_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
requires_auth = pytest.mark.skipif(
|
||||||
|
not _has_auth(),
|
||||||
|
reason="Set WRENN_API_KEY to run integration tests",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> Generator[WrennClient, None, None]:
|
||||||
|
with WrennClient(
|
||||||
|
api_key=WRENN_API_KEY,
|
||||||
|
base_url=WRENN_BASE_URL,
|
||||||
|
) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def async_client() -> AsyncGenerator[AsyncWrennClient, None]:
|
||||||
|
async with AsyncWrennClient(api_key=WRENN_API_KEY, base_url=WRENN_BASE_URL) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def async_minimal_capsule() -> AsyncGenerator[AsyncCapsule, None]:
|
||||||
|
"""Provides a ready-to-use minimal capsule and cleans it up afterward."""
|
||||||
|
async with await AsyncCapsule.create(
|
||||||
|
template="minimal",
|
||||||
|
timeout=120,
|
||||||
|
wait=True,
|
||||||
|
api_key=WRENN_API_KEY,
|
||||||
|
base_url=WRENN_BASE_URL,
|
||||||
|
) as cap:
|
||||||
|
yield cap
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def async_python_capsule() -> AsyncGenerator[AsyncCapsule, None]:
|
||||||
|
"""Provides a ready-to-use Python interpreter capsule."""
|
||||||
|
async with await AsyncCapsule.create(
|
||||||
|
template="python-interpreter-v0-beta",
|
||||||
|
timeout=120,
|
||||||
|
wait=True,
|
||||||
|
api_key=WRENN_API_KEY,
|
||||||
|
base_url=WRENN_BASE_URL,
|
||||||
|
) as cap:
|
||||||
|
yield cap
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def minimal_capsule() -> Generator[Capsule, None, None]:
|
||||||
|
"""Provides a ready-to-use minimal capsule and cleans it up afterward."""
|
||||||
|
with Capsule(
|
||||||
|
template="minimal",
|
||||||
|
timeout=120,
|
||||||
|
wait=True,
|
||||||
|
api_key=WRENN_API_KEY,
|
||||||
|
base_url=WRENN_BASE_URL,
|
||||||
|
) as cap:
|
||||||
|
yield cap
|
||||||
91
tests/integration/test_commands.py
Normal file
91
tests/integration/test_commands.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wrenn import Capsule, CommandResult
|
||||||
|
from wrenn.commands import CommandHandle, ProcessInfo
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommands:
|
||||||
|
"""Shared capsule for command execution tests."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_run_foreground(self):
|
||||||
|
result = self.capsule.commands.run("echo hello")
|
||||||
|
assert isinstance(result, CommandResult)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "hello" in result.stdout
|
||||||
|
|
||||||
|
def test_run_stderr(self):
|
||||||
|
result = self.capsule.commands.run("echo error >&2")
|
||||||
|
assert "error" in result.stderr
|
||||||
|
|
||||||
|
def test_run_exit_code(self):
|
||||||
|
result = self.capsule.commands.run("exit 42")
|
||||||
|
assert result.exit_code == 42
|
||||||
|
|
||||||
|
def test_run_with_envs(self):
|
||||||
|
result = self.capsule.commands.run("export MY_VAR=test_value && echo $MY_VAR")
|
||||||
|
assert "test_value" in result.stdout
|
||||||
|
|
||||||
|
def test_run_with_cwd(self):
|
||||||
|
result = self.capsule.commands.run("cd /tmp && pwd")
|
||||||
|
assert result.stdout.strip() == "/tmp"
|
||||||
|
|
||||||
|
def test_run_multiline_output(self):
|
||||||
|
result = self.capsule.commands.run("echo -e 'line1\\nline2\\nline3'")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
lines = result.stdout.strip().splitlines()
|
||||||
|
assert len(lines) == 3
|
||||||
|
|
||||||
|
def test_run_background(self):
|
||||||
|
handle = self.capsule.commands.run("sleep 30", background=True, tag="bg-test")
|
||||||
|
assert isinstance(handle, CommandHandle)
|
||||||
|
assert handle.pid > 0
|
||||||
|
assert handle.tag == "bg-test"
|
||||||
|
assert handle.capsule_id == self.capsule.capsule_id
|
||||||
|
|
||||||
|
self.capsule.commands.kill(handle.pid)
|
||||||
|
|
||||||
|
def test_list_processes(self):
|
||||||
|
handle = self.capsule.commands.run("sleep 30", background=True, tag="list-test")
|
||||||
|
try:
|
||||||
|
time.sleep(0.5)
|
||||||
|
processes = self.capsule.commands.list()
|
||||||
|
assert isinstance(processes, list)
|
||||||
|
pids = [p.pid for p in processes]
|
||||||
|
assert handle.pid in pids
|
||||||
|
|
||||||
|
proc = next(p for p in processes if p.pid == handle.pid)
|
||||||
|
assert isinstance(proc, ProcessInfo)
|
||||||
|
finally:
|
||||||
|
self.capsule.commands.kill(handle.pid)
|
||||||
|
|
||||||
|
def test_kill_process(self):
|
||||||
|
handle = self.capsule.commands.run("sleep 30", background=True)
|
||||||
|
self.capsule.commands.kill(handle.pid)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
processes = self.capsule.commands.list()
|
||||||
|
pids = [p.pid for p in processes]
|
||||||
|
assert handle.pid not in pids
|
||||||
|
|
||||||
|
def test_run_duration_ms(self):
|
||||||
|
result = self.capsule.commands.run("sleep 1")
|
||||||
|
assert result.duration_ms is None or result.duration_ms >= 900
|
||||||
95
tests/integration/test_files.py
Normal file
95
tests/integration/test_files.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wrenn import Capsule
|
||||||
|
from wrenn.models import FileEntry
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
class TestFiles:
|
||||||
|
"""Shared capsule for filesystem tests."""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_write_and_read(self):
|
||||||
|
self.capsule.files.write("/tmp/test.txt", "hello world")
|
||||||
|
content = self.capsule.files.read("/tmp/test.txt")
|
||||||
|
assert content == "hello world"
|
||||||
|
|
||||||
|
def test_write_and_read_bytes(self):
|
||||||
|
data = b"\x00\x01\x02\xff"
|
||||||
|
self.capsule.files.write("/tmp/test.bin", data)
|
||||||
|
result = self.capsule.files.read_bytes("/tmp/test.bin")
|
||||||
|
assert result == data
|
||||||
|
|
||||||
|
def test_list_directory(self):
|
||||||
|
self.capsule.files.write("/tmp/listdir/a.txt", "a")
|
||||||
|
self.capsule.files.write("/tmp/listdir/b.txt", "b")
|
||||||
|
entries = self.capsule.files.list("/tmp/listdir")
|
||||||
|
assert isinstance(entries, list)
|
||||||
|
names = [e.name for e in entries]
|
||||||
|
assert "a.txt" in names
|
||||||
|
assert "b.txt" in names
|
||||||
|
|
||||||
|
def test_exists(self):
|
||||||
|
self.capsule.files.write("/tmp/exists_test.txt", "x")
|
||||||
|
assert self.capsule.files.exists("/tmp/exists_test.txt")
|
||||||
|
assert not self.capsule.files.exists("/tmp/does_not_exist_xyz.txt")
|
||||||
|
|
||||||
|
def test_make_dir(self):
|
||||||
|
entry = self.capsule.files.make_dir("/tmp/newdir")
|
||||||
|
assert isinstance(entry, FileEntry)
|
||||||
|
assert self.capsule.files.exists("/tmp/newdir")
|
||||||
|
|
||||||
|
def test_make_dir_idempotent(self):
|
||||||
|
self.capsule.files.make_dir("/tmp/idempotent_dir")
|
||||||
|
entry = self.capsule.files.make_dir("/tmp/idempotent_dir")
|
||||||
|
assert isinstance(entry, FileEntry)
|
||||||
|
|
||||||
|
def test_remove_file(self):
|
||||||
|
self.capsule.files.write("/tmp/to_remove.txt", "delete me")
|
||||||
|
assert self.capsule.files.exists("/tmp/to_remove.txt")
|
||||||
|
self.capsule.files.remove("/tmp/to_remove.txt")
|
||||||
|
assert not self.capsule.files.exists("/tmp/to_remove.txt")
|
||||||
|
|
||||||
|
def test_remove_directory(self):
|
||||||
|
self.capsule.files.make_dir("/tmp/dir_to_remove")
|
||||||
|
self.capsule.files.write("/tmp/dir_to_remove/child.txt", "data")
|
||||||
|
self.capsule.files.remove("/tmp/dir_to_remove")
|
||||||
|
assert not self.capsule.files.exists("/tmp/dir_to_remove")
|
||||||
|
|
||||||
|
def test_write_creates_parent_dirs(self):
|
||||||
|
self.capsule.files.write("/tmp/deep/nested/dir/file.txt", "nested")
|
||||||
|
content = self.capsule.files.read("/tmp/deep/nested/dir/file.txt")
|
||||||
|
assert content == "nested"
|
||||||
|
|
||||||
|
def test_list_with_depth(self):
|
||||||
|
self.capsule.files.write("/tmp/depth_test/a/b.txt", "deep")
|
||||||
|
entries_shallow = self.capsule.files.list("/tmp/depth_test", depth=1)
|
||||||
|
entries_deep = self.capsule.files.list("/tmp/depth_test", depth=2)
|
||||||
|
assert len(entries_deep) >= len(entries_shallow)
|
||||||
|
|
||||||
|
def test_overwrite_file(self):
|
||||||
|
self.capsule.files.write("/tmp/overwrite.txt", "original")
|
||||||
|
self.capsule.files.write("/tmp/overwrite.txt", "updated")
|
||||||
|
content = self.capsule.files.read("/tmp/overwrite.txt")
|
||||||
|
assert content == "updated"
|
||||||
|
|
||||||
|
def test_upload_and_download_stream(self):
|
||||||
|
chunks = [b"chunk1", b"chunk2", b"chunk3"]
|
||||||
|
self.capsule.files.upload_stream("/tmp/streamed.bin", iter(chunks))
|
||||||
|
downloaded = b"".join(self.capsule.files.download_stream("/tmp/streamed.bin"))
|
||||||
|
assert downloaded == b"chunk1chunk2chunk3"
|
||||||
94
tests/integration/test_git.py
Normal file
94
tests/integration/test_git.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wrenn import Capsule
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
class TestGit:
|
||||||
|
"""Shared capsule for git operation tests.
|
||||||
|
|
||||||
|
Initializes a repo at /root (default cwd) since the exec API
|
||||||
|
does not support the cwd parameter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
capsule: Capsule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_class(cls):
|
||||||
|
cls.capsule = Capsule(wait=True)
|
||||||
|
cls.capsule.git.init(".", initial_branch="main")
|
||||||
|
cls.capsule.git.configure_user("Test User", "test@example.com")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown_class(cls):
|
||||||
|
try:
|
||||||
|
cls.capsule.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_init_created_repo(self):
|
||||||
|
assert self.capsule.files.exists("/root/.git")
|
||||||
|
|
||||||
|
def test_status_clean(self):
|
||||||
|
status = self.capsule.git.status()
|
||||||
|
assert status.branch == "main"
|
||||||
|
|
||||||
|
def test_add_and_commit(self):
|
||||||
|
self.capsule.files.write("/root/hello.txt", "hello git")
|
||||||
|
self.capsule.git.add(all=True)
|
||||||
|
result = self.capsule.git.commit("initial commit")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_status_after_commit(self):
|
||||||
|
status = self.capsule.git.status()
|
||||||
|
assert status.is_clean
|
||||||
|
|
||||||
|
def test_status_with_changes(self):
|
||||||
|
self.capsule.files.write("/root/dirty.txt", "uncommitted")
|
||||||
|
try:
|
||||||
|
status = self.capsule.git.status()
|
||||||
|
assert not status.is_clean
|
||||||
|
paths = [f.path for f in status.files]
|
||||||
|
assert "dirty.txt" in paths
|
||||||
|
finally:
|
||||||
|
self.capsule.files.remove("/root/dirty.txt")
|
||||||
|
|
||||||
|
def test_branches(self):
|
||||||
|
branches = self.capsule.git.branches()
|
||||||
|
assert len(branches) >= 1
|
||||||
|
names = [b.name for b in branches]
|
||||||
|
assert "main" in names
|
||||||
|
current = [b for b in branches if b.is_current]
|
||||||
|
assert len(current) == 1
|
||||||
|
|
||||||
|
def test_create_and_checkout_branch(self):
|
||||||
|
self.capsule.git.create_branch("feature-1")
|
||||||
|
branches = self.capsule.git.branches()
|
||||||
|
names = [b.name for b in branches]
|
||||||
|
assert "feature-1" in names
|
||||||
|
|
||||||
|
current = [b for b in branches if b.is_current]
|
||||||
|
assert current[0].name == "feature-1"
|
||||||
|
|
||||||
|
self.capsule.git.checkout_branch("main")
|
||||||
|
|
||||||
|
def test_delete_branch(self):
|
||||||
|
self.capsule.git.create_branch("to-delete")
|
||||||
|
self.capsule.git.checkout_branch("main")
|
||||||
|
self.capsule.git.delete_branch("to-delete")
|
||||||
|
|
||||||
|
branches = self.capsule.git.branches()
|
||||||
|
names = [b.name for b in branches]
|
||||||
|
assert "to-delete" not in names
|
||||||
|
|
||||||
|
def test_set_and_get_config(self):
|
||||||
|
self.capsule.git.set_config("test.key", "test-value")
|
||||||
|
value = self.capsule.git.get_config("test.key")
|
||||||
|
assert value == "test-value"
|
||||||
|
|
||||||
|
def test_get_config_missing_returns_none(self):
|
||||||
|
value = self.capsule.git.get_config("nonexistent.key")
|
||||||
|
assert value is None
|
||||||
119
tests/integration/test_lifecycle.py
Normal file
119
tests/integration/test_lifecycle.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wrenn import Capsule
|
||||||
|
from wrenn.models import Capsule as CapsuleModel, Status
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapsuleLifecycle:
|
||||||
|
"""Each test manages its own capsule to test create/destroy paths."""
|
||||||
|
|
||||||
|
def test_create_and_destroy(self):
|
||||||
|
capsule = Capsule()
|
||||||
|
capsule_id = capsule.capsule_id
|
||||||
|
try:
|
||||||
|
assert capsule_id
|
||||||
|
assert capsule.info is not None
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
info = Capsule.get_info(capsule_id)
|
||||||
|
assert info.status in (Status.stopped, Status.missing)
|
||||||
|
|
||||||
|
def test_create_with_wait(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
assert capsule.info is not None
|
||||||
|
assert capsule.info.status == Status.running
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_context_manager_destroys(self):
|
||||||
|
with Capsule(wait=True) as capsule:
|
||||||
|
capsule_id = capsule.capsule_id
|
||||||
|
assert capsule.is_running()
|
||||||
|
|
||||||
|
info = Capsule.get_info(capsule_id)
|
||||||
|
assert info.status in (Status.stopped, Status.missing)
|
||||||
|
|
||||||
|
def test_get_info(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
info = capsule.get_info()
|
||||||
|
assert isinstance(info, CapsuleModel)
|
||||||
|
assert info.id == capsule.capsule_id
|
||||||
|
assert info.status == Status.running
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_pause_and_resume(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
paused = capsule.pause()
|
||||||
|
assert paused.status == Status.paused
|
||||||
|
assert not capsule.is_running()
|
||||||
|
|
||||||
|
resumed = capsule.resume()
|
||||||
|
assert resumed.status == Status.running
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_static_destroy(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
capsule_id = capsule.capsule_id
|
||||||
|
try:
|
||||||
|
Capsule.destroy(capsule_id)
|
||||||
|
except Exception:
|
||||||
|
capsule.destroy()
|
||||||
|
raise
|
||||||
|
|
||||||
|
info = Capsule.get_info(capsule_id)
|
||||||
|
assert info.status in (Status.stopped, Status.missing)
|
||||||
|
|
||||||
|
def test_connect_to_existing(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
connected = Capsule.connect(capsule.capsule_id)
|
||||||
|
assert connected.capsule_id == capsule.capsule_id
|
||||||
|
assert connected.info is not None
|
||||||
|
assert connected.info.status == Status.running
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_connect_resumes_paused(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
capsule.pause()
|
||||||
|
connected = Capsule.connect(capsule.capsule_id)
|
||||||
|
assert connected.info is not None
|
||||||
|
assert connected.info.status == Status.running
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_list_capsules(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
capsules = Capsule.list()
|
||||||
|
assert isinstance(capsules, list)
|
||||||
|
ids = [c.id for c in capsules]
|
||||||
|
assert capsule.capsule_id in ids
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_wait_ready(self):
|
||||||
|
capsule = Capsule()
|
||||||
|
try:
|
||||||
|
capsule.wait_ready(timeout=60)
|
||||||
|
assert capsule.is_running()
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
|
|
||||||
|
def test_ping(self):
|
||||||
|
capsule = Capsule(wait=True)
|
||||||
|
try:
|
||||||
|
capsule.ping()
|
||||||
|
finally:
|
||||||
|
capsule.destroy()
|
||||||
197
tests/test_capsule_features.py
Normal file
197
tests/test_capsule_features.py
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
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)
|
||||||
263
tests/test_client.py
Normal file
263
tests/test_client.py
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from wrenn.client import AsyncWrennClient, WrennClient
|
||||||
|
from wrenn.exceptions import (
|
||||||
|
WrennAgentError,
|
||||||
|
WrennAuthenticationError,
|
||||||
|
WrennConflictError,
|
||||||
|
WrennInternalError,
|
||||||
|
WrennNotFoundError,
|
||||||
|
WrennValidationError,
|
||||||
|
)
|
||||||
|
from wrenn.models import (
|
||||||
|
Capsule,
|
||||||
|
Status,
|
||||||
|
Template,
|
||||||
|
)
|
||||||
|
|
||||||
|
BASE = "https://app.wrenn.dev/api"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
with WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def async_client():
|
||||||
|
return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapsules:
|
||||||
|
@respx.mock
|
||||||
|
def test_create(self, client):
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
201,
|
||||||
|
json={
|
||||||
|
"id": "sb-1",
|
||||||
|
"status": "pending",
|
||||||
|
"template": "base-python",
|
||||||
|
"vcpus": 2,
|
||||||
|
"memory_mb": 1024,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024)
|
||||||
|
assert isinstance(resp, Capsule)
|
||||||
|
assert resp.id == "sb-1"
|
||||||
|
assert resp.status == Status.pending
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_create_defaults(self, client):
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
201, json={"id": "sb-2", "status": "pending"}
|
||||||
|
)
|
||||||
|
resp = client.capsules.create()
|
||||||
|
assert resp.id == "sb-2"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_list(self, client):
|
||||||
|
respx.get(f"{BASE}/v1/capsules").respond(
|
||||||
|
200, json=[{"id": "sb-1", "status": "running"}]
|
||||||
|
)
|
||||||
|
boxes = client.capsules.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(
|
||||||
|
200, json={"id": "sb-1", "status": "running"}
|
||||||
|
)
|
||||||
|
resp = client.capsules.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")
|
||||||
|
assert route.called
|
||||||
|
|
||||||
|
|
||||||
|
class TestSnapshots:
|
||||||
|
@respx.mock
|
||||||
|
def test_create(self, client):
|
||||||
|
respx.post(f"{BASE}/v1/snapshots").respond(
|
||||||
|
201,
|
||||||
|
json={"name": "snap-1", "type": "snapshot", "vcpus": 1},
|
||||||
|
)
|
||||||
|
resp = client.snapshots.create(capsule_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(
|
||||||
|
201, json={"name": "snap-1", "type": "snapshot"}
|
||||||
|
)
|
||||||
|
client.snapshots.create(capsule_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(
|
||||||
|
200, json=[{"name": "base-python", "type": "base"}]
|
||||||
|
)
|
||||||
|
snaps = client.snapshots.list()
|
||||||
|
assert len(snaps) == 1
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_list_with_filter(self, client):
|
||||||
|
route = respx.get(f"{BASE}/v1/snapshots").respond(200, json=[])
|
||||||
|
client.snapshots.list(type="snapshot")
|
||||||
|
req = route.calls[0].request
|
||||||
|
assert "type=snapshot" in str(req.url)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_delete(self, client):
|
||||||
|
route = respx.delete(f"{BASE}/v1/snapshots/snap-1").respond(204)
|
||||||
|
client.snapshots.delete("snap-1")
|
||||||
|
assert route.called
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorHandling:
|
||||||
|
@respx.mock
|
||||||
|
def test_validation_error(self, client):
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
400,
|
||||||
|
json={"error": {"code": "invalid_request", "message": "bad input"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennValidationError) as exc_info:
|
||||||
|
client.capsules.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(
|
||||||
|
401,
|
||||||
|
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennAuthenticationError):
|
||||||
|
client.capsules.list()
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_not_found_error(self, client):
|
||||||
|
respx.get(f"{BASE}/v1/capsules/nope").respond(
|
||||||
|
404,
|
||||||
|
json={"error": {"code": "not_found", "message": "capsule not found"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennNotFoundError):
|
||||||
|
client.capsules.get("nope")
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_conflict_error(self, client):
|
||||||
|
respx.get(f"{BASE}/v1/capsules/sb-1").respond(
|
||||||
|
409,
|
||||||
|
json={"error": {"code": "invalid_state", "message": "not running"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennConflictError):
|
||||||
|
client.capsules.get("sb-1")
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_agent_error(self, client):
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
502,
|
||||||
|
json={"error": {"code": "agent_error", "message": "host agent failed"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennAgentError):
|
||||||
|
client.capsules.create()
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_internal_error(self, client):
|
||||||
|
respx.get(f"{BASE}/v1/capsules/sb-1").respond(
|
||||||
|
500,
|
||||||
|
json={"error": {"code": "internal_error", "message": "oops"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennInternalError):
|
||||||
|
client.capsules.get("sb-1")
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_unknown_error_code_falls_back(self, client):
|
||||||
|
respx.get(f"{BASE}/v1/capsules/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")
|
||||||
|
assert exc_info.value.code == "teapot"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthModes:
|
||||||
|
def test_api_key_header(self):
|
||||||
|
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"):
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncClient:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_capsules_create(self, async_client):
|
||||||
|
async with async_client:
|
||||||
|
respx.post(f"{BASE}/v1/capsules").respond(
|
||||||
|
201, json={"id": "sb-1", "status": "pending"}
|
||||||
|
)
|
||||||
|
resp = await async_client.capsules.create(template="base-python")
|
||||||
|
assert resp.id == "sb-1"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_async_capsules_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()
|
||||||
|
assert len(boxes) == 1
|
||||||
|
|
||||||
|
@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(
|
||||||
|
404,
|
||||||
|
json={"error": {"code": "not_found", "message": "not found"}},
|
||||||
|
)
|
||||||
|
with pytest.raises(WrennNotFoundError):
|
||||||
|
await async_client.capsules.get("nope")
|
||||||
491
tests/test_filesystem_pty.py
Normal file
491
tests/test_filesystem_pty.py
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
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) == 2
|
||||||
|
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"
|
||||||
|
|
||||||
|
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) == 2
|
||||||
|
assert events[0].type == PtyEventType.started
|
||||||
|
assert session.tag == "pty-xyz"
|
||||||
|
assert session.pid == 5
|
||||||
|
|
||||||
|
|
||||||
|
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
Normal file
1137
tests/test_git.py
Normal file
File diff suppressed because it is too large
Load Diff
446
uv.lock
generated
446
uv.lock
generated
@ -1,5 +1,5 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 2
|
revision = 3
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.14'",
|
"python_full_version >= '3.14'",
|
||||||
@ -72,6 +72,63 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "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]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.3.2"
|
version = "8.3.2"
|
||||||
@ -93,6 +150,46 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "datamodel-code-generator"
|
name = "datamodel-code-generator"
|
||||||
version = "0.56.0"
|
version = "0.56.0"
|
||||||
@ -112,6 +209,79 @@ 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" },
|
{ 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 = "dnspython"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
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"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "dnspython" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "genson"
|
name = "genson"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@ -158,6 +328,21 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx-ws"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "wsproto" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/cd/ca91a07ae446451f7476bf3fcc909e98cb942ff032ebfda0e3fe449aca7b/httpx_ws-0.9.0.tar.gz", hash = "sha256:797373326f70eec1ae96f6e43ae9f12002fd7d73aee139a4985eaab964338a08", size = 107105, upload-time = "2026-03-28T14:11:10.781Z" }
|
||||||
|
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]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.11"
|
version = "3.11"
|
||||||
@ -363,6 +548,37 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "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]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.0"
|
version = "26.0"
|
||||||
@ -467,6 +683,31 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.20.0"
|
version = "2.20.0"
|
||||||
@ -564,6 +805,33 @@ 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" },
|
{ 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"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "httpx" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.10"
|
version = "0.15.10"
|
||||||
@ -589,6 +857,63 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "typeguard"
|
name = "typeguard"
|
||||||
version = "4.5.1"
|
version = "4.5.1"
|
||||||
@ -622,35 +947,150 @@ 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" },
|
{ 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 = "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]]
|
[[package]]
|
||||||
name = "wrenn"
|
name = "wrenn"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "email-validator" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "httpx-ws" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "datamodel-code-generator" },
|
{ name = "datamodel-code-generator", extra = ["ruff"] },
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
|
{ name = "pydoc-markdown" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "respx" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "email-validator", specifier = ">=2.3.0" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
|
{ name = "httpx-ws", specifier = ">=0.9.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.5" },
|
{ name = "pydantic", specifier = ">=2.12.5" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "datamodel-code-generator", specifier = ">=0.56.0" },
|
{ name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" },
|
||||||
{ name = "mypy", specifier = ">=1.20.0" },
|
{ name = "mypy", specifier = ">=1.20.0" },
|
||||||
|
{ name = "pydoc-markdown", specifier = ">=4.8.2" },
|
||||||
{ name = "pytest", specifier = ">=9.0.3" },
|
{ name = "pytest", specifier = ">=9.0.3" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||||
|
{ name = "respx", specifier = ">=0.23.1" },
|
||||||
{ name = "ruff", specifier = ">=0.15.10" },
|
{ name = "ruff", specifier = ">=0.15.10" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wsproto"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" }
|
||||||
|
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