diff --git a/README.md b/README.md index 2c39d93..3c4593f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,370 @@ -# python-sdk +# Wrenn Python SDK -Python SDK for wrenn \ No newline at end of file +Python client for the [Wrenn](https://wrenn.dev) microVM code execution platform. Create isolated capsules, execute commands, manage files, run interactive terminals, and execute persistent code — all from Python. + +## Installation + +```bash +pip install wrenn +``` + +Requires Python 3.13+. + +## Quick Start + +```python +from wrenn import WrennClient + +client = WrennClient(api_key="wrn_your_api_key_here") + +# Create a capsule and run a command +with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60) + + result = cap.exec("echo", args=["hello world"]) + print(result.stdout) # "hello world" + print(result.exit_code) # 0 +``` + +## Authentication + +The SDK supports two authentication methods: + +```python +# API key +client = WrennClient(api_key="wrn_...") + +# JWT token +client = WrennClient(token="eyJ...") +``` + +You can obtain an API key via the dashboard or create one programmatically: + +```python +with WrennClient(token="jwt_token") as client: + key = client.api_keys.create(name="my-key") + print(key.key) # wrn_... +``` + +## Capsules + +Capsules are isolated microVM environments. Create, manage, and interact with them: + +```python +# Create +cap = client.capsules.create( + template="base-python", + vcpus=2, + memory_mb=1024, + timeout_sec=300, +) + +# List +for c in client.capsules.list(): + print(c.id, c.status) + +# Get +cap = client.capsules.get("cl-abc123") + +# Destroy +client.capsules.destroy("cl-abc123") +``` + +### Context Manager + +Use capsules as context managers for automatic cleanup: + +```python +with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60) + cap.exec("python -c 'print(42)'") +# cap.destroy() is called automatically +``` + +## Command Execution + +### `exec()` — One-off Commands + +Starts a fresh process for each call. No state persists between calls. + +```python +result = cap.exec("python", args=["-c", "import os; print(os.getcwd())"]) +print(result.stdout) # "/home/user\n" +print(result.stderr) # "" +print(result.exit_code) # 0 +print(result.duration_ms) # 42 +``` + +### `exec_stream()` — Streaming Output + +Stream real-time output from long-running commands: + +```python +for event in cap.exec_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}") +``` + +### `run_code()` — Stateful Code Execution + +Execute Python code in a persistent Jupyter kernel. Variables, imports, and function definitions survive across calls: + +```python +with client.capsules.create(template="python-interpreter-v0-beta") as cap: + cap.wait_ready(timeout=60) + + cap.run_code("x = 42") + r = cap.run_code("x * 2") + print(r.text) # "84" + + cap.run_code("def greet(name): return f'hello {name}'") + r = cap.run_code("greet('world')") + print(r.text) # "'hello world'" + + r = cap.run_code("1/0") + print(r.error) # "ZeroDivisionError: division by zero\n..." +``` + +**`CodeResult` fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `text` | `str \| None` | Plain text representation | +| `data` | `dict \| None` | Rich MIME bundle (e.g. `{"image/png": "..."}`) | +| `stdout` | `str` | Accumulated stdout | +| `stderr` | `str` | Accumulated stderr | +| `error` | `str \| None` | Error traceback string | + +## Filesystem + +Upload, download, and manage files inside capsules: + +```python +# Upload / Download +cap.upload("/app/main.py", b"print('hello')") +content = cap.download("/app/main.py") + +# Streaming (for large files) +def chunks(): + yield b"chunk1" + yield b"chunk2" + +cap.stream_upload("/data/large.bin", chunks()) +for chunk in cap.stream_download("/data/large.bin"): + process(chunk) + +# Directory operations +entries = cap.list_dir("/home/user", depth=1) +for entry in entries: + print(entry.name, entry.type, entry.size) + +cap.mkdir("/home/user/data") +cap.remove("/home/user/old_data") +``` + +## Interactive Terminal (PTY) + +Open a full interactive terminal session over WebSocket: + +```python +with cap.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 +``` + +**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 (available after `started` event) | +| `pid` | Process PID (available after `started` event) | + +Reconnect to an existing session using the tag: + +```python +with cap.pty_connect(term.tag) as term: + term.write(b"echo reconnected\n") +``` + +## Lifecycle + +Pause and resume capsules to save resources: + +```python +cap = client.capsules.create(template="minimal") +cap.wait_ready(timeout=60) + +# Pause (snapshots and releases resources) +cap.pause() +print(cap.status) # "paused" + +# Resume (restores from snapshot) +cap.resume() +cap.wait_ready(timeout=60) +``` + +Keep a capsule alive with `ping()`: + +```python +cap.ping() # Resets the inactivity timer +``` + +## Proxy URL + +Access services running inside a capsule through the proxy: + +```python +url = cap.get_url(8888) +# "wss://8888-cl-abc123.api.wrenn.dev" + +# Pre-configured HTTP client targeting port 8888 +resp = cap.http_client.get("/api/kernels") +``` + +## Snapshots + +Create templates from running capsules: + +```python +# Create a snapshot +template = client.snapshots.create( + capsule_id="cl-abc123", + name="my-template", + overwrite=True, +) + +# List templates +for t in client.snapshots.list(): + print(t.name, t.type) + +# Delete +client.snapshots.delete("my-template") +``` + +## Hosts + +Manage host machines: + +```python +host = client.hosts.create(type="regular") +client.hosts.list() +client.hosts.get("h-1") +client.hosts.delete("h-1") +client.hosts.regenerate_token("h-1") +client.hosts.list_tags("h-1") +client.hosts.add_tag("h-1", "gpu") +client.hosts.remove_tag("h-1", "gpu") +``` + +## Async Support + +All operations have async variants. Use `AsyncWrennClient` and prefix capsule methods with `async_`: + +```python +from wrenn import AsyncWrennClient + +async with AsyncWrennClient(api_key="wrn_...") as client: + cap = await client.capsules.create(template="minimal") + await cap.async_wait_ready(timeout=60) + + result = await cap.async_exec("echo", args=["hello"]) + await cap.async_upload("/app/file.txt", b"data") + entries = await cap.async_list_dir("/home/user") + r = await cap.async_run_code("42 * 2") + + await cap.async_destroy() +``` + +**Async method mapping:** + +| Sync | Async | +|------|-------| +| `exec()` | `async_exec()` | +| `upload()` | `async_upload()` | +| `download()` | `async_download()` | +| `stream_upload()` | `async_stream_upload()` | +| `stream_download()` | `async_stream_download()` | +| `list_dir()` | `async_list_dir()` | +| `mkdir()` | `async_mkdir()` | +| `remove()` | `async_remove()` | +| `wait_ready()` | `async_wait_ready()` | +| `pause()` | `async_pause()` | +| `resume()` | `async_resume()` | +| `destroy()` | `async_destroy()` | +| `ping()` | `async_ping()` | +| `run_code()` | `async_run_code()` | + +## 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: + client.capsules.get("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`. + +## 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 + +# Regenerate models from OpenAPI spec +make generate +``` + +### Running Integration Tests + +Integration tests require a live Wrenn server. Set environment variables: + +```bash +export WRENN_API_KEY="wrn_..." +export WRENN_BASE_URL="http://localhost:8080" # optional +make test-integration +``` + +## License + +MIT