feat: rename kill to destroy, improve code interpreter, update README

- Rename Capsule.kill/AsyncCapsule.kill to destroy for frontend consistency
- Add Sandbox deprecation alias to wrenn.code_interpreter module
- run_code text falls back to stripped stdout when no expression result
- Strip quotes from string expression results (matching e2b behavior)
- _ensure_kernel reuses existing Jupyter kernels before creating new ones
- Rewrite README with complete examples for capsules and code interpreter
- Remove stale AGENTS.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 18:58:59 +06:00
parent eecf1dc65b
commit 3d0eda5c60
8 changed files with 440 additions and 294 deletions

View File

@ -1,80 +0,0 @@
# AGENTS.md
## What this repo is
Python SDK for **Wrenn** (microVM code execution platform). Communicates with the Control Plane via REST + WebSockets only — no gRPC. The `envd` and `HostAgentService` are internal to the Go backend and never reachable from this SDK.
## Build & dev commands
All commands go through `uv` and the `Makefile`. Never use raw `pip`, `venv`, or `python -m venv`.
```bash
make generate # Fetch openapi.yaml → src/wrenn/models/_generated.py
make lint # ruff check + ruff format --check on src/
make test # runs ONLY tests/test_client.py
make test-integration # runs ALL tests (unit + integration, needs live server)
make check # lint + test (test_client.py only)
```
To run all unit tests (not just test_client.py):
```bash
uv run pytest tests/test_client.py tests/test_sandbox_features.py tests/test_filesystem_pty.py -v
```
To run a single test:
```bash
uv run pytest tests/test_client.py::TestAuth::test_signup -v
```
## Code generation (CRITICAL)
Models in `src/wrenn/models/_generated.py` are generated by `datamodel-codegen` from `api/openapi.yaml`.
1. **Never edit `_generated.py`** — overwritten on next `make generate`.
2. All user-facing models must be re-exported in `src/wrenn/models/__init__.py` via `__all__`.
3. To extend a generated model with custom methods, subclass it (e.g. `Sandbox` in `sandbox.py` subclasses the generated `SandboxModel`).
## Dependency management
```bash
uv add <package> # runtime dep
uv add --dev <package> # dev dep
uv run <command> # run in managed .venv
```
## Implemented resource namespaces
Only these are currently implemented in `client.py`:
- **`client.auth`** — `signup`, `login`
- **`client.api_keys`** — `create`, `list`, `delete`
- **`client.sandboxes`** — `create`, `list`, `get`, `destroy`
- **`client.snapshots`** — `create`, `list`, `delete`
- **`client.hosts`** — `create`, `list`, `get`, `delete`, `regenerate_token`, `list_tags`, `add_tag`, `remove_tag`
Both sync and async variants exist for every resource.
## Architecture notes
- **Sync/async parity**: `WrennClient` + `AsyncWrennClient` in `client.py`, using `httpx.Client`/`httpx.AsyncClient`. Async methods on `Sandbox` are prefixed `async_` (e.g. `async_exec`, `async_upload`).
- **WebSocket library**: `httpx-ws` (not `websockets`). Used for `exec_stream`, `pty`, and `run_code`.
- **Sandbox proxy URL**: `get_url(port)` returns `ws://` or `wss://` scheme. The `http_client` property converts to `http://`/`https://` automatically.
- **`Sandbox`** (in `sandbox.py`) is the main developer-facing class — subclasses generated model, adds lifecycle methods (`exec`, `upload`, `download`, `list_dir`, `mkdir`, `remove`, `pty`, `run_code`, `wait_ready`, `pause`, `resume`, `destroy`, `ping`, `metrics`), context manager support, and proxy helpers.
- **Error handling**: `handle_response()` in `exceptions.py` maps server error `code` field to typed exceptions (not just HTTP status). All inherit from `WrennError` with `.code`, `.message`, `.status_code`.
## Testing
- **HTTP mocking**: `respx` library (not `responses` or `pytest-httpx`). Mock routes with `@respx.mock` decorator or `respx.mock` context manager.
- **Async tests**: use `@pytest.mark.asyncio` (backed by `pytest-asyncio`).
- **Integration tests**: in `test_integration.py`, require env vars `WRENN_API_KEY` or `WRENN_TOKEN` (plus optional `WRENN_BASE_URL`, `WRENN_TEST_EMAIL`, `WRENN_TEST_PASSWORD`). They are skipped via `@requires_auth` if credentials are absent.
- **Fixtures**: test fixtures create `WrennClient(api_key="wrn_test1234567890abcdef12345678")` with context manager cleanup.
## Coding conventions
- **Python 3.13+** with modern syntax (`|` unions, `list[str]` generics).
- **Strict typing** throughout. `pyright`/`mypy` available but not in CI.
- **`ruff`** is the sole linter and formatter. Do not use `black`, `isort`, or `flake8`.
- **Google-style docstrings** on all public APIs.
- **No comments** unless explicitly asked.

