diff --git a/.gitignore b/.gitignore index 36b13f1..23b2ad4 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,4 @@ cython_debug/ # PyPI configuration file .pypirc +CODE_EXECUTION.md diff --git a/.woodpecker/check.yml b/.woodpecker/check.yml new file mode 100644 index 0000000..83a35d7 --- /dev/null +++ b/.woodpecker/check.yml @@ -0,0 +1,46 @@ +when: + event: push + branch: + - main + - dev + +variables: + - &python_image "ghcr.io/astral-sh/uv:python3.13-bookworm-slim" + - &uv_cache_dir "/root/.cache/uv" + +steps: + - name: restore-cache + image: woodpeckerci/plugin-cache + settings: + restore: true + cache_key: "uv-{{ checksum \"uv.lock\" }}" + mount: + - /root/.cache/uv + + - name: lint + image: *python_image + environment: + UV_CACHE_DIR: *uv_cache_dir + UV_FROZEN: 1 + commands: + - uv sync --no-install-project + - make lint + + - name: test + image: *python_image + environment: + UV_CACHE_DIR: *uv_cache_dir + UV_FROZEN: 1 + commands: + - uv sync --no-install-project + - make test + + - name: rebuild-cache + image: woodpeckerci/plugin-cache + when: + - status: [success] + settings: + rebuild: true + cache_key: "uv-{{ checksum \"uv.lock\" }}" + mount: + - /root/.cache/uv diff --git a/LICENSE b/LICENSE index 583698c..6c40f1d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,18 +1,18 @@ 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 -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 -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +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 +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 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. -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 -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 +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 +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 USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile index 6d10f3c..7720026 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ # Makefile -.PHONY: generate +.PHONY: generate lint test check test-integration # Variables -SPEC_URL = "https://git.omukk.dev/wrenn/sandbox/raw/branch/main/internal/api/openapi.yaml" +SPEC_URL = "https://git.omukk.dev/wrenn/wrenn/raw/branch/dev/internal/api/openapi.yaml" SPEC_PATH = "api/openapi.yaml" generate: @@ -21,4 +21,18 @@ generate: --use-schema-description \ --target-python-version 3.13 \ --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 diff --git a/README.md b/README.md index 2c39d93..d7d8758 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,526 @@ -# python-sdk +# Wrenn Python SDK -Python SDK for wrenn \ No newline at end of file +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) +``` + +### 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(result.text) # "hello" +``` + +### 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` field returns the expression result when available. For `print()` calls (which produce no expression result), it falls back to the stripped stdout output. + +### Error Handling in Code + +```python +result = capsule.run_code("1 / 0") +print(result.error) # "ZeroDivisionError: division by zero\n..." +``` + +### Rich Output + +```python +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 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: + +```bash +export WRENN_API_KEY="wrn_..." +export WRENN_BASE_URL="http://localhost:8080" # optional +make test-integration +``` + +## License + +MIT diff --git a/api/openapi.yaml b/api/openapi.yaml index f4c8f66..031cefd 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,6 +1,6 @@ openapi: "3.1.0" info: - title: Wrenn Sandbox API + title: Wrenn API description: MicroVM-based code execution platform API. version: "0.1.0" @@ -42,6 +42,47 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/auth/switch-team: + post: + summary: Switch active team + operationId: switchTeam + tags: [auth] + security: + - bearerAuth: [] + description: | + Re-issues a JWT scoped to a different team. The user must be a member of + the target team (verified from DB). Use the returned token for subsequent + requests to that team's resources. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [team_id] + properties: + team_id: + type: string + responses: + "200": + description: New JWT issued for the target team + content: + application/json: + schema: + $ref: "#/components/schemas/AuthResponse" + "403": + description: Not a member of this team + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Team not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/auth/login: post: summary: Log in with email and password @@ -195,50 +236,175 @@ paths: "204": description: API key deleted - /v1/sandboxes: - post: - summary: Create a sandbox - operationId: createSandbox - tags: [sandboxes] - security: - - apiKeyAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateSandboxRequest" - responses: - "201": - description: Sandbox created - content: - application/json: - schema: - $ref: "#/components/schemas/Sandbox" - "502": - description: Host agent error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - + /v1/users/search: get: - summary: List sandboxes for your team - operationId: listSandboxes - tags: [sandboxes] + summary: Search users by email prefix + operationId: searchUsers + tags: [users] security: - - apiKeyAuth: [] + - bearerAuth: [] + description: | + Returns up to 10 users whose email starts with the given prefix. + The prefix must contain "@". Intended for the add-member UI autocomplete. + parameters: + - name: email + in: query + required: true + schema: + type: string + description: Email prefix (must contain "@", e.g. "alice@") responses: "200": - description: List of sandboxes + description: Matching users content: application/json: schema: type: array items: - $ref: "#/components/schemas/Sandbox" + $ref: "#/components/schemas/UserSearchResult" + "400": + description: Prefix does not contain "@" + content: + application/json: + schema: + $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}: + /v1/teams: + get: + summary: List teams for the authenticated user + operationId: listTeams + tags: [teams] + security: + - bearerAuth: [] + responses: + "200": + description: Teams the user belongs to, each with their role + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TeamWithRole" + + post: + summary: Create a new team + operationId: createTeam + tags: [teams] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + description: 1-128 chars; A-Z a-z 0-9 space _ + responses: + "201": + description: Team created (caller is owner) + content: + application/json: + schema: + $ref: "#/components/schemas/TeamWithRole" + "400": + description: Invalid team name + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/teams/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Team ID (must match the JWT's team_id) + + get: + summary: Get team info and member list + operationId: getTeam + tags: [teams] + security: + - bearerAuth: [] + responses: + "200": + description: Team details with members + content: + application/json: + schema: + $ref: "#/components/schemas/TeamDetail" + "403": + description: JWT team does not match requested team + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Team not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + patch: + summary: Rename the team + operationId: renameTeam + tags: [teams] + security: + - bearerAuth: [] + description: Admin or owner role required (verified from DB). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + responses: + "204": + description: Renamed + "400": + description: Invalid team name + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "403": + description: Insufficient role + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + delete: + summary: Delete the team + operationId: deleteTeam + tags: [teams] + security: + - bearerAuth: [] + description: | + Owner only. Soft-deletes the team and destroys all running/paused/starting + capsulees. All DB records are preserved. The team slug is permanently reserved. + responses: + "204": + description: Team deleted + "403": + description: Caller is not the owner + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/teams/{id}/members: parameters: - name: id in: path @@ -247,36 +413,271 @@ paths: type: string get: - summary: Get sandbox details - operationId: getSandbox - tags: [sandboxes] + summary: List team members + operationId: listTeamMembers + tags: [teams] security: - - apiKeyAuth: [] + - bearerAuth: [] responses: "200": - description: Sandbox details + description: Members with roles content: application/json: schema: - $ref: "#/components/schemas/Sandbox" + type: array + items: + $ref: "#/components/schemas/TeamMember" + + post: + summary: Add a member by email + operationId: addTeamMember + tags: [teams] + security: + - bearerAuth: [] + description: Admin or owner role required. User is added instantly as a member. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: + type: string + format: email + responses: + "201": + description: Member added + content: + application/json: + schema: + $ref: "#/components/schemas/TeamMember" + "403": + description: Insufficient role + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "404": - description: Sandbox not found + description: No account with that email + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "400": + description: User is already a member + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/teams/{id}/members/{uid}: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: uid + in: path + required: true + schema: + type: string + description: Target user ID + + patch: + summary: Update member role + operationId: updateMemberRole + tags: [teams] + security: + - bearerAuth: [] + description: | + Admin or owner required. Valid target roles: admin, member. + The owner's role cannot be changed. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [role] + properties: + role: + type: string + enum: [admin, member] + responses: + "204": + description: Role updated + "403": + description: Insufficient role or attempt to modify owner + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: User is not a member content: application/json: schema: $ref: "#/components/schemas/Error" delete: - summary: Destroy a sandbox - operationId: destroySandbox - tags: [sandboxes] + summary: Remove a member + operationId: removeTeamMember + tags: [teams] + security: + - bearerAuth: [] + description: Admin or owner required. Owner cannot be removed. + responses: + "204": + description: Member removed + "403": + description: Insufficient role or attempt to remove owner + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: User is not a member + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/teams/{id}/leave: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Leave the team + operationId: leaveTeam + tags: [teams] + security: + - bearerAuth: [] + description: The owner cannot leave; they must delete the team instead. + responses: + "204": + description: Left the team + "403": + description: Owner cannot leave + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/capsules: + post: + summary: Create a capsule + operationId: createCapsule + tags: [capsules] + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateCapsuleRequest" + responses: + "201": + description: Capsule created + content: + application/json: + schema: + $ref: "#/components/schemas/Capsule" + "502": + description: Host agent error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + get: + summary: List capsulees for your team + operationId: listCapsules + tags: [capsules] + security: + - apiKeyAuth: [] + responses: + "200": + description: List of capsulees + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Capsule" + + /v1/capsules/stats: + get: + summary: Get capsule usage stats for your team + operationId: getCapsuleStats + tags: [capsules] + security: + - apiKeyAuth: [] + parameters: + - name: range + in: query + required: false + schema: + type: string + enum: [5m, 1h, 6h, 24h, 30d] + default: 1h + description: Time window for the time-series data. + responses: + "200": + description: Capsule stats for the team + content: + application/json: + schema: + $ref: "#/components/schemas/CapsuleStats" + "400": + $ref: "#/components/responses/BadRequest" + + /v1/capsules/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: Get capsule details + operationId: getCapsule + tags: [capsules] + security: + - apiKeyAuth: [] + responses: + "200": + description: Capsule details + content: + application/json: + schema: + $ref: "#/components/schemas/Capsule" + "404": + description: Capsule not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + delete: + summary: Destroy a capsule + operationId: destroyCapsule + tags: [capsules] security: - apiKeyAuth: [] responses: "204": - description: Sandbox destroyed + description: Capsule destroyed - /v1/sandboxes/{id}/exec: + /v1/capsules/{id}/exec: parameters: - name: id in: path @@ -287,7 +688,7 @@ paths: post: summary: Execute a command operationId: execCommand - tags: [sandboxes] + tags: [capsules] security: - apiKeyAuth: [] requestBody: @@ -298,25 +699,147 @@ paths: $ref: "#/components/schemas/ExecRequest" responses: "200": - description: Command output + description: Command output (foreground exec) content: application/json: schema: $ref: "#/components/schemas/ExecResponse" + "202": + description: Background process started + content: + application/json: + schema: + $ref: "#/components/schemas/BackgroundExecResponse" "404": - description: Sandbox not found + description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/ping: + /v1/capsules/{id}/processes: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: List running processes + operationId: listProcesses + tags: [capsules] + security: + - apiKeyAuth: [] + description: | + Returns all running processes inside the capsule, including background + processes and any processes started by templates or init scripts. + responses: + "200": + description: Process list + content: + application/json: + schema: + $ref: "#/components/schemas/ProcessListResponse" + "404": + description: Capsule not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Capsule not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/capsules/{id}/processes/{selector}: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: selector + in: path + required: true + description: Process PID (numeric) or tag (string) + schema: + type: string + + delete: + summary: Kill a process + operationId: killProcess + tags: [capsules] + security: + - apiKeyAuth: [] + parameters: + - name: signal + in: query + required: false + description: Signal to send (SIGKILL or SIGTERM, default SIGKILL) + schema: + type: string + enum: [SIGKILL, SIGTERM] + default: SIGKILL + responses: + "204": + description: Process killed + "404": + description: Capsule or process not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Capsule not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/capsules/{id}/processes/{selector}/stream: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: selector + in: path + required: true + description: Process PID (numeric) or tag (string) + schema: + type: string + + get: + summary: Stream process output via WebSocket + operationId: connectProcess + tags: [capsules] + security: + - apiKeyAuth: [] + description: | + Opens a WebSocket connection to stream stdout/stderr from a running + background process. The selector can be a numeric PID or a string tag. + + Server sends JSON messages: + - `{"type": "start", "pid": 42}` — connected to process + - `{"type": "stdout", "data": "..."}` — stdout output + - `{"type": "stderr", "data": "..."}` — stderr output + - `{"type": "exit", "exit_code": 0}` — process exited + - `{"type": "error", "data": "..."}` — error message + responses: + "101": + description: WebSocket upgrade + + /v1/capsules/{id}/ping: parameters: - name: id in: path @@ -325,32 +848,86 @@ paths: type: string post: - summary: Reset sandbox inactivity timer - operationId: pingSandbox - tags: [sandboxes] + summary: Reset capsule inactivity timer + operationId: pingCapsule + tags: [capsules] security: - apiKeyAuth: [] description: | - Resets the last_active_at timestamp for a running sandbox, preventing - the auto-pause TTL from expiring. Use this as a keepalive for sandboxes + Resets the last_active_at timestamp for a running capsule, preventing + the auto-pause TTL from expiring. Use this as a keepalive for capsulees that are idle but should remain running. responses: "204": description: Ping acknowledged, inactivity timer reset "404": - description: Sandbox not found + description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/pause: + /v1/capsules/{id}/metrics: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: Get per-capsule resource metrics + operationId: getCapsuleMetrics + tags: [capsules] + security: + - apiKeyAuth: [] + - bearerAuth: [] + description: | + Returns time-series CPU, memory, and disk metrics for a capsule. + Three tiers are available with different granularity and retention: + - `10m`: 500ms samples, last 10 minutes + - `2h`: 30-second averages, last 2 hours + - `24h`: 5-minute averages, last 24 hours + + For running capsulees, data comes from the host agent's in-memory + ring buffer. For paused capsulees, data is read from persisted + snapshots in the database. Stopped/destroyed capsulees return 404. + parameters: + - name: range + in: query + required: false + schema: + type: string + enum: ["5m", "10m", "1h", "2h", "6h", "12h", "24h"] + default: "10m" + description: Time range filter to query + responses: + "200": + description: Metrics retrieved + content: + application/json: + schema: + $ref: "#/components/schemas/CapsuleMetrics" + "400": + description: Invalid range parameter + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Capsule not found or metrics not available + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/capsules/{id}/pause: parameters: - name: id in: path @@ -359,30 +936,30 @@ paths: type: string post: - summary: Pause a running sandbox - operationId: pauseSandbox - tags: [sandboxes] + summary: Pause a running capsule + operationId: pauseCapsule + tags: [capsules] security: - apiKeyAuth: [] description: | - Takes a snapshot of the sandbox (VM state + memory + rootfs), then - destroys all running resources. The sandbox exists only as files on + Takes a snapshot of the capsule (VM state + memory + rootfs), then + destroys all running resources. The capsule exists only as files on disk and can be resumed later. responses: "200": - description: Sandbox paused (snapshot taken, resources released) + description: Capsule paused (snapshot taken, resources released) content: application/json: schema: - $ref: "#/components/schemas/Sandbox" + $ref: "#/components/schemas/Capsule" "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/resume: + /v1/capsules/{id}/resume: parameters: - name: id in: path @@ -391,24 +968,24 @@ paths: type: string post: - summary: Resume a paused sandbox - operationId: resumeSandbox - tags: [sandboxes] + summary: Resume a paused capsule + operationId: resumeCapsule + tags: [capsules] security: - apiKeyAuth: [] description: | - Restores a paused sandbox from its snapshot using UFFD for lazy + Restores a paused capsule from its snapshot using UFFD for lazy memory loading. Boots a fresh Firecracker process, sets up a new network slot, and waits for envd to become ready. responses: "200": - description: Sandbox resumed (new VM booted from snapshot) + description: Capsule resumed (new VM booted from snapshot) content: application/json: schema: - $ref: "#/components/schemas/Sandbox" + $ref: "#/components/schemas/Capsule" "409": - description: Sandbox not paused + description: Capsule not paused content: application/json: schema: @@ -422,9 +999,9 @@ paths: security: - apiKeyAuth: [] description: | - Pauses a running sandbox, takes a full snapshot, copies the snapshot + Pauses a running capsule, takes a full snapshot, copies the snapshot files to the images directory as a reusable template, then destroys - the sandbox. The template can be used to create new sandboxes. + the capsule. The template can be used to create new capsulees. parameters: - name: overwrite in: query @@ -447,7 +1024,7 @@ paths: schema: $ref: "#/components/schemas/Template" "409": - description: Name already exists or sandbox not running + description: Name already exists or capsule not running content: application/json: schema: @@ -502,7 +1079,7 @@ paths: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/files/write: + /v1/capsules/{id}/files/write: parameters: - name: id in: path @@ -513,7 +1090,7 @@ paths: post: summary: Upload a file operationId: uploadFile - tags: [sandboxes] + tags: [capsules] security: - apiKeyAuth: [] requestBody: @@ -526,7 +1103,7 @@ paths: properties: path: type: string - description: Absolute destination path inside the sandbox + description: Absolute destination path inside the capsule file: type: string format: binary @@ -535,7 +1112,7 @@ paths: "204": description: File uploaded "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: @@ -547,7 +1124,7 @@ paths: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/files/read: + /v1/capsules/{id}/files/read: parameters: - name: id in: path @@ -558,7 +1135,7 @@ paths: post: summary: Download a file operationId: downloadFile - tags: [sandboxes] + tags: [capsules] security: - apiKeyAuth: [] requestBody: @@ -576,13 +1153,129 @@ paths: type: string format: binary "404": - description: Sandbox or file not found + description: Capsule or file not found content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/exec/stream: + /v1/capsules/{id}/files/list: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: List directory contents + operationId: listDir + tags: [capsules] + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListDirRequest" + responses: + "200": + description: Directory listing + content: + application/json: + schema: + $ref: "#/components/schemas/ListDirResponse" + "404": + description: Capsule not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Capsule not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/capsules/{id}/files/mkdir: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Create a directory + operationId: makeDir + tags: [capsules] + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MakeDirRequest" + responses: + "200": + description: Directory created + content: + application/json: + schema: + $ref: "#/components/schemas/MakeDirResponse" + "404": + description: Capsule not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Capsule not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/capsules/{id}/files/remove: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Remove a file or directory + operationId: removePath + tags: [capsules] + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RemoveRequest" + responses: + "204": + description: File or directory removed + "404": + description: Capsule not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Capsule not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/capsules/{id}/exec/stream: parameters: - name: id in: path @@ -593,7 +1286,7 @@ paths: get: summary: Stream command execution via WebSocket operationId: execStream - tags: [sandboxes] + tags: [capsules] security: - apiKeyAuth: [] description: | @@ -623,19 +1316,97 @@ paths: "101": description: WebSocket upgrade "404": - description: Sandbox not found + description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/files/stream/write: + /v1/capsules/{id}/pty: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: Interactive PTY session via WebSocket + operationId: ptySession + tags: [capsules] + security: + - apiKeyAuth: [] + description: | + Opens a WebSocket connection for an interactive PTY (terminal) session. + Supports creating new sessions, sending input, resizing, killing, and + reconnecting to existing sessions. + + **Client sends** (first message — start a new PTY): + ```json + { + "type": "start", + "cmd": "/bin/bash", + "args": [], + "cols": 80, + "rows": 24, + "envs": {"TERM": "xterm-256color"}, + "cwd": "/home/user", + "user": "user" + } + ``` + All fields except `type` are optional. Defaults: cmd="/bin/bash", cols=80, rows=24. + + **Client sends** (first message — reconnect to existing PTY): + ```json + {"type": "connect", "tag": "pty-abc123de"} + ``` + + **Client sends** (after session is established): + ```json + {"type": "input", "data": ""} + {"type": "resize", "cols": 120, "rows": 40} + {"type": "kill"} + ``` + + **Server sends**: + ```json + {"type": "started", "tag": "pty-abc123de", "pid": 42} + {"type": "output", "data": ""} + {"type": "exit", "exit_code": 0} + {"type": "error", "data": "description", "fatal": true} + {"type": "ping"} + ``` + + PTY data (input and output) is base64-encoded because it contains raw + terminal bytes (escape sequences, control codes) that are not valid UTF-8. + + Sessions have a 120-second inactivity timeout (reset on input/resize). + Sessions persist across WebSocket disconnections — the process keeps + running in the capsule. Use the `tag` from the "started" response to + reconnect later. + responses: + "101": + description: WebSocket upgrade + "404": + description: Capsule not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Capsule not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/capsules/{id}/files/stream/write: parameters: - name: id in: path @@ -646,11 +1417,11 @@ paths: post: summary: Upload a file (streaming) operationId: streamUploadFile - tags: [sandboxes] + tags: [capsules] security: - apiKeyAuth: [] description: | - Streams file content to the sandbox without buffering in memory. + Streams file content to the capsule without buffering in memory. Suitable for large files. Uses the same multipart/form-data format as the non-streaming upload endpoint. requestBody: @@ -663,7 +1434,7 @@ paths: properties: path: type: string - description: Absolute destination path inside the sandbox + description: Absolute destination path inside the capsule file: type: string format: binary @@ -672,19 +1443,19 @@ paths: "204": description: File uploaded "404": - description: Sandbox not found + description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/files/stream/read: + /v1/capsules/{id}/files/stream/read: parameters: - name: id in: path @@ -695,11 +1466,11 @@ paths: post: summary: Download a file (streaming) operationId: streamDownloadFile - tags: [sandboxes] + tags: [capsules] security: - apiKeyAuth: [] description: | - Streams file content from the sandbox without buffering in memory. + Streams file content from the capsule without buffering in memory. Suitable for large files. Returns raw bytes with chunked transfer encoding. requestBody: required: true @@ -716,13 +1487,13 @@ paths: type: string format: binary "404": - description: Sandbox or file not found + description: Capsule or file not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: @@ -818,8 +1589,16 @@ paths: security: - bearerAuth: [] description: | - Admins can delete any host. Team owners can delete BYOC hosts - belonging to their team. + Admins can delete any host. Team owners and admins can delete BYOC hosts + belonging to their team. Without `?force=true`, returns 409 if the host + has active capsulees. With `?force=true`, destroys all capsulees first. + parameters: + - name: force + in: query + required: false + schema: + type: boolean + description: If true, destroy all capsulees on the host before deleting. responses: "204": description: Host deleted @@ -829,6 +1608,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + "409": + description: Host has active capsulees (only when force is not set) + content: + application/json: + schema: + $ref: "#/components/schemas/HostHasCapsulesError" /v1/hosts/{id}/token: parameters: @@ -937,6 +1722,72 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/hosts/auth/refresh: + post: + summary: Refresh host JWT + operationId: refreshHostToken + tags: [hosts] + description: | + Exchanges a refresh token for a new JWT and rotated refresh token. + The old refresh token is immediately revoked. No authentication required — + the refresh token itself is the credential. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RefreshHostTokenRequest" + responses: + "200": + description: New JWT and rotated refresh token + content: + application/json: + schema: + $ref: "#/components/schemas/RefreshHostTokenResponse" + "401": + description: Invalid, expired, or revoked refresh token + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/hosts/{id}/delete-preview: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: Preview host deletion + operationId: getHostDeletePreview + tags: [hosts] + security: + - bearerAuth: [] + description: | + Returns the list of capsule IDs that would be destroyed if the host + were deleted with `?force=true`. No state is modified. + responses: + "200": + description: Deletion preview + content: + application/json: + schema: + $ref: "#/components/schemas/HostDeletePreview" + "403": + description: Insufficient permissions + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Host not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/hosts/{id}/tags: parameters: - name: id @@ -1012,13 +1863,183 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/channels: + post: + summary: Create a notification channel + operationId: createChannel + tags: [channels] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateChannelRequest" + responses: + "201": + description: Channel created + content: + application/json: + schema: + $ref: "#/components/schemas/ChannelResponse" + "400": + $ref: "#/components/responses/BadRequest" + get: + summary: List notification channels + operationId: listChannels + tags: [channels] + security: + - bearerAuth: [] + responses: + "200": + description: Channels list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ChannelResponse" + + /v1/channels/test: + post: + summary: Test a channel configuration + description: > + Sends a test notification using the provided provider and config without + saving anything. Use this to verify credentials before creating a channel. + operationId: testChannel + tags: [channels] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TestChannelRequest" + responses: + "200": + description: Test notification sent successfully + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + "400": + $ref: "#/components/responses/BadRequest" + + /v1/channels/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + get: + summary: Get a notification channel + operationId: getChannel + tags: [channels] + security: + - bearerAuth: [] + responses: + "200": + description: Channel details + content: + application/json: + schema: + $ref: "#/components/schemas/ChannelResponse" + "404": + description: Channel not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + patch: + summary: Update a notification channel + operationId: updateChannel + tags: [channels] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateChannelRequest" + responses: + "200": + description: Channel updated + content: + application/json: + schema: + $ref: "#/components/schemas/ChannelResponse" + "400": + $ref: "#/components/responses/BadRequest" + "404": + description: Channel not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + delete: + summary: Delete a notification channel + operationId: deleteChannel + tags: [channels] + security: + - bearerAuth: [] + responses: + "204": + description: Channel deleted + + /v1/channels/{id}/config: + parameters: + - name: id + in: path + required: true + schema: + type: string + put: + summary: Rotate channel secrets + description: > + Replaces the channel's provider configuration entirely with new secrets. + The previous config is discarded. Config fields must match the provider's + required fields. + operationId: rotateChannelConfig + tags: [channels] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RotateConfigRequest" + responses: + "200": + description: Config rotated + content: + application/json: + schema: + $ref: "#/components/schemas/ChannelResponse" + "400": + $ref: "#/components/responses/BadRequest" + "404": + description: Channel not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + components: securitySchemes: apiKeyAuth: type: apiKey in: header name: X-API-Key - description: API key for sandbox lifecycle operations. Create via POST /v1/api-keys. + description: API key for capsule lifecycle operations. Create via POST /v1/api-keys. bearerAuth: type: http @@ -1030,12 +2051,12 @@ components: type: apiKey in: header name: X-Host-Token - description: Long-lived host JWT returned from POST /v1/hosts/register. Valid for 1 year. + description: Host JWT returned from POST /v1/hosts/register or POST /v1/hosts/auth/refresh. Valid for 7 days. schemas: SignupRequest: type: object - required: [email, password] + required: [email, password, name] properties: email: type: string @@ -1043,6 +2064,9 @@ components: password: type: string minLength: 8 + name: + type: string + maxLength: 100 LoginRequest: type: object @@ -1066,6 +2090,8 @@ components: type: string email: type: string + name: + type: string CreateAPIKeyRequest: type: object @@ -1098,7 +2124,7 @@ components: description: Full plaintext key. Only returned on creation, never again. nullable: true - CreateSandboxRequest: + CreateCapsuleRequest: type: object properties: template: @@ -1114,18 +2140,69 @@ components: type: integer default: 0 description: > - Auto-pause TTL in seconds. The sandbox is automatically paused + Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause. - Sandbox: + CapsuleStats: + type: object + properties: + range: + type: string + enum: [5m, 1h, 6h, 24h, 30d] + current: + type: object + properties: + running_count: + type: integer + vcpus_reserved: + type: integer + memory_mb_reserved: + type: integer + sampled_at: + type: string + format: date-time + nullable: true + peaks: + type: object + description: Maximum values over the last 30 days. + properties: + running_count: + type: integer + vcpus: + type: integer + memory_mb: + type: integer + series: + type: object + description: Parallel arrays for chart rendering. + properties: + labels: + type: array + items: + type: string + format: date-time + running: + type: array + items: + type: integer + vcpus: + type: array + items: + type: integer + memory_mb: + type: array + items: + type: integer + + Capsule: type: object properties: id: type: string status: type: string - enum: [pending, running, paused, stopped, error] + enum: [pending, starting, running, paused, hibernated, stopped, missing, error] template: type: string vcpus: @@ -1159,7 +2236,7 @@ components: properties: sandbox_id: type: string - description: ID of the running sandbox to snapshot. + description: ID of the running capsule to snapshot. name: type: string description: Name for the snapshot template. Auto-generated if omitted. @@ -1198,6 +2275,56 @@ components: timeout_sec: type: integer default: 30 + description: Timeout in seconds (foreground exec only, default 30) + background: + type: boolean + default: false + description: If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202) + tag: + type: string + description: Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true. + envs: + type: object + additionalProperties: + type: string + description: Environment variables for the process (background exec only) + cwd: + type: string + description: Working directory for the process (background exec only) + + BackgroundExecResponse: + type: object + properties: + sandbox_id: + type: string + cmd: + type: string + pid: + type: integer + tag: + type: string + + ProcessEntry: + type: object + properties: + pid: + type: integer + tag: + type: string + cmd: + type: string + args: + type: array + items: + type: string + + ProcessListResponse: + type: object + properties: + processes: + type: array + items: + $ref: "#/components/schemas/ProcessEntry" ExecResponse: type: object @@ -1225,7 +2352,79 @@ components: properties: path: type: string - description: Absolute file path inside the sandbox + description: Absolute file path inside the capsule + + ListDirRequest: + type: object + required: [path] + properties: + path: + type: string + description: Directory path inside the capsule + depth: + type: integer + default: 1 + description: Recursion depth (0 = non-recursive, 1 = immediate children) + + ListDirResponse: + type: object + properties: + entries: + type: array + items: + $ref: "#/components/schemas/FileEntry" + + FileEntry: + type: object + properties: + name: + type: string + path: + type: string + type: + type: string + enum: [file, directory, symlink] + size: + type: integer + format: int64 + mode: + type: integer + permissions: + type: string + description: Human-readable permissions (e.g. "-rwxr-xr-x") + owner: + type: string + group: + type: string + modified_at: + type: integer + format: int64 + description: Unix timestamp (seconds) + symlink_target: + type: string + nullable: true + + MakeDirRequest: + type: object + required: [path] + properties: + path: + type: string + description: Directory path to create inside the capsule + + MakeDirResponse: + type: object + properties: + entry: + $ref: "#/components/schemas/FileEntry" + + RemoveRequest: + type: object + required: [path] + properties: + path: + type: string + description: Path to remove inside the capsule CreateHostRequest: type: object @@ -1281,7 +2480,10 @@ components: $ref: "#/components/schemas/Host" token: type: string - description: Long-lived host JWT for X-Host-Token header. Valid for 1 year. + description: Host JWT for X-Host-Token header. Valid for 7 days. + refresh_token: + type: string + description: Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use. Host: type: object @@ -1317,7 +2519,7 @@ components: nullable: true status: type: string - enum: [pending, online, offline, draining] + enum: [pending, online, offline, draining, unreachable] last_heartbeat_at: type: string format: date-time @@ -1331,6 +2533,54 @@ components: type: string format: date-time + RefreshHostTokenRequest: + type: object + required: [refresh_token] + properties: + refresh_token: + type: string + description: Refresh token obtained from registration or a previous refresh. + + RefreshHostTokenResponse: + type: object + properties: + host: + $ref: "#/components/schemas/Host" + token: + type: string + description: New host JWT. Valid for 7 days. + refresh_token: + type: string + description: New refresh token. Valid for 60 days; old token is revoked. + + HostDeletePreview: + type: object + properties: + host: + $ref: "#/components/schemas/Host" + sandbox_ids: + type: array + items: + type: string + description: IDs of capsulees that would be destroyed on force-delete. + + HostHasCapsulesError: + type: object + properties: + error: + type: object + properties: + code: + type: string + example: host_has_sandboxes + message: + type: string + sandbox_ids: + type: array + items: + type: string + description: IDs of active capsulees blocking deletion. + AddTagRequest: type: object required: [tag] @@ -1338,6 +2588,199 @@ components: tag: type: string + UserSearchResult: + type: object + properties: + user_id: + type: string + email: + type: string + + Team: + type: object + properties: + id: + type: string + name: + type: string + slug: + type: string + description: Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3) + created_at: + type: string + format: date-time + + TeamWithRole: + allOf: + - $ref: "#/components/schemas/Team" + - type: object + properties: + role: + type: string + enum: [owner, admin, member] + + TeamMember: + type: object + properties: + user_id: + type: string + email: + type: string + role: + type: string + enum: [owner, admin, member] + joined_at: + type: string + format: date-time + + TeamDetail: + type: object + properties: + team: + $ref: "#/components/schemas/Team" + members: + type: array + items: + $ref: "#/components/schemas/TeamMember" + + CapsuleMetrics: + type: object + properties: + sandbox_id: + type: string + range: + type: string + enum: ["5m", "10m", "1h", "2h", "6h", "12h", "24h"] + points: + type: array + items: + $ref: "#/components/schemas/MetricPoint" + + MetricPoint: + type: object + properties: + timestamp_unix: + type: integer + format: int64 + cpu_pct: + type: number + format: double + description: "CPU utilization percentage (0-100), normalized to vCPU count" + mem_bytes: + type: integer + format: int64 + description: "Resident memory in bytes (VmRSS of Firecracker process)" + disk_bytes: + type: integer + format: int64 + description: "Allocated disk bytes for the CoW sparse file" + + CreateChannelRequest: + type: object + required: [name, provider, config, events] + properties: + name: + type: string + description: Unique channel name within the team. + provider: + type: string + enum: [discord, slack, teams, googlechat, telegram, matrix, webhook] + config: + type: object + additionalProperties: + type: string + 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). + events: + type: array + items: + type: string + enum: + - capsule.created + - capsule.running + - capsule.paused + - capsule.destroyed + - template.snapshot.created + - template.snapshot.deleted + - host.up + - host.down + + TestChannelRequest: + type: object + required: [provider, config] + properties: + provider: + type: string + enum: [discord, slack, teams, googlechat, telegram, matrix, webhook] + config: + type: object + additionalProperties: + type: string + description: Provider-specific configuration fields (same as CreateChannelRequest.config). + + RotateConfigRequest: + type: object + required: [config] + properties: + config: + type: object + additionalProperties: + type: string + description: > + New provider configuration fields. Must include all required fields + for the channel's provider. Replaces the existing config entirely. + + UpdateChannelRequest: + type: object + required: [name, events] + properties: + name: + type: string + events: + type: array + items: + type: string + enum: + - capsule.created + - capsule.running + - capsule.paused + - capsule.destroyed + - template.snapshot.created + - template.snapshot.deleted + - host.up + - host.down + + ChannelResponse: + type: object + properties: + id: + type: string + team_id: + type: string + name: + type: string + provider: + type: string + enum: [discord, slack, teams, googlechat, telegram, matrix, webhook] + events: + type: array + items: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + secret: + type: string + nullable: true + description: Webhook secret. Only returned on creation, never again. + Error: type: object properties: diff --git a/pyproject.toml b/pyproject.toml index 0149f62..0f51113 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,11 @@ authors = [ ] requires-python = ">=3.13" dependencies = [ + "email-validator>=2.3.0", "httpx>=0.28.1", + "httpx-ws>=0.9.0", "pydantic>=2.12.5", + "python-dotenv>=1.2.2", ] [build-system] @@ -18,9 +21,15 @@ build-backend = "hatchling.build" [dependency-groups] dev = [ - "datamodel-code-generator>=0.56.0", + "datamodel-code-generator[ruff]>=0.56.0", "mypy>=1.20.0", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", + "respx>=0.23.1", "ruff>=0.15.10", ] + +[tool.pytest.ini_options] +markers = [ + "integration: integration tests (require live server)", +] diff --git a/src/wrenn/__init__.py b/src/wrenn/__init__.py index cc0f99e..55447c6 100644 --- a/src/wrenn/__init__.py +++ b/src/wrenn/__init__.py @@ -1,2 +1,89 @@ -def hello() -> str: - return "Hello from wrenn!" +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", + "AsyncPtySession", + "AsyncWrennClient", + "Capsule", + "CommandHandle", + "CommandResult", + "FileEntry", + "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}") diff --git a/src/wrenn/_config.py b/src/wrenn/_config.py new file mode 100644 index 0000000..a9b57ad --- /dev/null +++ b/src/wrenn/_config.py @@ -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} diff --git a/src/wrenn/async_capsule.py b/src/wrenn/async_capsule.py new file mode 100644 index 0000000..d4bfb4b --- /dev/null +++ b/src/wrenn/async_capsule.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import asyncio +import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import httpx_ws + +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) + + # ── Properties ────────────────────────────────────────────── + + @property + def capsule_id(self) -> str: + return self._id + + @property + def info(self) -> CapsuleModel | None: + 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.""" + 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. Resumes it if paused.""" + 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: + await self._client.capsules.ping(self._id) + + async def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None: + 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: + 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]: + 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]: + 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]: + 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: + 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: + 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 diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py new file mode 100644 index 0000000..62eddd1 --- /dev/null +++ b/src/wrenn/capsule.py @@ -0,0 +1,338 @@ +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.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: + 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) + + if wait: + self.wait_ready() + + # ── Properties ────────────────────────────────────────────── + + @property + def capsule_id(self) -> str: + return self._id + + @property + def info(self) -> CapsuleModel | None: + 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. Alias for ``Capsule(...)``.""" + 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. Resumes it if paused.""" + 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.""" + 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``.""" + 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: + 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 for the 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.""" + 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.""" + 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 inside this 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.""" + 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 + + diff --git a/src/wrenn/client.py b/src/wrenn/client.py new file mode 100644 index 0000000..ea9e74c --- /dev/null +++ b/src/wrenn/client.py @@ -0,0 +1,281 @@ +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: + 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]: + resp = self._http.get("/v1/capsules") + return [CapsuleModel.model_validate(item) for item in handle_response(resp)] + + def get(self, id: str) -> CapsuleModel: + resp = self._http.get(f"/v1/capsules/{id}") + return CapsuleModel.model_validate(handle_response(resp)) + + def destroy(self, id: str) -> None: + resp = self._http.delete(f"/v1/capsules/{id}") + handle_response(resp) + + def pause(self, id: str) -> CapsuleModel: + resp = self._http.post(f"/v1/capsules/{id}/pause") + return CapsuleModel.model_validate(handle_response(resp)) + + def resume(self, id: str) -> CapsuleModel: + resp = self._http.post(f"/v1/capsules/{id}/resume") + return CapsuleModel.model_validate(handle_response(resp)) + + def ping(self, id: str) -> None: + 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: + 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]: + 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: + resp = await self._http.get(f"/v1/capsules/{id}") + return CapsuleModel.model_validate(handle_response(resp)) + + async def destroy(self, id: str) -> None: + resp = await self._http.delete(f"/v1/capsules/{id}") + handle_response(resp) + + async def pause(self, id: str) -> CapsuleModel: + resp = await self._http.post(f"/v1/capsules/{id}/pause") + return CapsuleModel.model_validate(handle_response(resp)) + + async def resume(self, id: str) -> CapsuleModel: + resp = await self._http.post(f"/v1/capsules/{id}/resume") + return CapsuleModel.model_validate(handle_response(resp)) + + async def ping(self, id: str) -> None: + 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: + 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]: + 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: + 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: + 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]: + 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: + 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() diff --git a/src/wrenn/code_interpreter/__init__.py b/src/wrenn/code_interpreter/__init__.py new file mode 100644 index 0000000..137dc17 --- /dev/null +++ b/src/wrenn/code_interpreter/__init__.py @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..090b21c --- /dev/null +++ b/src/wrenn/code_interpreter/async_capsule.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import asyncio +import json +import time +import uuid + +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 CodeResult, DEFAULT_TEMPLATE + + +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: + 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, + ) -> CodeResult: + """Execute code in a persistent Jupyter kernel (async).""" + 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"] + + result = CodeResult() + 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": + name = content.get("name", "stdout") + if name == "stderr": + result.stderr += content.get("text", "") + else: + result.stdout += content.get("text", "") + elif msg_type == "execute_result": + bundle = content.get("data", {}) + 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", []) + result.error = "\n".join(traceback) + 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: + if self._proxy_client is not None: + try: + await self._proxy_client.aclose() + except Exception: + pass + await super().__aexit__(*args) diff --git a/src/wrenn/code_interpreter/capsule.py b/src/wrenn/code_interpreter/capsule.py new file mode 100644 index 0000000..e92f72a --- /dev/null +++ b/src/wrenn/code_interpreter/capsule.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import json +import time +import uuid +from dataclasses import dataclass + +import httpx +import httpx_ws + +from wrenn.capsule import Capsule as BaseCapsule +from wrenn.capsule import _build_proxy_url + + +DEFAULT_TEMPLATE = "code-runner-beta" + + +@dataclass +class CodeResult: + """Result from stateful code execution. + + Attributes: + text: text/plain representation of the result. + data: rich MIME bundle (e.g. ``{"image/png": "..."}``). + stdout: accumulated stdout output. + stderr: accumulated stderr output. + error: language-specific error/traceback string. + """ + + text: str | None = None + data: dict[str, str] | None = None + stdout: str = "" + stderr: str = "" + error: str | None = None + + +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.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: + 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: + 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, + ) -> CodeResult: + """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. + + Returns: + A ``CodeResult`` with ``.text``, ``.data``, ``.stdout``, ``.stderr``, ``.error``. + """ + 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"] + + result = CodeResult() + 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": + name = content.get("name", "stdout") + if name == "stderr": + result.stderr += content.get("text", "") + else: + result.stdout += content.get("text", "") + elif msg_type == "execute_result": + bundle = content.get("data", {}) + 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", []) + result.error = "\n".join(traceback) + 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: + if self._proxy_client is not None: + try: + self._proxy_client.close() + except Exception: + pass + super().__exit__(*args) diff --git a/src/wrenn/commands.py b/src/wrenn/commands.py new file mode 100644 index 0000000..13d97a2 --- /dev/null +++ b/src/wrenn/commands.py @@ -0,0 +1,366 @@ +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: + payload: dict = {"cmd": 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]: + 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: + 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.""" + 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, yielding ``StreamEvent`` objects.""" + with httpx_ws.connect_ws( + f"/v1/capsules/{self._capsule_id}/exec/stream", + self._http, + ) as ws: + start_msg: dict = {"type": "start", "cmd": cmd} + if args: + start_msg["args"] = args + 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: + payload: dict = {"cmd": 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]: + 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: + 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.""" + 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, yielding ``StreamEvent`` objects.""" + async with httpx_ws.aconnect_ws( + f"/v1/capsules/{self._capsule_id}/exec/stream", + self._http, + ) as ws: + start_msg: dict = {"type": "start", "cmd": cmd} + if args: + start_msg["args"] = args + 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 diff --git a/src/wrenn/exceptions.py b/src/wrenn/exceptions.py new file mode 100644 index 0000000..c4b39d8 --- /dev/null +++ b/src/wrenn/exceptions.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import warnings + +import httpx + + +class WrennError(Exception): + """Base exception for all Wrenn SDK errors.""" + + def __init__(self, code: str, message: str, status_code: int) -> None: + 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.""" + + def __init__( + self, code: str, message: str, status_code: int, capsule_ids: list[str] + ) -> None: + 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}") diff --git a/src/wrenn/files.py b/src/wrenn/files.py new file mode 100644 index 0000000..837aa2f --- /dev/null +++ b/src/wrenn/files.py @@ -0,0 +1,241 @@ +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.""" + return self.read_bytes(path).decode("utf-8", errors="replace") + + def read_bytes(self, path: str) -> bytes: + """Read a file as raw bytes.""" + 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.""" + 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.""" + 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.""" + 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.""" + 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.""" + 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: + """Streaming upload for large files.""" + 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]: + """Streaming download for large files.""" + 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.""" + 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.""" + 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.""" + 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.""" + 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.""" + 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.""" + 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.""" + 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: + """Streaming upload for large files.""" + 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]: + """Streaming download for large files.""" + 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 diff --git a/src/wrenn/models/__init__.py b/src/wrenn/models/__init__.py new file mode 100644 index 0000000..5628e11 --- /dev/null +++ b/src/wrenn/models/__init__.py @@ -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", +] diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py new file mode 100644 index 0000000..4ebdc74 --- /dev/null +++ b/src/wrenn/models/_generated.py @@ -0,0 +1,577 @@ +# generated by datamodel-codegen: +# filename: openapi.yaml +# timestamp: 2026-04-15T08:37:41+00:00 + +from __future__ import annotations +from pydantic import AwareDatetime, BaseModel, EmailStr, Field +from typing import Annotated +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 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 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 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 diff --git a/src/wrenn/pty.py b/src/wrenn/pty.py new file mode 100644 index 0000000..83ee871 --- /dev/null +++ b/src/wrenn/pty.py @@ -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 diff --git a/src/wrenn/sandbox.py b/src/wrenn/sandbox.py new file mode 100644 index 0000000..1b2499c --- /dev/null +++ b/src/wrenn/sandbox.py @@ -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}") diff --git a/tests/test_capsule_features.py b/tests/test_capsule_features.py new file mode 100644 index 0000000..54f280f --- /dev/null +++ b/tests/test_capsule_features.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import pytest +import respx + +from wrenn.capsule import Capsule, _build_proxy_url +from wrenn.code_interpreter.capsule import CodeResult + +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") + 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") + 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") 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() + 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") + 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") + 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") + 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") + 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") + 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") + assert cap.capsule_id == "cl-1" + + +class TestCodeResult: + def test_defaults(self): + r = CodeResult() + assert r.text is None + assert r.data is None + assert r.stdout == "" + assert r.stderr == "" + assert r.error is None + + def test_with_values(self): + r = CodeResult( + text="84", + data={"text/plain": "84"}, + stdout="", + stderr="", + error=None, + ) + assert r.text == "84" + assert r.data["text/plain"] == "84" + + def test_error_result(self): + r = CodeResult(error="ZeroDivisionError: division by zero\n...") + assert r.error is not None + assert "ZeroDivisionError" in r.error + + +class TestDeprecationWarnings: + def test_import_sandbox_from_wrenn_warns(self): + import importlib + 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) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..00ba03b --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,264 @@ +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") as c: + yield c + + +@pytest.fixture +def async_client(): + return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678") + + +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): + 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") diff --git a/tests/test_filesystem_pty.py b/tests/test_filesystem_pty.py new file mode 100644 index 0000000..2ed5c51 --- /dev/null +++ b/tests/test_filesystem_pty.py @@ -0,0 +1,495 @@ +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") + + +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 diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..9cba1c8 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,568 @@ +from __future__ import annotations + +import os +from typing import Generator + +import pytest + +from wrenn.client import AsyncWrennClient, WrennClient +from wrenn.exceptions import WrennNotFoundError, WrennValidationError +from wrenn.pty import PtyEventType + +WRENN_API_KEY = os.environ.get("WRENN_API_KEY") +WRENN_TOKEN = os.environ.get("WRENN_TOKEN") +WRENN_BASE_URL = os.environ.get("WRENN_BASE_URL", "http://localhost:8080") +WRENN_TEST_EMAIL = os.environ.get("WRENN_TEST_EMAIL") +WRENN_TEST_PASSWORD = os.environ.get("WRENN_TEST_PASSWORD") + + +def _has_auth() -> bool: + return bool(WRENN_API_KEY or WRENN_TOKEN) + + +requires_auth = pytest.mark.skipif( + not _has_auth(), + reason="Set WRENN_API_KEY or WRENN_TOKEN to run integration tests", +) + + +@pytest.fixture +def client() -> Generator[WrennClient, None, None]: + with WrennClient( + api_key=WRENN_API_KEY, + token=WRENN_TOKEN, + base_url=WRENN_BASE_URL, + ) as c: + yield c + + +@pytest.fixture +def async_client() -> AsyncWrennClient: + return AsyncWrennClient( + api_key=WRENN_API_KEY, + token=WRENN_TOKEN, + base_url=WRENN_BASE_URL, + ) + + +@pytest.fixture +def bearer_client() -> Generator[WrennClient, None, None]: + if WRENN_TOKEN: + with WrennClient(token=WRENN_TOKEN, base_url=WRENN_BASE_URL) as c: + yield c + elif WRENN_TEST_EMAIL and WRENN_TEST_PASSWORD: + with WrennClient( + api_key=WRENN_API_KEY, token=WRENN_TOKEN, base_url=WRENN_BASE_URL + ) as c: + resp = c.auth.login(WRENN_TEST_EMAIL, WRENN_TEST_PASSWORD) + with WrennClient(token=resp.token, base_url=WRENN_BASE_URL) as c: + yield c + else: + pytest.skip( + "Set WRENN_TOKEN or WRENN_TEST_EMAIL+WRENN_TEST_PASSWORD for bearer-auth tests" + ) + + +@requires_auth +class TestCapsuleLifecycle: + def test_create_exec_destroy(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + result = cap.exec("echo", args=["hello"]) + assert result.exit_code == 0 + assert "hello" in result.stdout + + def test_exec_with_args(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + result = cap.exec("echo", args=["hello", "world"]) + assert result.exit_code == 0 + assert "hello world" in result.stdout + + def test_exec_nonzero_exit(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + result = cap.exec("sh", args=["-c", "exit 42"]) + assert result.exit_code == 42 + + def test_exec_stderr(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + result = cap.exec("sh", args=["-c", "echo err>&2"]) + assert result.exit_code == 0 + assert "err" in result.stderr + + def test_context_manager_cleanup(self, client): + cap = client.capsules.create(template="minimal", timeout_sec=120) + cap_id = cap.id + + with cap: + cap.wait_ready(timeout=60, interval=1) + + fetched = client.capsules.get(cap_id) + assert fetched.status in ("stopped", "destroyed") + + +@requires_auth +class TestFileIO: + def test_upload_and_download(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + content = b"Hello from integration test!" + cap.upload("/tmp/test_file.txt", content) + downloaded = cap.download("/tmp/test_file.txt") + assert downloaded == content + + def test_download_nonexistent_file(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + with pytest.raises(Exception): + cap.download("/tmp/no_such_file_12345") + + +@requires_auth +class TestPauseResume: + def test_pause_and_resume(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.pause() + assert cap.status == "paused" + + cap.resume() + cap.wait_ready(timeout=60, interval=1) + + result = cap.exec("echo", args=["resumed"]) + assert result.exit_code == 0 + assert "resumed" in result.stdout + + +@requires_auth +class TestPing: + def test_ping_resets_timer(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.ping() + result = cap.exec("echo", args=["still_alive"]) + assert result.exit_code == 0 + assert "still_alive" in result.stdout + + +@requires_auth +class TestProxy: + def test_get_url(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + url = cap.get_url(8888) + assert cap.id in url + assert "8888" in url + + +@requires_auth +class TestListAndGet: + def test_list_capsules(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + boxes = client.capsules.list() + ids = [b.id for b in boxes] + assert cap.id in ids + + def test_get_existing_capsule(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + fetched = client.capsules.get(cap.id) + assert fetched.id == cap.id + assert fetched.status == "running" + + def test_get_nonexistent_capsule(self, client): + with pytest.raises((WrennNotFoundError, WrennValidationError)): + client.capsules.get("cl-nonexistent00000000000000000") + + +@requires_auth +class TestSnapshots: + def test_list_templates(self, client): + templates = client.snapshots.list() + assert isinstance(templates, list) + + +@requires_auth +class TestAPIKeys: + def test_create_list_delete(self, bearer_client): + key_resp = bearer_client.api_keys.create(name="integration-test-key") + assert key_resp.name == "integration-test-key" + assert key_resp.key is not None + assert key_resp.id is not None + + try: + keys = bearer_client.api_keys.list() + ids = [k.id for k in keys] + assert key_resp.id in ids + finally: + bearer_client.api_keys.delete(key_resp.id) + + +@requires_auth +class TestRunCode: + def test_basic_execution(self, client): + with client.capsules.create( + template="python-interpreter-v0-beta", timeout_sec=120 + ) as cap: + cap.wait_ready(timeout=60, interval=1) + + r = cap.run_code("x = 42") + assert r.error is None + + r = cap.run_code("x * 2") + assert r.text == "84" + + def test_state_persists(self, client): + with client.capsules.create( + template="python-interpreter-v0-beta", timeout_sec=120 + ) as cap: + cap.wait_ready(timeout=60, interval=1) + + cap.run_code("def greet(name): return f'hello {name}'") + r = cap.run_code("greet('capsule')") + assert "hello capsule" in (r.text or "") + + def test_error_traceback(self, client): + with client.capsules.create( + template="python-interpreter-v0-beta", timeout_sec=120 + ) as cap: + cap.wait_ready(timeout=60, interval=1) + + r = cap.run_code("1/0") + assert r.error is not None + assert "ZeroDivisionError" in r.error + + def test_stdout_capture(self, client): + with client.capsules.create( + template="python-interpreter-v0-beta", timeout_sec=120 + ) as cap: + cap.wait_ready(timeout=60, interval=1) + + r = cap.run_code("print('hello from kernel')") + assert "hello from kernel" in r.stdout + + +@requires_auth +class TestAsyncCapsuleLifecycle: + @pytest.mark.asyncio + async def test_async_create_exec_destroy(self, async_client): + async with async_client: + cap = await async_client.capsules.create( + template="minimal", timeout_sec=120 + ) + try: + await cap.async_wait_ready(timeout=60, interval=1) + result = await cap.async_exec("echo", args=["async_hello"]) + assert result.exit_code == 0 + assert "async_hello" in result.stdout + finally: + await cap.async_destroy() + + @pytest.mark.asyncio + async def test_async_upload_download(self, async_client): + async with async_client: + cap = await async_client.capsules.create( + template="minimal", timeout_sec=120 + ) + try: + await cap.async_wait_ready(timeout=60, interval=1) + content = b"Async upload test" + await cap.async_upload("/tmp/async_test.txt", content) + downloaded = await cap.async_download("/tmp/async_test.txt") + assert downloaded == content + finally: + await cap.async_destroy() + + @pytest.mark.asyncio + async def test_async_run_code(self, async_client): + async with async_client: + cap = await async_client.capsules.create( + template="python-interpreter-v0-beta", timeout_sec=120 + ) + try: + await cap.async_wait_ready(timeout=60, interval=1) + r = await cap.async_run_code("42 * 2") + assert r.text == "84" + finally: + await cap.async_destroy() + + +@requires_auth +class TestFilesystemListDir: + def test_list_dir_root(self, client: WrennClient): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.mkdir("/tmp/ls_test_root") + cap.upload("/tmp/ls_test_root/hello.txt", b"hello") + entries = cap.list_dir("/tmp/ls_test_root") + assert isinstance(entries, list) + names = [e.name for e in entries] + assert "hello.txt" in names + + def test_list_dir_after_mkdir(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.mkdir("/tmp/fs_test_dir") + entries = cap.list_dir("/tmp") + names = [e.name for e in entries] + assert "fs_test_dir" in names + + def test_list_dir_file_metadata(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.upload("/tmp/meta_test.txt", b"hello world") + entries = cap.list_dir("/tmp") + match = [e for e in entries if e.name == "meta_test.txt"] + assert len(match) == 1 + f = match[0] + assert f.type == "file" + assert f.size == 11 + assert f.permissions is not None + assert f.owner is not None + assert f.group is not None + assert f.modified_at is not None + + def test_list_dir_depth(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.mkdir("/tmp/depth_a/depth_b") + cap.upload("/tmp/depth_a/depth_b/nested.txt", b"deep") + entries = cap.list_dir("/tmp/depth_a", depth=2) + paths = [e.path for e in entries] + assert any("nested.txt" in p for p in paths) + + def test_list_dir_empty_directory(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.mkdir("/tmp/empty_dir_test") + entries = cap.list_dir("/tmp/empty_dir_test") + assert entries == [] + + +@requires_auth +class TestFilesystemMkdir: + def test_mkdir_creates_directory(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + entry = cap.mkdir("/tmp/mkdir_test") + assert entry.name == "mkdir_test" + assert entry.type == "directory" + assert entry.path == "/tmp/mkdir_test" + + def test_mkdir_creates_parents(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + entry = cap.mkdir("/tmp/a/b/c/d") + assert entry.type == "directory" + + def test_mkdir_already_exists(self, client: WrennClient): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.mkdir("/tmp/exist_test") + entry = cap.mkdir("/tmp/exist_test") + assert entry.type == "directory" + + +@requires_auth +class TestFilesystemRemove: + def test_remove_file(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.upload("/tmp/rm_test.txt", b"delete me") + entries_before = cap.list_dir("/tmp") + assert any(e.name == "rm_test.txt" for e in entries_before) + cap.remove("/tmp/rm_test.txt") + entries_after = cap.list_dir("/tmp") + assert not any(e.name == "rm_test.txt" for e in entries_after) + + def test_remove_directory(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.mkdir("/tmp/rm_dir_test") + cap.upload("/tmp/rm_dir_test/file.txt", b"inside") + cap.remove("/tmp/rm_dir_test") + entries = cap.list_dir("/tmp") + assert not any(e.name == "rm_dir_test" for e in entries) + + def test_upload_download_remove_roundtrip(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + content = b"round trip test data " * 100 + cap.upload("/tmp/rt.txt", content) + downloaded = cap.download("/tmp/rt.txt") + assert downloaded == content + cap.remove("/tmp/rt.txt") + with pytest.raises(Exception): + cap.download("/tmp/rt.txt") + + +@requires_auth +class TestStreamUploadDownload: + def test_stream_upload_and_download(self, client: WrennClient): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + chunks = [b"chunk0_", b"chunk1_", b"chunk2"] + + def data_gen(): + yield from chunks + + cap.stream_upload("/tmp/stream_test.bin", data_gen()) + downloaded = cap.download("/tmp/stream_test.bin") + assert downloaded == b"chunk0_chunk1_chunk2" + + def test_stream_download_large(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + content = b"x" * 65536 * 3 + cap.upload("/tmp/large.bin", content) + collected = b"" + for chunk in cap.stream_download("/tmp/large.bin"): + collected += chunk + assert collected == content + + +@requires_auth +class TestPty: + def test_pty_basic_output(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + with cap.pty(cmd="/bin/sh", cwd="/tmp") as term: + term.write(b"echo pty_hello\n") + output = b"" + for event in term: + if event.type == PtyEventType.output: + output += event.data + elif event.type == PtyEventType.exit: + break + if b"pty_hello" in output: + term.write(b"exit\n") + assert b"pty_hello" in output + + def test_pty_tag_and_pid(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + with cap.pty(cmd="/bin/sh") as term: + started = False + for event in term: + if event.type == PtyEventType.started: + started = True + assert term.tag is not None + assert term.pid is not None + assert term.tag.startswith("pty-") + elif event.type == PtyEventType.output: + term.write(b"exit\n") + elif event.type == PtyEventType.exit: + break + assert started + + def test_pty_exit_on_command_exit(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + with cap.pty(cmd="/bin/echo", args=["immediate"]) as term: + events = list(term) + types = [e.type for e in events] + assert PtyEventType.started in types + assert PtyEventType.output in types or PtyEventType.exit in types + + def test_pty_resize(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + with cap.pty(cmd="/bin/sh", cols=80, rows=24) as term: + for event in term: + if event.type == PtyEventType.started: + term.resize(120, 40) + term.write(b"exit\n") + elif event.type == PtyEventType.exit: + break + + def test_pty_envs(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + with cap.pty(cmd="/bin/sh", envs={"MY_VAR": "hello_env"}) as term: + output = b"" + for event in term: + if event.type == PtyEventType.started: + term.write(b"echo $MY_VAR\n") + elif event.type == PtyEventType.output: + output += event.data + if b"hello_env" in output: + term.write(b"exit\n") + elif event.type == PtyEventType.exit: + break + assert b"hello_env" in output + + +@requires_auth +class TestAsyncFilesystem: + @pytest.mark.asyncio + async def test_async_list_dir(self, async_client): + async with async_client: + cap = await async_client.capsules.create( + template="minimal", timeout_sec=120 + ) + try: + await cap.async_wait_ready(timeout=60, interval=1) + await cap.async_mkdir("/tmp/async_ls_test") + await cap.async_upload("/tmp/async_ls_test/file.txt", b"data") + entries = await cap.async_list_dir("/tmp/async_ls_test") + assert isinstance(entries, list) + assert any(e.name == "file.txt" for e in entries) + finally: + await cap.async_destroy() + + @pytest.mark.asyncio + async def test_async_mkdir(self, async_client): + async with async_client: + cap = await async_client.capsules.create( + template="minimal", timeout_sec=120 + ) + try: + await cap.async_wait_ready(timeout=60, interval=1) + entry = await cap.async_mkdir("/tmp/async_mkdir_test") + assert entry.type == "directory" + assert entry.name == "async_mkdir_test" + finally: + await cap.async_destroy() + + @pytest.mark.asyncio + async def test_async_remove(self, async_client): + async with async_client: + cap = await async_client.capsules.create( + template="minimal", timeout_sec=120 + ) + try: + await cap.async_wait_ready(timeout=60, interval=1) + await cap.async_upload("/tmp/async_rm.txt", b"bye") + entries = await cap.async_list_dir("/tmp") + assert any(e.name == "async_rm.txt" for e in entries) + await cap.async_remove("/tmp/async_rm.txt") + entries = await cap.async_list_dir("/tmp") + assert not any(e.name == "async_rm.txt" for e in entries) + finally: + await cap.async_destroy() + + @pytest.mark.asyncio + async def test_async_full_filesystem_roundtrip(self, async_client): + async with async_client: + cap = await async_client.capsules.create( + template="minimal", timeout_sec=120 + ) + try: + await cap.async_wait_ready(timeout=60, interval=1) + + await cap.async_mkdir("/tmp/async_rt") + await cap.async_upload("/tmp/async_rt/file.txt", b"async content") + entries = await cap.async_list_dir("/tmp/async_rt") + assert any(e.name == "file.txt" for e in entries) + + data = await cap.async_download("/tmp/async_rt/file.txt") + assert data == b"async content" + + await cap.async_remove("/tmp/async_rt/file.txt") + entries = await cap.async_list_dir("/tmp/async_rt") + assert not any(e.name == "file.txt" for e in entries) + finally: + await cap.async_destroy() diff --git a/uv.lock b/uv.lock index 852c192..36827e6 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.14'", @@ -112,6 +112,33 @@ 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" }, ] +[package.optional-dependencies] +ruff = [ + { name = "ruff" }, +] + +[[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 = "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]] name = "genson" version = "1.3.0" @@ -158,6 +185,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" }, ] +[[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]] name = "idna" version = "3.11" @@ -504,6 +546,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "pytokens" version = "0.4.1" @@ -564,6 +615,18 @@ 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" }, ] +[[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]] name = "ruff" version = "0.15.10" @@ -627,30 +690,50 @@ name = "wrenn" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "email-validator" }, { name = "httpx" }, + { name = "httpx-ws" }, { name = "pydantic" }, + { name = "python-dotenv" }, ] [package.dev-dependencies] dev = [ - { name = "datamodel-code-generator" }, + { name = "datamodel-code-generator", extra = ["ruff"] }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "respx" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ + { name = "email-validator", specifier = ">=2.3.0" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "httpx-ws", specifier = ">=0.9.0" }, { name = "pydantic", specifier = ">=2.12.5" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, ] [package.metadata.requires-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 = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "respx", specifier = ">=0.23.1" }, { 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" }, +]