diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 030df8d..0000000 --- a/AGENTS.md +++ /dev/null @@ -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 # runtime dep -uv add --dev # dev dep -uv run # 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. diff --git a/README.md b/README.md index 3c4593f..d7d8758 100644 --- a/README.md +++ b/README.md @@ -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_..." diff --git a/src/wrenn/async_capsule.py b/src/wrenn/async_capsule.py index e99a5b2..d4bfb4b 100644 --- a/src/wrenn/async_capsule.py +++ b/src/wrenn/async_capsule.py @@ -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: diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py index ba77e71..62eddd1 100644 --- a/src/wrenn/capsule.py +++ b/src/wrenn/capsule.py @@ -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: diff --git a/src/wrenn/code_interpreter/__init__.py b/src/wrenn/code_interpreter/__init__.py index cb08537..137dc17 100644 --- a/src/wrenn/code_interpreter/__init__.py +++ b/src/wrenn/code_interpreter/__init__.py @@ -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}") diff --git a/src/wrenn/code_interpreter/async_capsule.py b/src/wrenn/code_interpreter/async_capsule.py index 715980f..090b21c 100644 --- a/src/wrenn/code_interpreter/async_capsule.py +++ b/src/wrenn/code_interpreter/async_capsule.py @@ -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: diff --git a/src/wrenn/code_interpreter/capsule.py b/src/wrenn/code_interpreter/capsule.py index d92f1c3..e92f72a 100644 --- a/src/wrenn/code_interpreter/capsule.py +++ b/src/wrenn/code_interpreter/capsule.py @@ -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: diff --git a/tests/test_capsule_features.py b/tests/test_capsule_features.py index 136b824..54f280f 100644 --- a/tests/test_capsule_features.py +++ b/tests/test_capsule_features.py @@ -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