542
README.md
View File

@ -1,6 +1,8 @@
# Wrenn Python SDK
Python client for the [Wrenn](https://wrenn.dev) microVM code execution platform. Create isolated capsules, execute commands, manage files, run interactive terminals, and execute persistent code all from Python.
Python client for the [Wrenn](https://wrenn.dev) microVM platform. Create isolated capsules, execute commands, manage files, run interactive terminals, and execute persistent code -- all from Python.
Designed as a drop-in replacement for [e2b](https://e2b.dev). If you're migrating, just swap your imports.
## Installation
@ -10,97 +12,144 @@ pip install wrenn
Requires Python 3.13+.
## Quick Start
```python
from wrenn import WrennClient
client = WrennClient(api_key="wrn_your_api_key_here")
# Create a capsule and run a command
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60)
result = cap.exec("echo", args=["hello world"])
print(result.stdout) # "hello world"
print(result.exit_code) # 0
```
## Authentication
The SDK supports two authentication methods:
Set the `WRENN_API_KEY` environment variable:
```python
# API key
client = WrennClient(api_key="wrn_...")
# JWT token
client = WrennClient(token="eyJ...")
```bash
export WRENN_API_KEY="wrn_your_api_key_here"
```
You can obtain an API key via the dashboard or create one programmatically:
Optionally override the API base URL:
```python
with WrennClient(token="jwt_token") as client:
key = client.api_keys.create(name="my-key")
print(key.key) # wrn_...
```bash
export WRENN_BASE_URL="https://app.wrenn.dev/api" # default
```
## Capsules
Capsules are isolated microVM environments. Create, manage, and interact with them:
You can also pass credentials directly:
```python
# Create
cap = client.capsules.create(
template="base-python",
vcpus=2,
memory_mb=1024,
timeout_sec=300,
)
from wrenn import Capsule
# List
for c in client.capsules.list():
print(c.id, c.status)
capsule = Capsule(api_key="wrn_...", base_url="https://...")
```
# Get
cap = client.capsules.get("cl-abc123")
---
# Destroy
client.capsules.destroy("cl-abc123")
## Wrenn Capsules
### Quick Start
```python
from wrenn import Capsule
# Create a capsule (reads WRENN_API_KEY from env)
with Capsule(template="minimal") as capsule:
result = capsule.commands.run("echo hello")
print(result.stdout) # "hello\n"
```
### Creating Capsules
```python
from wrenn import Capsule
# Direct construction (creates immediately)
capsule = Capsule()
capsule = Capsule(template="base-python", vcpus=2, memory_mb=1024, timeout=300)
# With auto-wait (blocks until capsule is running)
capsule = Capsule(template="minimal", wait=True)
# Via factory classmethod
capsule = Capsule.create(template="minimal", wait=True)
```
### Context Manager
Use capsules as context managers for automatic cleanup:
Use capsules as context managers for automatic cleanup (destroys capsule on exit):
```python
with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60)
cap.exec("python -c 'print(42)'")
# cap.destroy() is called automatically
with Capsule(template="minimal", wait=True) as capsule:
capsule.commands.run("echo hello")
# capsule is automatically destroyed
```
## Command Execution
### Connecting to Existing Capsules
### `exec()` — One-off Commands
Starts a fresh process for each call. No state persists between calls.
Attach to a running capsule by ID. If it's paused, it will be resumed automatically:
```python
result = cap.exec("python", args=["-c", "import os; print(os.getcwd())"])
print(result.stdout) # "/home/user\n"
print(result.stderr) # ""
print(result.exit_code) # 0
print(result.duration_ms) # 42
capsule = Capsule.connect("cl-abc123")
result = capsule.commands.run("echo still running")
```
### `exec_stream()` — Streaming Output
Stream real-time output from long-running commands:
For code interpreter capsules:
```python
for event in cap.exec_stream("python", args=["-u", "train.py"]):
from wrenn.code_interpreter import Capsule as CodeCapsule
capsule = CodeCapsule.connect("cl-abc123")
result = capsule.run_code("print('reconnected')")
```
### Lifecycle Management
```python
# Instance methods
capsule.pause()
capsule.resume()
capsule.destroy()
capsule.ping() # reset inactivity timer
capsule.wait_ready() # block until running
info = capsule.get_info()
print(info.status) # "running"
print(capsule.is_running()) # True
# Static methods (no instance needed)
Capsule.destroy("cl-abc123", api_key="wrn_...")
Capsule.pause("cl-abc123")
Capsule.resume("cl-abc123")
info = Capsule.get_info("cl-abc123")
# List all capsules
capsules = Capsule.list()
```
### Command Execution
Commands are accessed via `capsule.commands`:
```python
# Foreground (blocks until complete)
result = capsule.commands.run("python -c 'print(42)'")
print(result.stdout) # "42\n"
print(result.stderr) # ""
print(result.exit_code) # 0
print(result.duration_ms) # 35
# With options
result = capsule.commands.run(
"python train.py",
timeout=120,
envs={"CUDA_VISIBLE_DEVICES": "0"},
cwd="/app",
)
# Background process
handle = capsule.commands.run("python server.py", background=True)
print(handle.pid) # 1234
print(handle.tag) # "exec-abc123"
```
#### Streaming Output
```python
import sys
# Stream a new command
for event in capsule.commands.stream("python", args=["-u", "train.py"]):
match event.type:
case "stdout":
print(event.data, end="")
@ -108,77 +157,80 @@ for event in cap.exec_stream("python", args=["-u", "train.py"]):
print(event.data, end="", file=sys.stderr)
case "exit":
print(f"\nExited with code {event.exit_code}")
# Connect to a running background process
for event in capsule.commands.connect(handle.pid):
if event.type == "stdout":
print(event.data, end="")
```
### `run_code()` — Stateful Code Execution
Execute Python code in a persistent Jupyter kernel. Variables, imports, and function definitions survive across calls:
#### Process Management
```python
with client.capsules.create(template="python-interpreter-v0-beta") as cap:
cap.wait_ready(timeout=60)
# List running processes
for proc in capsule.commands.list():
print(proc.pid, proc.cmd, proc.tag)
cap.run_code("x = 42")
r = cap.run_code("x * 2")
print(r.text) # "84"
cap.run_code("def greet(name): return f'hello {name}'")
r = cap.run_code("greet('world')")
print(r.text) # "'hello world'"
r = cap.run_code("1/0")
print(r.error) # "ZeroDivisionError: division by zero\n..."
# Kill a process
capsule.commands.kill(pid=1234)
```
**`CodeResult` fields:**
### Filesystem
| Field | Type | Description |
|-------|------|-------------|
| `text` | `str \| None` | Plain text representation |
| `data` | `dict \| None` | Rich MIME bundle (e.g. `{"image/png": "..."}`) |
| `stdout` | `str` | Accumulated stdout |
| `stderr` | `str` | Accumulated stderr |
| `error` | `str \| None` | Error traceback string |
## Filesystem
Upload, download, and manage files inside capsules:
Files are accessed via `capsule.files`:
```python
# Upload / Download
cap.upload("/app/main.py", b"print('hello')")
content = cap.download("/app/main.py")
# Write and read files
capsule.files.write("/app/main.py", "print('hello')")
content = capsule.files.read("/app/main.py") # str
raw = capsule.files.read_bytes("/app/main.py") # bytes
# Streaming (for large files)
# Check existence
capsule.files.exists("/app/main.py") # True
# List directory
entries = capsule.files.list("/home/user", depth=1)
for entry in entries:
print(entry.name, entry.type, entry.size)
# Create directory
capsule.files.make_dir("/app/data")
# Remove file or directory
capsule.files.remove("/app/old_data")
```
#### Streaming (Large Files)
```python
# Streaming upload
def chunks():
yield b"chunk1"
yield b"chunk2"
cap.stream_upload("/data/large.bin", chunks())
for chunk in cap.stream_download("/data/large.bin"):
capsule.files.upload_stream("/data/large.bin", chunks())
# Streaming download
for chunk in capsule.files.download_stream("/data/large.bin"):
process(chunk)
# Directory operations
entries = cap.list_dir("/home/user", depth=1)
for entry in entries:
print(entry.name, entry.type, entry.size)
cap.mkdir("/home/user/data")
cap.remove("/home/user/old_data")
```
## Interactive Terminal (PTY)
Open a full interactive terminal session over WebSocket:
### Interactive Terminal (PTY)
```python
with cap.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term:
import sys
with capsule.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term:
term.write(b"ls -la\n")
for event in term:
if event.type == "output":
sys.stdout.buffer.write(event.data)
elif event.type == "exit":
break
# Reconnect to an existing session
with capsule.pty_connect(term.tag) as term:
term.write(b"echo reconnected\n")
```
**PtySession methods:**
@ -188,123 +240,169 @@ with cap.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term:
| `write(data: bytes)` | Send raw bytes to stdin |
| `resize(cols, rows)` | Resize the terminal |
| `kill()` | Send SIGKILL to the process |
| `tag` | Session tag (available after `started` event) |
| `pid` | Process PID (available after `started` event) |
| `tag` | Session tag (after `started` event) |
| `pid` | Process PID (after `started` event) |
Reconnect to an existing session using the tag:
### Proxy URL
Access services running inside a capsule:
```python
with cap.pty_connect(term.tag) as term:
term.write(b"echo reconnected\n")
url = capsule.get_url(8080)
# "wss://8080-cl-abc123.app.wrenn.dev"
```
## Lifecycle
### Snapshots
Pause and resume capsules to save resources:
Create reusable templates from running capsules:
```python
cap = client.capsules.create(template="minimal")
cap.wait_ready(timeout=60)
# Pause (snapshots and releases resources)
cap.pause()
print(cap.status) # "paused"
# Resume (restores from snapshot)
cap.resume()
cap.wait_ready(timeout=60)
template = capsule.create_snapshot(name="my-template", overwrite=True)
```
Keep a capsule alive with `ping()`:
---
## Code Interpreter
The `wrenn.code_interpreter` module provides a specialized capsule for stateful code execution via a persistent Jupyter kernel.
### Quick Start
```python
cap.ping() # Resets the inactivity timer
from wrenn.code_interpreter import Capsule
with Capsule(wait=True) as capsule:
result = capsule.run_code("print('hello')")
print(result.text) # "hello"
```
## Proxy URL
### Stateful Execution
Access services running inside a capsule through the proxy:
Variables, imports, and function definitions persist across `run_code` calls:
```python
url = cap.get_url(8888)
# "wss://8888-cl-abc123.api.wrenn.dev"
from wrenn.code_interpreter import Capsule
# Pre-configured HTTP client targeting port 8888
resp = cap.http_client.get("/api/kernels")
with Capsule(wait=True) as capsule:
capsule.run_code("x = 42")
result = capsule.run_code("x * 2")
print(result.text) # "84"
capsule.run_code("import math")
result = capsule.run_code("math.pi")
print(result.text) # "3.141592653589793"
capsule.run_code("def greet(name): return f'hello {name}'")
result = capsule.run_code("greet('world')")
print(result.text) # "hello world"
```
## Snapshots
The `text` field returns the expression result when available. For `print()` calls (which produce no expression result), it falls back to the stripped stdout output.
Create templates from running capsules:
### Error Handling in Code
```python
# Create a snapshot
template = client.snapshots.create(
capsule_id="cl-abc123",
name="my-template",
overwrite=True,
)
# List templates
for t in client.snapshots.list():
print(t.name, t.type)
# Delete
client.snapshots.delete("my-template")
result = capsule.run_code("1 / 0")
print(result.error) # "ZeroDivisionError: division by zero\n..."
```
## Hosts
Manage host machines:
### Rich Output
```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")
result = capsule.run_code("""
import matplotlib.pyplot as plt
plt.plot([1, 2, 3])
plt.savefig('/tmp/plot.png')
plt.show()
""")
print(result.data) # {"image/png": "base64...", "text/plain": "..."}
```
### 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')")
```
### CodeResult Fields
| Field | Type | Description |
|-------|------|-------------|
| `text` | `str \| None` | Expression result, or stripped stdout if no expression result |
| `data` | `dict \| None` | Rich MIME bundle (e.g. `{"image/png": "..."}`) |
| `stdout` | `str` | Raw accumulated stdout output |
| `stderr` | `str` | Raw accumulated stderr output |
| `error` | `str \| None` | Error traceback string |
String expression results have quotes stripped automatically (e.g. `'hello'` becomes `hello`).
### Code Interpreter + Commands/Files
The code interpreter capsule inherits all standard capsule features:
```python
from wrenn.code_interpreter import Capsule
with Capsule(wait=True) as capsule:
# Use run_code for Jupyter execution
capsule.run_code("import pandas as pd; df = pd.DataFrame({'a': [1,2,3]})")
capsule.run_code("df.to_csv('/tmp/data.csv', index=False)")
# Use standard file operations
content = capsule.files.read("/tmp/data.csv")
print(content)
# Use standard command execution
result = capsule.commands.run("wc -l /tmp/data.csv")
print(result.stdout)
```
---
## Async Support
All operations have async variants. Use `AsyncWrennClient` and prefix capsule methods with `async_`:
All operations have async variants via `AsyncCapsule`:
### Async Capsule
```python
from wrenn import AsyncWrennClient
from wrenn import AsyncCapsule
async with AsyncWrennClient(api_key="wrn_...") as client:
cap = await client.capsules.create(template="minimal")
await cap.async_wait_ready(timeout=60)
async with await AsyncCapsule.create(template="minimal", wait=True) as capsule:
result = await capsule.commands.run("echo hello")
print(result.stdout)
result = await cap.async_exec("echo", args=["hello"])
await cap.async_upload("/app/file.txt", b"data")
entries = await cap.async_list_dir("/home/user")
r = await cap.async_run_code("42 * 2")
await capsule.files.write("/app/file.txt", "data")
entries = await capsule.files.list("/app")
await cap.async_destroy()
await capsule.pause()
await capsule.resume()
```
**Async method mapping:**
### Async Code Interpreter
| Sync | Async |
|------|-------|
| `exec()` | `async_exec()` |
| `upload()` | `async_upload()` |
| `download()` | `async_download()` |
| `stream_upload()` | `async_stream_upload()` |
| `stream_download()` | `async_stream_download()` |
| `list_dir()` | `async_list_dir()` |
| `mkdir()` | `async_mkdir()` |
| `remove()` | `async_remove()` |
| `wait_ready()` | `async_wait_ready()` |
| `pause()` | `async_pause()` |
| `resume()` | `async_resume()` |
| `destroy()` | `async_destroy()` |
| `ping()` | `async_ping()` |
| `run_code()` | `async_run_code()` |
```python
from wrenn.code_interpreter import AsyncCapsule
async with await AsyncCapsule.create(wait=True) as capsule:
result = await capsule.run_code("2 + 2")
print(result.text) # "4"
```
### Async PTY
```python
async with capsule.pty(cmd="/bin/bash") as term:
await term.write(b"ls -la\n")
async for event in term:
if event.type == "output":
sys.stdout.buffer.write(event.data)
```
---
## Error Handling
@ -318,14 +416,14 @@ from wrenn import (
WrennForbiddenError, # 403
WrennNotFoundError, # 404
WrennConflictError, # 409
WrennHostHasCapsulesError, # 409 host has running capsules
WrennHostHasCapsulesError, # 409 (host has running capsules)
WrennAgentError, # 502
WrennInternalError, # 500
WrennHostUnavailableError, # 503
)
try:
client.capsules.get("nonexistent")
Capsule.get_info("nonexistent")
except WrennNotFoundError as e:
print(e.code) # "not_found"
print(e.message) # "capsule not found"
@ -334,6 +432,67 @@ except WrennNotFoundError as e:
All exceptions inherit from `WrennError` and expose `.code`, `.message`, and `.status_code`.
---
## Migrating from e2b
Replace your imports:
```python
# Before
from e2b import Sandbox
sandbox = Sandbox()
# After
from wrenn import Capsule
capsule = Capsule()
```
For code interpreter:
```python
# Before
from e2b_code_interpreter import Sandbox
sandbox = Sandbox()
result = sandbox.run_code("print('hello')")
# After
from wrenn.code_interpreter import Capsule
capsule = Capsule()
result = capsule.run_code("print('hello')")
```
The `Sandbox` name is available as a deprecated alias in both modules:
```python
from wrenn import Sandbox # works, emits FutureWarning
from wrenn.code_interpreter import Sandbox # works, emits FutureWarning
```
---
## Low-Level Client
For direct API access, use `WrennClient` / `AsyncWrennClient`:
```python
from wrenn import WrennClient
with WrennClient(api_key="wrn_...") as client:
capsule = client.capsules.create(template="minimal")
client.capsules.pause(capsule.id)
client.capsules.resume(capsule.id)
client.capsules.ping(capsule.id)
client.capsules.destroy(capsule.id)
# Snapshots
template = client.snapshots.create(capsule_id="cl-abc", name="my-snap")
templates = client.snapshots.list()
client.snapshots.delete("my-snap")
```
---
## Development
This project uses [uv](https://docs.astral.sh/uv/) for dependency management.
@ -350,14 +509,11 @@ make test
# Run all tests (including integration)
make test-integration
# Regenerate models from OpenAPI spec
make generate
```
### Running Integration Tests
Integration tests require a live Wrenn server. Set environment variables:
Integration tests require a live Wrenn server:
```bash
export WRENN_API_KEY="wrn_..."

View File

@ -63,6 +63,7 @@ class AsyncCapsule:
memory_mb: int | None = None,
timeout: int | None = None,
*,
wait: bool = False,
api_key: str | None = None,
base_url: str | None = None,
) -> AsyncCapsule:
@ -74,11 +75,14 @@ class AsyncCapsule:
memory_mb=memory_mb,
timeout_sec=timeout,
)
return cls(
capsule = cls(
_capsule_id=info.id,
_client=client,
_info=info,
)
if wait:
await capsule.wait_ready()
return capsule
@classmethod
async def connect(
@ -103,16 +107,16 @@ class AsyncCapsule:
# ── Dual instance/static lifecycle ──────────────────────────
kill = _DualMethod("_instance_kill", "_static_kill")
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_kill(self) -> None:
async def _instance_destroy(self) -> None:
await self._client.capsules.destroy(self._id)
@classmethod
async def _static_kill(
async def _static_destroy(
cls,
capsule_id: str,
*,
@ -260,7 +264,7 @@ class AsyncCapsule:
exc_tb: object,
) -> None:
try:
await self._instance_kill()
await self._instance_destroy()
except Exception:
pass
try:

View File

@ -66,6 +66,7 @@ class Capsule:
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
@ -93,6 +94,9 @@ class Capsule:
self.commands = Commands(self._id, self._client.http)
self.files = Files(self._id, self._client.http)
if wait:
self.wait_ready()
# ── Properties ──────────────────────────────────────────────
@property
@ -113,6 +117,7 @@ class Capsule:
memory_mb: int | None = None,
timeout: int | None = None,
*,
wait: bool = False,
api_key: str | None = None,
base_url: str | None = None,
) -> Capsule:
@ -122,6 +127,7 @@ class Capsule:
vcpus=vcpus,
memory_mb=memory_mb,
timeout=timeout,
wait=wait,
api_key=api_key,
base_url=base_url,
)
@ -149,17 +155,17 @@ class Capsule:
# ── Dual instance/static lifecycle ──────────────────────────
kill = _DualMethod("_instance_kill", "_static_kill")
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_kill(self) -> None:
def _instance_destroy(self) -> None:
"""Destroy this capsule."""
self._client.capsules.destroy(self._id)
@classmethod
def _static_kill(
def _static_destroy(
cls,
capsule_id: str,
*,
@ -321,7 +327,7 @@ class Capsule:
exc_tb: object,
) -> None:
try:
self._instance_kill()
self._instance_destroy()
except Exception:
pass
try:

View File

@ -1,8 +1,26 @@
from wrenn.code_interpreter.capsule import Capsule, CodeResult
from wrenn.code_interpreter.async_capsule import AsyncCapsule
from wrenn.code_interpreter.capsule import Capsule, CodeResult
__all__ = [
"AsyncCapsule",
"Capsule",
"CodeResult",
"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}")

View File

@ -41,6 +41,7 @@ class AsyncCapsule(BaseAsyncCapsule):
memory_mb: int | None = None,
timeout: int | None = None,
*,
wait: bool = False,
api_key: str | None = None,
base_url: str | None = None,
) -> AsyncCapsule:
@ -51,11 +52,14 @@ class AsyncCapsule(BaseAsyncCapsule):
memory_mb=memory_mb,
timeout_sec=timeout,
)
return cls(
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:
@ -80,11 +84,20 @@ class AsyncCapsule(BaseAsyncCapsule):
while time.monotonic() < deadline:
try:
resp = await client.post("/api/kernels")
# Try to reuse an existing kernel
resp = await client.get("/api/kernels")
if resp.status_code < 500:
resp.raise_for_status()
self._kernel_id = resp.json()["id"]
return self._kernel_id
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,
@ -180,7 +193,13 @@ class AsyncCapsule(BaseAsyncCapsule):
result.stdout += content.get("text", "")
elif msg_type == "execute_result":
bundle = content.get("data", {})
result.text = bundle.get("text/plain")
text = bundle.get("text/plain")
if text and (
(text.startswith("'") and text.endswith("'"))
or (text.startswith('"') and text.endswith('"'))
):
text = text[1:-1]
result.text = text
result.data = bundle
elif msg_type == "error":
traceback = content.get("traceback", [])
@ -188,6 +207,9 @@ class AsyncCapsule(BaseAsyncCapsule):
elif msg_type == "status" and content.get("execution_state") == "idle":
break
if result.text is None and result.stdout:
result.text = result.stdout.strip()
return result
async def __aexit__(self, *args) -> None:

View File

@ -80,6 +80,7 @@ class Capsule(BaseCapsule):
memory_mb: int | None = None,
timeout: int | None = None,
*,
wait: bool = False,
api_key: str | None = None,
base_url: str | None = None,
) -> Capsule:
@ -88,6 +89,7 @@ class Capsule(BaseCapsule):
vcpus=vcpus,
memory_mb=memory_mb,
timeout=timeout,
wait=wait,
api_key=api_key,
base_url=base_url,
)
@ -115,11 +117,20 @@ class Capsule(BaseCapsule):
while time.monotonic() < deadline:
try:
resp = client.post("/api/kernels")
# Try to reuse an existing kernel
resp = client.get("/api/kernels")
if resp.status_code < 500:
resp.raise_for_status()
self._kernel_id = resp.json()["id"]
return self._kernel_id
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,
@ -225,7 +236,13 @@ class Capsule(BaseCapsule):
result.stdout += content.get("text", "")
elif msg_type == "execute_result":
bundle = content.get("data", {})
result.text = bundle.get("text/plain")
text = bundle.get("text/plain")
if text and (
(text.startswith("'") and text.endswith("'"))
or (text.startswith('"') and text.endswith('"'))
):
text = text[1:-1]
result.text = text
result.data = bundle
elif msg_type == "error":
traceback = content.get("traceback", [])
@ -233,6 +250,9 @@ class Capsule(BaseCapsule):
elif msg_type == "status" and content.get("execution_state") == "idle":
break
if result.text is None and result.stdout:
result.text = result.stdout.strip()
return result
def __exit__(self, *args) -> None:

View File

@ -68,9 +68,9 @@ class TestCapsuleCreate:
class TestCapsuleStaticMethods:
@respx.mock
def test_static_kill(self):
def test_static_destroy(self):
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
Capsule._static_kill("cl-1", api_key="wrn_test1234567890abcdef12345678")
Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678")
assert route.called
@respx.mock