All checks were successful
ci/woodpecker/push/unit Pipeline was successful
Co-authored-by: Tasnim Kabir Sadik <tksadik92@gmail.com> Reviewed-on: #14 Co-authored-by: pptx704 <rafeed@omukk.dev> Co-committed-by: pptx704 <rafeed@omukk.dev>
679 lines
17 KiB
Markdown
679 lines
17 KiB
Markdown
# Wrenn Python SDK
|
|
|
|
Python client for the [Wrenn](https://wrenn.dev) microVM platform. Create isolated capsules, execute commands, manage files, run interactive terminals, and execute persistent code -- all from Python.
|
|
|
|
Designed as a drop-in replacement for [e2b](https://e2b.dev). If you're migrating, just swap your imports.
|
|
|
|
## Installation
|
|
|
|
```bash
|
|
pip install wrenn
|
|
```
|
|
|
|
Requires Python 3.13+.
|
|
|
|
## Authentication
|
|
|
|
Set the `WRENN_API_KEY` environment variable:
|
|
|
|
```bash
|
|
export WRENN_API_KEY="wrn_your_api_key_here"
|
|
```
|
|
|
|
Optionally override the API base URL:
|
|
|
|
```bash
|
|
export WRENN_BASE_URL="https://app.wrenn.dev/api" # default
|
|
```
|
|
|
|
For self-hosted deployments you can also override the capsule proxy domain
|
|
(used to build `{port}-{capsule_id}.<domain>` URLs returned by
|
|
`Capsule.get_url`):
|
|
|
|
```bash
|
|
export WRENN_PROXY_DOMAIN="wrenn.example.com"
|
|
```
|
|
|
|
Resolution order: explicit `proxy_domain=` kwarg → `WRENN_PROXY_DOMAIN` env →
|
|
`wrenn.dev` when `base_url` is the default `app.wrenn.dev` host, else the
|
|
`base_url` host (with port) verbatim.
|
|
|
|
You can also pass credentials directly:
|
|
|
|
```python
|
|
from wrenn import WrennClient, Capsule
|
|
|
|
# WrennClient also accepts a timeout (httpx.Timeout or float seconds).
|
|
# Default: 30s read/write/pool, 10s connect.
|
|
client = WrennClient(
|
|
api_key="wrn_...",
|
|
base_url="https://...",
|
|
proxy_domain="wrenn.example.com", # optional override
|
|
timeout=30.0, # optional override
|
|
)
|
|
|
|
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 runner capsules:
|
|
|
|
```python
|
|
from wrenn.code_runner 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 "start":
|
|
print(f"PID: {event.pid}")
|
|
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):
|
|
match event.type:
|
|
case "start":
|
|
print(f"PID: {event.pid}")
|
|
case "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)
|
|
# FileEntry has: name, type (file/dir), size, modified_at
|
|
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
|
|
|
|
# Reset and restore
|
|
capsule.git.reset(mode="hard", ref="HEAD~1", cwd="/app")
|
|
capsule.git.restore(["file.txt"], staged=True, cwd="/app")
|
|
```
|
|
|
|
#### Persistent Credential Store
|
|
|
|
For workflows that need repeated authenticated operations, you can persist credentials via the git credential store:
|
|
|
|
```python
|
|
capsule.git.dangerously_authenticate(
|
|
username="user",
|
|
password="ghp_token",
|
|
host="github.com",
|
|
protocol="https",
|
|
)
|
|
```
|
|
|
|
> **Warning:** Credentials are written in plaintext inside the capsule and are accessible to any process running there. Prefer per-operation `username`/`password` on `clone`, `push`, and `pull` instead.
|
|
|
|
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=80, rows=24, 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 Runner
|
|
|
|
The `wrenn.code_runner` module provides a specialized capsule for stateful code execution via a persistent Jupyter kernel. Defaults to the `code-runner-beta` template and the `wrenn` Jupyter kernelspec.
|
|
|
|
> The legacy module path `wrenn.code_interpreter` still works but emits a `FutureWarning` on import. Use `wrenn.code_runner`.
|
|
|
|
### Quick Start
|
|
|
|
```python
|
|
from wrenn.code_runner 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_runner 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 and Kernels
|
|
|
|
By default, the `code-runner-beta` template and the `wrenn` Jupyter kernelspec are used. Override either:
|
|
|
|
```python
|
|
capsule = Capsule(
|
|
template="my-custom-jupyter-template",
|
|
kernel="python3",
|
|
wait=True,
|
|
)
|
|
result = capsule.run_code("print('running on custom template')")
|
|
```
|
|
|
|
`Capsule` reuses the first kernel matching the requested `kernel` name on the Jupyter server and creates one if none exists.
|
|
|
|
### 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 |
|
|
| `timed_out` | `bool` | ``True`` when execution was cut short by the timeout |
|
|
| `text` | `str \| None` | (property) `text/plain` of the main `execute_result` |
|
|
|
|
Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `gif`, `pdf`, `latex`, `json`, `javascript`, `plotly`, plus `extra` for unknown types. The `text` field is Jupyter's `text/plain` bundle verbatim — the Python `repr()` of the cell's last expression. So `run_code("'hi'").text` is `"'hi'"` (with quotes), and `run_code("42").text` is `"42"`. This preserves the distinction between the string `'2'` and the int `2`.
|
|
|
|
### Code Runner + Commands/Files
|
|
|
|
The code runner capsule inherits all standard capsule features:
|
|
|
|
```python
|
|
from wrenn.code_runner 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 Runner
|
|
|
|
```python
|
|
from wrenn.code_runner 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)
|
|
WrennInternalError, # 500
|
|
WrennAgentError, # 502
|
|
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(type="custom") # optional type filter
|
|
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
|