v0.1.1 #7

Merged
pptx704 merged 29 commits from dev into main 2026-05-01 23:06:55 +00:00
33 changed files with 2606 additions and 8231 deletions
Showing only changes of commit bf5914c0a8 - Show all commits

View File

@ -1,42 +1,46 @@
kind: pipeline
name: static-analysis
when: when:
- event: push event: push
branch: branch:
- main - main
- dev - dev
variables: variables:
- &python_image ghcr.io/astral-sh/uv:python3.13-bookworm-slim - &python_image "ghcr.io/astral-sh/uv:python3.13-bookworm-slim"
- &uv_cache_dir /root/.cache/uv - &uv_cache_dir "/root/.cache/uv"
steps: steps:
lint: - 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 image: *python_image
environment: environment:
UV_CACHE_DIR: *uv_cache_dir UV_CACHE_DIR: *uv_cache_dir
UV_FROZEN: "1" UV_FROZEN: 1
commands: commands:
- uv sync --no-install-project - uv sync --no-install-project
- make lint - make lint
volumes:
- name: uv-cache
path: *uv_cache_dir
test: - name: test
image: *python_image image: *python_image
environment: environment:
UV_CACHE_DIR: *uv_cache_dir UV_CACHE_DIR: *uv_cache_dir
UV_FROZEN: "1" UV_FROZEN: 1
commands: commands:
- uv sync --no-install-project - uv sync --no-install-project
- make test - make test
volumes:
- name: uv-cache
path: *uv_cache_dir
volumes: - name: rebuild-cache
- name: uv-cache image: woodpeckerci/plugin-cache
host: when:
path: /var/lib/woodpecker/cache/uv - status: [success]
settings:
rebuild: true
cache_key: "uv-{{ checksum \"uv.lock\" }}"
mount:
- /root/.cache/uv

View File

@ -1,6 +1,6 @@
openapi: "3.1.0" openapi: "3.1.0"
info: info:
title: Wrenn Sandbox API title: Wrenn API
description: MicroVM-based code execution platform API. description: MicroVM-based code execution platform API.
version: "0.1.0" version: "0.1.0"
@ -393,7 +393,7 @@ paths:
- bearerAuth: [] - bearerAuth: []
description: | description: |
Owner only. Soft-deletes the team and destroys all running/paused/starting Owner only. Soft-deletes the team and destroys all running/paused/starting
sandboxes. All DB records are preserved. The team slug is permanently reserved. capsulees. All DB records are preserved. The team slug is permanently reserved.
responses: responses:
"204": "204":
description: Team deleted description: Team deleted
@ -570,11 +570,11 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes: /v1/capsules:
post: post:
summary: Create a sandbox summary: Create a capsule
operationId: createSandbox operationId: createCapsule
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
requestBody: requestBody:
@ -582,14 +582,14 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/CreateSandboxRequest" $ref: "#/components/schemas/CreateCapsuleRequest"
responses: responses:
"201": "201":
description: Sandbox created description: Capsule created
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Sandbox" $ref: "#/components/schemas/Capsule"
"502": "502":
description: Host agent error description: Host agent error
content: content:
@ -598,26 +598,26 @@ paths:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
get: get:
summary: List sandboxes for your team summary: List capsulees for your team
operationId: listSandboxes operationId: listCapsules
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
responses: responses:
"200": "200":
description: List of sandboxes description: List of capsulees
content: content:
application/json: application/json:
schema: schema:
type: array type: array
items: items:
$ref: "#/components/schemas/Sandbox" $ref: "#/components/schemas/Capsule"
/v1/sandboxes/stats: /v1/capsules/stats:
get: get:
summary: Get sandbox usage stats for your team summary: Get capsule usage stats for your team
operationId: getSandboxStats operationId: getCapsuleStats
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
parameters: parameters:
@ -631,15 +631,15 @@ paths:
description: Time window for the time-series data. description: Time window for the time-series data.
responses: responses:
"200": "200":
description: Sandbox stats for the team description: Capsule stats for the team
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/SandboxStats" $ref: "#/components/schemas/CapsuleStats"
"400": "400":
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
/v1/sandboxes/{id}: /v1/capsules/{id}:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -648,36 +648,36 @@ paths:
type: string type: string
get: get:
summary: Get sandbox details summary: Get capsule details
operationId: getSandbox operationId: getCapsule
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
responses: responses:
"200": "200":
description: Sandbox details description: Capsule details
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Sandbox" $ref: "#/components/schemas/Capsule"
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
delete: delete:
summary: Destroy a sandbox summary: Destroy a capsule
operationId: destroySandbox operationId: destroyCapsule
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
responses: responses:
"204": "204":
description: Sandbox destroyed description: Capsule destroyed
/v1/sandboxes/{id}/exec: /v1/capsules/{id}/exec:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -688,7 +688,7 @@ paths:
post: post:
summary: Execute a command summary: Execute a command
operationId: execCommand operationId: execCommand
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
requestBody: requestBody:
@ -705,19 +705,19 @@ paths:
schema: schema:
$ref: "#/components/schemas/ExecResponse" $ref: "#/components/schemas/ExecResponse"
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/ping: /v1/capsules/{id}/ping:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -726,32 +726,32 @@ paths:
type: string type: string
post: post:
summary: Reset sandbox inactivity timer summary: Reset capsule inactivity timer
operationId: pingSandbox operationId: pingCapsule
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
description: | description: |
Resets the last_active_at timestamp for a running sandbox, preventing Resets the last_active_at timestamp for a running capsule, preventing
the auto-pause TTL from expiring. Use this as a keepalive for sandboxes the auto-pause TTL from expiring. Use this as a keepalive for capsulees
that are idle but should remain running. that are idle but should remain running.
responses: responses:
"204": "204":
description: Ping acknowledged, inactivity timer reset description: Ping acknowledged, inactivity timer reset
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/metrics: /v1/capsules/{id}/metrics:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -760,22 +760,22 @@ paths:
type: string type: string
get: get:
summary: Get per-sandbox resource metrics summary: Get per-capsule resource metrics
operationId: getSandboxMetrics operationId: getCapsuleMetrics
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
- bearerAuth: [] - bearerAuth: []
description: | description: |
Returns time-series CPU, memory, and disk metrics for a sandbox. Returns time-series CPU, memory, and disk metrics for a capsule.
Three tiers are available with different granularity and retention: Three tiers are available with different granularity and retention:
- `10m`: 500ms samples, last 10 minutes - `10m`: 500ms samples, last 10 minutes
- `2h`: 30-second averages, last 2 hours - `2h`: 30-second averages, last 2 hours
- `24h`: 5-minute averages, last 24 hours - `24h`: 5-minute averages, last 24 hours
For running sandboxes, data comes from the host agent's in-memory For running capsulees, data comes from the host agent's in-memory
ring buffer. For paused sandboxes, data is read from persisted ring buffer. For paused capsulees, data is read from persisted
snapshots in the database. Stopped/destroyed sandboxes return 404. snapshots in the database. Stopped/destroyed capsulees return 404.
parameters: parameters:
- name: range - name: range
in: query in: query
@ -791,7 +791,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/SandboxMetrics" $ref: "#/components/schemas/CapsuleMetrics"
"400": "400":
description: Invalid range parameter description: Invalid range parameter
content: content:
@ -799,13 +799,13 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"404": "404":
description: Sandbox not found or metrics not available description: Capsule not found or metrics not available
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/pause: /v1/capsules/{id}/pause:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -814,30 +814,30 @@ paths:
type: string type: string
post: post:
summary: Pause a running sandbox summary: Pause a running capsule
operationId: pauseSandbox operationId: pauseCapsule
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
description: | description: |
Takes a snapshot of the sandbox (VM state + memory + rootfs), then Takes a snapshot of the capsule (VM state + memory + rootfs), then
destroys all running resources. The sandbox exists only as files on destroys all running resources. The capsule exists only as files on
disk and can be resumed later. disk and can be resumed later.
responses: responses:
"200": "200":
description: Sandbox paused (snapshot taken, resources released) description: Capsule paused (snapshot taken, resources released)
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Sandbox" $ref: "#/components/schemas/Capsule"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/resume: /v1/capsules/{id}/resume:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -846,24 +846,24 @@ paths:
type: string type: string
post: post:
summary: Resume a paused sandbox summary: Resume a paused capsule
operationId: resumeSandbox operationId: resumeCapsule
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
description: | 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 memory loading. Boots a fresh Firecracker process, sets up a new
network slot, and waits for envd to become ready. network slot, and waits for envd to become ready.
responses: responses:
"200": "200":
description: Sandbox resumed (new VM booted from snapshot) description: Capsule resumed (new VM booted from snapshot)
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Sandbox" $ref: "#/components/schemas/Capsule"
"409": "409":
description: Sandbox not paused description: Capsule not paused
content: content:
application/json: application/json:
schema: schema:
@ -877,9 +877,9 @@ paths:
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
description: | 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 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: parameters:
- name: overwrite - name: overwrite
in: query in: query
@ -902,7 +902,7 @@ paths:
schema: schema:
$ref: "#/components/schemas/Template" $ref: "#/components/schemas/Template"
"409": "409":
description: Name already exists or sandbox not running description: Name already exists or capsule not running
content: content:
application/json: application/json:
schema: schema:
@ -957,7 +957,7 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/write: /v1/capsules/{id}/files/write:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -968,7 +968,7 @@ paths:
post: post:
summary: Upload a file summary: Upload a file
operationId: uploadFile operationId: uploadFile
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
requestBody: requestBody:
@ -981,7 +981,7 @@ paths:
properties: properties:
path: path:
type: string type: string
description: Absolute destination path inside the sandbox description: Absolute destination path inside the capsule
file: file:
type: string type: string
format: binary format: binary
@ -990,7 +990,7 @@ paths:
"204": "204":
description: File uploaded description: File uploaded
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
@ -1002,7 +1002,7 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/read: /v1/capsules/{id}/files/read:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -1013,7 +1013,7 @@ paths:
post: post:
summary: Download a file summary: Download a file
operationId: downloadFile operationId: downloadFile
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
requestBody: requestBody:
@ -1031,13 +1031,13 @@ paths:
type: string type: string
format: binary format: binary
"404": "404":
description: Sandbox or file not found description: Capsule or file not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/list: /v1/capsules/{id}/files/list:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -1048,7 +1048,7 @@ paths:
post: post:
summary: List directory contents summary: List directory contents
operationId: listDir operationId: listDir
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
requestBody: requestBody:
@ -1065,19 +1065,19 @@ paths:
schema: schema:
$ref: "#/components/schemas/ListDirResponse" $ref: "#/components/schemas/ListDirResponse"
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/mkdir: /v1/capsules/{id}/files/mkdir:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -1088,7 +1088,7 @@ paths:
post: post:
summary: Create a directory summary: Create a directory
operationId: makeDir operationId: makeDir
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
requestBody: requestBody:
@ -1105,19 +1105,19 @@ paths:
schema: schema:
$ref: "#/components/schemas/MakeDirResponse" $ref: "#/components/schemas/MakeDirResponse"
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/remove: /v1/capsules/{id}/files/remove:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -1128,7 +1128,7 @@ paths:
post: post:
summary: Remove a file or directory summary: Remove a file or directory
operationId: removePath operationId: removePath
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
requestBody: requestBody:
@ -1141,19 +1141,19 @@ paths:
"204": "204":
description: File or directory removed description: File or directory removed
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/exec/stream: /v1/capsules/{id}/exec/stream:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -1164,7 +1164,7 @@ paths:
get: get:
summary: Stream command execution via WebSocket summary: Stream command execution via WebSocket
operationId: execStream operationId: execStream
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
description: | description: |
@ -1194,19 +1194,19 @@ paths:
"101": "101":
description: WebSocket upgrade description: WebSocket upgrade
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/pty: /v1/capsules/{id}/pty:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -1217,7 +1217,7 @@ paths:
get: get:
summary: Interactive PTY session via WebSocket summary: Interactive PTY session via WebSocket
operationId: ptySession operationId: ptySession
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
description: | description: |
@ -1266,25 +1266,25 @@ paths:
Sessions have a 120-second inactivity timeout (reset on input/resize). Sessions have a 120-second inactivity timeout (reset on input/resize).
Sessions persist across WebSocket disconnections — the process keeps Sessions persist across WebSocket disconnections — the process keeps
running in the sandbox. Use the `tag` from the "started" response to running in the capsule. Use the `tag` from the "started" response to
reconnect later. reconnect later.
responses: responses:
"101": "101":
description: WebSocket upgrade description: WebSocket upgrade
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/stream/write: /v1/capsules/{id}/files/stream/write:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -1295,11 +1295,11 @@ paths:
post: post:
summary: Upload a file (streaming) summary: Upload a file (streaming)
operationId: streamUploadFile operationId: streamUploadFile
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
description: | 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 Suitable for large files. Uses the same multipart/form-data format
as the non-streaming upload endpoint. as the non-streaming upload endpoint.
requestBody: requestBody:
@ -1312,7 +1312,7 @@ paths:
properties: properties:
path: path:
type: string type: string
description: Absolute destination path inside the sandbox description: Absolute destination path inside the capsule
file: file:
type: string type: string
format: binary format: binary
@ -1321,19 +1321,19 @@ paths:
"204": "204":
description: File uploaded description: File uploaded
"404": "404":
description: Sandbox not found description: Capsule not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/sandboxes/{id}/files/stream/read: /v1/capsules/{id}/files/stream/read:
parameters: parameters:
- name: id - name: id
in: path in: path
@ -1344,11 +1344,11 @@ paths:
post: post:
summary: Download a file (streaming) summary: Download a file (streaming)
operationId: streamDownloadFile operationId: streamDownloadFile
tags: [sandboxes] tags: [capsules]
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
description: | 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. Suitable for large files. Returns raw bytes with chunked transfer encoding.
requestBody: requestBody:
required: true required: true
@ -1365,13 +1365,13 @@ paths:
type: string type: string
format: binary format: binary
"404": "404":
description: Sandbox or file not found description: Capsule or file not found
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Sandbox not running description: Capsule not running
content: content:
application/json: application/json:
schema: schema:
@ -1469,14 +1469,14 @@ paths:
description: | description: |
Admins can delete any host. Team owners and admins can delete BYOC hosts 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 belonging to their team. Without `?force=true`, returns 409 if the host
has active sandboxes. With `?force=true`, destroys all sandboxes first. has active capsulees. With `?force=true`, destroys all capsulees first.
parameters: parameters:
- name: force - name: force
in: query in: query
required: false required: false
schema: schema:
type: boolean type: boolean
description: If true, destroy all sandboxes on the host before deleting. description: If true, destroy all capsulees on the host before deleting.
responses: responses:
"204": "204":
description: Host deleted description: Host deleted
@ -1487,11 +1487,11 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Host has active sandboxes (only when force is not set) description: Host has active capsulees (only when force is not set)
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/HostHasSandboxesError" $ref: "#/components/schemas/HostHasCapsulesError"
/v1/hosts/{id}/token: /v1/hosts/{id}/token:
parameters: parameters:
@ -1644,7 +1644,7 @@ paths:
security: security:
- bearerAuth: [] - bearerAuth: []
description: | description: |
Returns the list of sandbox IDs that would be destroyed if the host Returns the list of capsule IDs that would be destroyed if the host
were deleted with `?force=true`. No state is modified. were deleted with `?force=true`. No state is modified.
responses: responses:
"200": "200":
@ -1917,7 +1917,7 @@ components:
type: apiKey type: apiKey
in: header in: header
name: X-API-Key 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: bearerAuth:
type: http type: http
@ -2002,7 +2002,7 @@ components:
description: Full plaintext key. Only returned on creation, never again. description: Full plaintext key. Only returned on creation, never again.
nullable: true nullable: true
CreateSandboxRequest: CreateCapsuleRequest:
type: object type: object
properties: properties:
template: template:
@ -2018,11 +2018,11 @@ components:
type: integer type: integer
default: 0 default: 0
description: > 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 after this duration of inactivity (no exec or ping). 0 means
no auto-pause. no auto-pause.
SandboxStats: CapsuleStats:
type: object type: object
properties: properties:
range: range:
@ -2073,7 +2073,7 @@ components:
items: items:
type: integer type: integer
Sandbox: Capsule:
type: object type: object
properties: properties:
id: id:
@ -2114,7 +2114,7 @@ components:
properties: properties:
sandbox_id: sandbox_id:
type: string type: string
description: ID of the running sandbox to snapshot. description: ID of the running capsule to snapshot.
name: name:
type: string type: string
description: Name for the snapshot template. Auto-generated if omitted. description: Name for the snapshot template. Auto-generated if omitted.
@ -2180,7 +2180,7 @@ components:
properties: properties:
path: path:
type: string type: string
description: Absolute file path inside the sandbox description: Absolute file path inside the capsule
ListDirRequest: ListDirRequest:
type: object type: object
@ -2188,7 +2188,7 @@ components:
properties: properties:
path: path:
type: string type: string
description: Directory path inside the sandbox description: Directory path inside the capsule
depth: depth:
type: integer type: integer
default: 1 default: 1
@ -2238,7 +2238,7 @@ components:
properties: properties:
path: path:
type: string type: string
description: Directory path to create inside the sandbox description: Directory path to create inside the capsule
MakeDirResponse: MakeDirResponse:
type: object type: object
@ -2252,7 +2252,7 @@ components:
properties: properties:
path: path:
type: string type: string
description: Path to remove inside the sandbox description: Path to remove inside the capsule
CreateHostRequest: CreateHostRequest:
type: object type: object
@ -2390,9 +2390,9 @@ components:
type: array type: array
items: items:
type: string type: string
description: IDs of sandboxes that would be destroyed on force-delete. description: IDs of capsulees that would be destroyed on force-delete.
HostHasSandboxesError: HostHasCapsulesError:
type: object type: object
properties: properties:
error: error:
@ -2407,7 +2407,7 @@ components:
type: array type: array
items: items:
type: string type: string
description: IDs of active sandboxes blocking deletion. description: IDs of active capsulees blocking deletion.
AddTagRequest: AddTagRequest:
type: object type: object
@ -2471,7 +2471,7 @@ components:
items: items:
$ref: "#/components/schemas/TeamMember" $ref: "#/components/schemas/TeamMember"
SandboxMetrics: CapsuleMetrics:
type: object type: object
properties: properties:
sandbox_id: sandbox_id:

View File

@ -1,22 +1,7 @@
from wrenn.client import AsyncWrennClient, WrennClient from wrenn.capsule import (
from wrenn.exceptions import ( Capsule,
WrennAgentError,
WrennAuthenticationError,
WrennConflictError,
WrennError,
WrennForbiddenError,
WrennHostHasSandboxesError,
WrennHostUnavailableError,
WrennInternalError,
WrennNotFoundError,
WrennValidationError,
)
from wrenn.models import FileEntry
from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession
from wrenn.sandbox import (
CodeResult, CodeResult,
ExecResult, ExecResult,
Sandbox,
StreamErrorEvent, StreamErrorEvent,
StreamEvent, StreamEvent,
StreamExitEvent, StreamExitEvent,
@ -24,6 +9,21 @@ from wrenn.sandbox import (
StreamStderrEvent, StreamStderrEvent,
StreamStdoutEvent, StreamStdoutEvent,
) )
from wrenn.client import AsyncWrennClient, WrennClient
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" __version__ = "0.1.0"
@ -31,6 +31,7 @@ __all__ = [
"__version__", "__version__",
"AsyncPtySession", "AsyncPtySession",
"AsyncWrennClient", "AsyncWrennClient",
"Capsule",
"CodeResult", "CodeResult",
"ExecResult", "ExecResult",
"FileEntry", "FileEntry",
@ -50,9 +51,32 @@ __all__ = [
"WrennConflictError", "WrennConflictError",
"WrennError", "WrennError",
"WrennForbiddenError", "WrennForbiddenError",
"WrennHostHasCapsulesError",
"WrennHostHasSandboxesError", "WrennHostHasSandboxesError",
"WrennHostUnavailableError", "WrennHostUnavailableError",
"WrennInternalError", "WrennInternalError",
"WrennNotFoundError", "WrennNotFoundError",
"WrennValidationError", "WrennValidationError",
] ]
def __getattr__(name: str) -> type:
if name == "Sandbox":
import warnings
warnings.warn(
"'Sandbox' is deprecated, use 'Capsule' instead",
DeprecationWarning,
stacklevel=2,
)
return Capsule
if name == "WrennHostHasSandboxesError":
import warnings
warnings.warn(
"'WrennHostHasSandboxesError' is deprecated, use 'WrennHostHasCapsulesError' instead",
DeprecationWarning,
stacklevel=2,
)
return WrennHostHasCapsulesError
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

1171
src/wrenn/capsule.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import builtins import builtins
import warnings
from typing import cast from typing import cast
import httpx import httpx
from wrenn.capsule import Capsule
from wrenn.exceptions import handle_response from wrenn.exceptions import handle_response
from wrenn.models import ( from wrenn.models import (
APIKeyResponse, APIKeyResponse,
@ -14,9 +16,8 @@ from wrenn.models import (
Template, Template,
) )
from wrenn.models import ( from wrenn.models import (
Sandbox as SandboxModel, Capsule as CapsuleModel,
) )
from wrenn.sandbox import Sandbox
DEFAULT_BASE_URL = "https://api.wrenn.dev" DEFAULT_BASE_URL = "https://api.wrenn.dev"
@ -112,8 +113,8 @@ class AsyncAPIKeysResource:
handle_response(resp) handle_response(resp)
class SandboxesResource: class CapsulesResource:
"""Sync sandbox control-plane operations.""" """Sync capsule control-plane operations."""
def __init__( def __init__(
self, self,
@ -133,7 +134,7 @@ class SandboxesResource:
vcpus: int | None = None, vcpus: int | None = None,
memory_mb: int | None = None, memory_mb: int | None = None,
timeout_sec: int | None = None, timeout_sec: int | None = None,
) -> Sandbox: ) -> Capsule:
payload: dict = {} payload: dict = {}
if template is not None: if template is not None:
payload["template"] = template payload["template"] = template
@ -143,27 +144,27 @@ class SandboxesResource:
payload["memory_mb"] = memory_mb payload["memory_mb"] = memory_mb
if timeout_sec is not None: if timeout_sec is not None:
payload["timeout_sec"] = timeout_sec payload["timeout_sec"] = timeout_sec
resp = self._http.post("/v1/sandboxes", json=payload) resp = self._http.post("/v1/capsules", json=payload)
model = SandboxModel.model_validate(handle_response(resp)) model = CapsuleModel.model_validate(handle_response(resp))
sb = Sandbox.model_validate(model.model_dump()) cap = Capsule.model_validate(model.model_dump())
sb._bind(self._http, self._base_url, self._api_key, self._token) cap._bind(self._http, self._base_url, self._api_key, self._token)
return sb return cap
def list(self) -> list[SandboxModel]: def list(self) -> list[CapsuleModel]:
resp = self._http.get("/v1/sandboxes") resp = self._http.get("/v1/capsules")
return [SandboxModel.model_validate(item) for item in handle_response(resp)] return [CapsuleModel.model_validate(item) for item in handle_response(resp)]
def get(self, id: str) -> SandboxModel: def get(self, id: str) -> CapsuleModel:
resp = self._http.get(f"/v1/sandboxes/{id}") resp = self._http.get(f"/v1/capsules/{id}")
return SandboxModel.model_validate(handle_response(resp)) return CapsuleModel.model_validate(handle_response(resp))
def destroy(self, id: str) -> None: def destroy(self, id: str) -> None:
resp = self._http.delete(f"/v1/sandboxes/{id}") resp = self._http.delete(f"/v1/capsules/{id}")
handle_response(resp) handle_response(resp)
class AsyncSandboxesResource: class AsyncCapsulesResource:
"""Async sandbox control-plane operations.""" """Async capsule control-plane operations."""
def __init__( def __init__(
self, self,
@ -183,7 +184,7 @@ class AsyncSandboxesResource:
vcpus: int | None = None, vcpus: int | None = None,
memory_mb: int | None = None, memory_mb: int | None = None,
timeout_sec: int | None = None, timeout_sec: int | None = None,
) -> Sandbox: ) -> Capsule:
payload: dict = {} payload: dict = {}
if template is not None: if template is not None:
payload["template"] = template payload["template"] = template
@ -193,22 +194,22 @@ class AsyncSandboxesResource:
payload["memory_mb"] = memory_mb payload["memory_mb"] = memory_mb
if timeout_sec is not None: if timeout_sec is not None:
payload["timeout_sec"] = timeout_sec payload["timeout_sec"] = timeout_sec
resp = await self._http.post("/v1/sandboxes", json=payload) resp = await self._http.post("/v1/capsules", json=payload)
model = SandboxModel.model_validate(handle_response(resp)) model = CapsuleModel.model_validate(handle_response(resp))
sb = Sandbox.model_validate(model.model_dump()) cap = Capsule.model_validate(model.model_dump())
sb._bind(self._http, self._base_url, self._api_key, self._token) cap._bind(self._http, self._base_url, self._api_key, self._token)
return sb return cap
async def list(self) -> list[SandboxModel]: async def list(self) -> list[CapsuleModel]:
resp = await self._http.get("/v1/sandboxes") resp = await self._http.get("/v1/capsules")
return [SandboxModel.model_validate(item) for item in handle_response(resp)] return [CapsuleModel.model_validate(item) for item in handle_response(resp)]
async def get(self, id: str) -> SandboxModel: async def get(self, id: str) -> CapsuleModel:
resp = await self._http.get(f"/v1/sandboxes/{id}") resp = await self._http.get(f"/v1/capsules/{id}")
return SandboxModel.model_validate(handle_response(resp)) return CapsuleModel.model_validate(handle_response(resp))
async def destroy(self, id: str) -> None: async def destroy(self, id: str) -> None:
resp = await self._http.delete(f"/v1/sandboxes/{id}") resp = await self._http.delete(f"/v1/capsules/{id}")
handle_response(resp) handle_response(resp)
@ -220,11 +221,11 @@ class SnapshotsResource:
def create( def create(
self, self,
sandbox_id: str, capsule_id: str,
name: str | None = None, name: str | None = None,
overwrite: bool = False, overwrite: bool = False,
) -> Template: ) -> Template:
payload: dict = {"sandbox_id": sandbox_id} payload: dict = {"sandbox_id": capsule_id}
if name is not None: if name is not None:
payload["name"] = name payload["name"] = name
params: dict = {} params: dict = {}
@ -253,11 +254,11 @@ class AsyncSnapshotsResource:
async def create( async def create(
self, self,
sandbox_id: str, capsule_id: str,
name: str | None = None, name: str | None = None,
overwrite: bool = False, overwrite: bool = False,
) -> Template: ) -> Template:
payload: dict = {"sandbox_id": sandbox_id} payload: dict = {"sandbox_id": capsule_id}
if name is not None: if name is not None:
payload["name"] = name payload["name"] = name
params: dict = {} params: dict = {}
@ -410,10 +411,19 @@ class WrennClient:
self.auth = AuthResource(self._http) self.auth = AuthResource(self._http)
self.api_keys = APIKeysResource(self._http) self.api_keys = APIKeysResource(self._http)
self.sandboxes = SandboxesResource(self._http, base_url, api_key, token) self.capsules = CapsulesResource(self._http, base_url, api_key, token)
self.snapshots = SnapshotsResource(self._http) self.snapshots = SnapshotsResource(self._http)
self.hosts = HostsResource(self._http) self.hosts = HostsResource(self._http)
@property
def sandboxes(self) -> CapsulesResource:
warnings.warn(
"'client.sandboxes' is deprecated, use 'client.capsules' instead",
DeprecationWarning,
stacklevel=2,
)
return self.capsules
def close(self) -> None: def close(self) -> None:
"""Close the underlying HTTP connection pool.""" """Close the underlying HTTP connection pool."""
self._http.close() self._http.close()
@ -458,10 +468,19 @@ class AsyncWrennClient:
self.auth = AsyncAuthResource(self._http) self.auth = AsyncAuthResource(self._http)
self.api_keys = AsyncAPIKeysResource(self._http) self.api_keys = AsyncAPIKeysResource(self._http)
self.sandboxes = AsyncSandboxesResource(self._http, base_url, api_key, token) self.capsules = AsyncCapsulesResource(self._http, base_url, api_key, token)
self.snapshots = AsyncSnapshotsResource(self._http) self.snapshots = AsyncSnapshotsResource(self._http)
self.hosts = AsyncHostsResource(self._http) self.hosts = AsyncHostsResource(self._http)
@property
def sandboxes(self) -> AsyncCapsulesResource:
warnings.warn(
"'client.sandboxes' is deprecated, use 'client.capsules' instead",
DeprecationWarning,
stacklevel=2,
)
return self.capsules
async def aclose(self) -> None: async def aclose(self) -> None:
"""Close the underlying async HTTP connection pool.""" """Close the underlying async HTTP connection pool."""
await self._http.aclose() await self._http.aclose()

View File

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import warnings
import httpx import httpx
@ -33,15 +35,24 @@ class WrennConflictError(WrennError):
"""409 — State conflict (e.g. invalid_state).""" """409 — State conflict (e.g. invalid_state)."""
class WrennHostHasSandboxesError(WrennConflictError): class WrennHostHasCapsulesError(WrennConflictError):
"""409 — Host still has running sandboxes.""" """409 — Host still has running capsules."""
def __init__( def __init__(
self, code: str, message: str, status_code: int, sandbox_ids: list[str] self, code: str, message: str, status_code: int, capsule_ids: list[str]
) -> None: ) -> None:
self.sandbox_ids = sandbox_ids self.capsule_ids = capsule_ids
super().__init__(code, message, status_code) 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): class WrennHostUnavailableError(WrennError):
"""503 — No suitable host available.""" """503 — No suitable host available."""
@ -62,7 +73,8 @@ _ERROR_MAP: dict[str, type[WrennError]] = {
"not_found": WrennNotFoundError, "not_found": WrennNotFoundError,
"invalid_state": WrennConflictError, "invalid_state": WrennConflictError,
"conflict": WrennConflictError, "conflict": WrennConflictError,
"host_has_sandboxes": WrennHostHasSandboxesError, "host_has_sandboxes": WrennHostHasCapsulesError,
"host_has_capsules": WrennHostHasCapsulesError,
"host_unavailable": WrennHostUnavailableError, "host_unavailable": WrennHostUnavailableError,
"agent_error": WrennAgentError, "agent_error": WrennAgentError,
"internal_error": WrennInternalError, "internal_error": WrennInternalError,
@ -83,12 +95,12 @@ def handle_response(resp: httpx.Response) -> dict | list:
exc_cls = _ERROR_MAP.get(code, WrennError) exc_cls = _ERROR_MAP.get(code, WrennError)
if exc_cls is WrennHostHasSandboxesError: if exc_cls is WrennHostHasCapsulesError:
raise WrennHostHasSandboxesError( raise WrennHostHasCapsulesError(
code=code, code=code,
message=message, message=message,
status_code=resp.status_code, status_code=resp.status_code,
sandbox_ids=body.get("sandbox_ids", []), capsule_ids=body.get("sandbox_ids", []),
) )
raise exc_cls( raise exc_cls(
@ -101,3 +113,14 @@ def handle_response(resp: httpx.Response) -> dict | list:
return {} return {}
return resp.json() 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}")

View File

@ -1,10 +1,11 @@
from wrenn.models._generated import ( from wrenn.models._generated import (
APIKeyResponse, APIKeyResponse,
AuthResponse, AuthResponse,
Capsule,
CreateAPIKeyRequest, CreateAPIKeyRequest,
CreateCapsuleRequest,
CreateHostRequest, CreateHostRequest,
CreateHostResponse, CreateHostResponse,
CreateSandboxRequest,
CreateSnapshotRequest, CreateSnapshotRequest,
Encoding, Encoding,
Error, Error,
@ -22,7 +23,6 @@ from wrenn.models._generated import (
RegisterHostRequest, RegisterHostRequest,
RegisterHostResponse, RegisterHostResponse,
RemoveRequest, RemoveRequest,
Sandbox,
SignupRequest, SignupRequest,
Status, Status,
Status1, Status1,
@ -38,7 +38,7 @@ __all__ = [
"CreateAPIKeyRequest", "CreateAPIKeyRequest",
"CreateHostRequest", "CreateHostRequest",
"CreateHostResponse", "CreateHostResponse",
"CreateSandboxRequest", "CreateCapsuleRequest",
"CreateSnapshotRequest", "CreateSnapshotRequest",
"Encoding", "Encoding",
"Error", "Error",
@ -56,7 +56,7 @@ __all__ = [
"RegisterHostRequest", "RegisterHostRequest",
"RegisterHostResponse", "RegisterHostResponse",
"RemoveRequest", "RemoveRequest",
"Sandbox", "Capsule",
"SignupRequest", "SignupRequest",
"Status", "Status",
"Status1", "Status1",

View File

@ -1,6 +1,6 @@
# generated by datamodel-codegen: # generated by datamodel-codegen:
# filename: openapi.yaml # filename: openapi.yaml
# timestamp: 2026-04-11T15:00:55+00:00 # timestamp: 2026-04-12T20:56:29+00:00
from __future__ import annotations from __future__ import annotations
@ -22,7 +22,7 @@ class LoginRequest(BaseModel):
class AuthResponse(BaseModel): class AuthResponse(BaseModel):
token: Annotated[str | None, Field(description="JWT token (valid for 6 hours)")] = ( token: Annotated[str | None, Field(description='JWT token (valid for 6 hours)')] = (
None None
) )
user_id: str | None = None user_id: str | None = None
@ -32,7 +32,7 @@ class AuthResponse(BaseModel):
class CreateAPIKeyRequest(BaseModel): class CreateAPIKeyRequest(BaseModel):
name: str | None = "Unnamed API Key" name: str | None = 'Unnamed API Key'
class APIKeyResponse(BaseModel): class APIKeyResponse(BaseModel):
@ -47,29 +47,29 @@ class APIKeyResponse(BaseModel):
key: Annotated[ key: Annotated[
str | None, str | None,
Field( Field(
description="Full plaintext key. Only returned on creation, never again." description='Full plaintext key. Only returned on creation, never again.'
), ),
] = None ] = None
class CreateSandboxRequest(BaseModel): class CreateCapsuleRequest(BaseModel):
template: str | None = "minimal" template: str | None = 'minimal'
vcpus: int | None = 1 vcpus: int | None = 1
memory_mb: int | None = 512 memory_mb: int | None = 512
timeout_sec: Annotated[ timeout_sec: Annotated[
int | None, int | None,
Field( Field(
description="Auto-pause TTL in seconds. The sandbox is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause.\n" 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 ] = 0
class Range(StrEnum): class Range(StrEnum):
field_5m = "5m" field_5m = '5m'
field_1h = "1h" field_1h = '1h'
field_6h = "6h" field_6h = '6h'
field_24h = "24h" field_24h = '24h'
field_30d = "30d" field_30d = '30d'
class Current(BaseModel): class Current(BaseModel):
@ -100,29 +100,29 @@ class Series(BaseModel):
memory_mb: list[int] | None = None memory_mb: list[int] | None = None
class SandboxStats(BaseModel): class CapsuleStats(BaseModel):
range: Range | None = None range: Range | None = None
current: Current | None = None current: Current | None = None
peaks: Annotated[ peaks: Annotated[
Peaks | None, Field(description="Maximum values over the last 30 days.") Peaks | None, Field(description='Maximum values over the last 30 days.')
] = None ] = None
series: Annotated[ series: Annotated[
Series | None, Field(description="Parallel arrays for chart rendering.") Series | None, Field(description='Parallel arrays for chart rendering.')
] = None ] = None
class Status(StrEnum): class Status(StrEnum):
pending = "pending" pending = 'pending'
starting = "starting" starting = 'starting'
running = "running" running = 'running'
paused = "paused" paused = 'paused'
hibernated = "hibernated" hibernated = 'hibernated'
stopped = "stopped" stopped = 'stopped'
missing = "missing" missing = 'missing'
error = "error" error = 'error'
class Sandbox(BaseModel): class Capsule(BaseModel):
id: str | None = None id: str | None = None
status: Status | None = None status: Status | None = None
template: str | None = None template: str | None = None
@ -139,17 +139,17 @@ class Sandbox(BaseModel):
class CreateSnapshotRequest(BaseModel): class CreateSnapshotRequest(BaseModel):
sandbox_id: Annotated[ sandbox_id: Annotated[
str, Field(description="ID of the running sandbox to snapshot.") str, Field(description='ID of the running capsule to snapshot.')
] ]
name: Annotated[ name: Annotated[
str | None, str | None,
Field(description="Name for the snapshot template. Auto-generated if omitted."), Field(description='Name for the snapshot template. Auto-generated if omitted.'),
] = None ] = None
class Type(StrEnum): class Type(StrEnum):
base = "base" base = 'base'
snapshot = "snapshot" snapshot = 'snapshot'
class Template(BaseModel): class Template(BaseModel):
@ -172,8 +172,8 @@ class Encoding(StrEnum):
Output encoding. "base64" when stdout/stderr contain binary data. Output encoding. "base64" when stdout/stderr contain binary data.
""" """
utf_8 = "utf-8" utf_8 = 'utf-8'
base64 = "base64" base64 = 'base64'
class ExecResponse(BaseModel): class ExecResponse(BaseModel):
@ -192,23 +192,23 @@ class ExecResponse(BaseModel):
class ReadFileRequest(BaseModel): class ReadFileRequest(BaseModel):
path: Annotated[str, Field(description="Absolute file path inside the sandbox")] path: Annotated[str, Field(description='Absolute file path inside the capsule')]
class ListDirRequest(BaseModel): class ListDirRequest(BaseModel):
path: Annotated[str, Field(description="Directory path inside the sandbox")] path: Annotated[str, Field(description='Directory path inside the capsule')]
depth: Annotated[ depth: Annotated[
int | None, int | None,
Field( Field(
description="Recursion depth (0 = non-recursive, 1 = immediate children)" description='Recursion depth (0 = non-recursive, 1 = immediate children)'
), ),
] = 1 ] = 1
class Type1(StrEnum): class Type1(StrEnum):
file = "file" file = 'file'
directory = "directory" directory = 'directory'
symlink = "symlink" symlink = 'symlink'
class FileEntry(BaseModel): class FileEntry(BaseModel):
@ -223,14 +223,14 @@ class FileEntry(BaseModel):
owner: str | None = None owner: str | None = None
group: str | None = None group: str | None = None
modified_at: Annotated[ modified_at: Annotated[
int | None, Field(description="Unix timestamp (seconds)") int | None, Field(description='Unix timestamp (seconds)')
] = None ] = None
symlink_target: str | None = None symlink_target: str | None = None
class MakeDirRequest(BaseModel): class MakeDirRequest(BaseModel):
path: Annotated[ path: Annotated[
str, Field(description="Directory path to create inside the sandbox") str, Field(description='Directory path to create inside the capsule')
] ]
@ -239,7 +239,7 @@ class MakeDirResponse(BaseModel):
class RemoveRequest(BaseModel): class RemoveRequest(BaseModel):
path: Annotated[str, Field(description="Path to remove inside the sandbox")] path: Annotated[str, Field(description='Path to remove inside the capsule')]
class Type2(StrEnum): class Type2(StrEnum):
@ -247,51 +247,51 @@ class Type2(StrEnum):
Host type. Regular hosts are shared; BYOC hosts belong to a team. Host type. Regular hosts are shared; BYOC hosts belong to a team.
""" """
regular = "regular" regular = 'regular'
byoc = "byoc" byoc = 'byoc'
class CreateHostRequest(BaseModel): class CreateHostRequest(BaseModel):
type: Annotated[ type: Annotated[
Type2, Type2,
Field( Field(
description="Host type. Regular hosts are shared; BYOC hosts belong to a team." 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 team_id: Annotated[str | None, Field(description='Required for BYOC hosts.')] = None
provider: Annotated[ provider: Annotated[
str | None, str | None,
Field(description="Cloud provider (e.g. aws, gcp, hetzner, bare-metal)."), Field(description='Cloud provider (e.g. aws, gcp, hetzner, bare-metal).'),
] = None ] = None
availability_zone: Annotated[ availability_zone: Annotated[
str | None, Field(description="Availability zone (e.g. us-east, eu-west).") str | None, Field(description='Availability zone (e.g. us-east, eu-west).')
] = None ] = None
class RegisterHostRequest(BaseModel): class RegisterHostRequest(BaseModel):
token: Annotated[ token: Annotated[
str, Field(description="One-time registration token from POST /v1/hosts.") str, Field(description='One-time registration token from POST /v1/hosts.')
] ]
arch: Annotated[ arch: Annotated[
str | None, Field(description="CPU architecture (e.g. x86_64, aarch64).") str | None, Field(description='CPU architecture (e.g. x86_64, aarch64).')
] = None ] = None
cpu_cores: int | None = None cpu_cores: int | None = None
memory_mb: int | None = None memory_mb: int | None = None
disk_gb: int | None = None disk_gb: int | None = None
address: Annotated[str, Field(description="Host agent address (ip:port).")] address: Annotated[str, Field(description='Host agent address (ip:port).')]
class Type3(StrEnum): class Type3(StrEnum):
regular = "regular" regular = 'regular'
byoc = "byoc" byoc = 'byoc'
class Status1(StrEnum): class Status1(StrEnum):
pending = "pending" pending = 'pending'
online = "online" online = 'online'
offline = "offline" offline = 'offline'
draining = "draining" draining = 'draining'
unreachable = "unreachable" unreachable = 'unreachable'
class Host(BaseModel): class Host(BaseModel):
@ -316,7 +316,7 @@ class RefreshHostTokenRequest(BaseModel):
refresh_token: Annotated[ refresh_token: Annotated[
str, str,
Field( Field(
description="Refresh token obtained from registration or a previous refresh." description='Refresh token obtained from registration or a previous refresh.'
), ),
] ]
@ -324,12 +324,12 @@ class RefreshHostTokenRequest(BaseModel):
class RefreshHostTokenResponse(BaseModel): class RefreshHostTokenResponse(BaseModel):
host: Host | None = None host: Host | None = None
token: Annotated[ token: Annotated[
str | None, Field(description="New host JWT. Valid for 7 days.") str | None, Field(description='New host JWT. Valid for 7 days.')
] = None ] = None
refresh_token: Annotated[ refresh_token: Annotated[
str | None, str | None,
Field( Field(
description="New refresh token. Valid for 60 days; old token is revoked." description='New refresh token. Valid for 60 days; old token is revoked.'
), ),
] = None ] = None
@ -338,20 +338,20 @@ class HostDeletePreview(BaseModel):
host: Host | None = None host: Host | None = None
sandbox_ids: Annotated[ sandbox_ids: Annotated[
list[str] | None, list[str] | None,
Field(description="IDs of sandboxes that would be destroyed on force-delete."), Field(description='IDs of capsulees that would be destroyed on force-delete.'),
] = None ] = None
class Error(BaseModel): class Error(BaseModel):
code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None code: Annotated[str | None, Field(examples=['host_has_sandboxes'])] = None
message: str | None = None message: str | None = None
sandbox_ids: Annotated[ sandbox_ids: Annotated[
list[str] | None, list[str] | None,
Field(description="IDs of active sandboxes blocking deletion."), Field(description='IDs of active capsulees blocking deletion.'),
] = None ] = None
class HostHasSandboxesError(BaseModel): class HostHasCapsulesError(BaseModel):
error: Error | None = None error: Error | None = None
@ -368,15 +368,15 @@ class Team(BaseModel):
id: str | None = None id: str | None = None
name: str | None = None name: str | None = None
slug: Annotated[ slug: Annotated[
str | None, Field(description="Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)") str | None, Field(description='Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)')
] = None ] = None
created_at: AwareDatetime | None = None created_at: AwareDatetime | None = None
class Role(StrEnum): class Role(StrEnum):
owner = "owner" owner = 'owner'
admin = "admin" admin = 'admin'
member = "member" member = 'member'
class TeamWithRole(Team): class TeamWithRole(Team):
@ -396,13 +396,13 @@ class TeamDetail(BaseModel):
class Range1(StrEnum): class Range1(StrEnum):
field_5m = "5m" field_5m = '5m'
field_10m = "10m" field_10m = '10m'
field_1h = "1h" field_1h = '1h'
field_2h = "2h" field_2h = '2h'
field_6h = "6h" field_6h = '6h'
field_12h = "12h" field_12h = '12h'
field_24h = "24h" field_24h = '24h'
class MetricPoint(BaseModel): class MetricPoint(BaseModel):
@ -410,41 +410,41 @@ class MetricPoint(BaseModel):
cpu_pct: Annotated[ cpu_pct: Annotated[
float | None, float | None,
Field( Field(
description="CPU utilization percentage (0-100), normalized to vCPU count" description='CPU utilization percentage (0-100), normalized to vCPU count'
), ),
] = None ] = None
mem_bytes: Annotated[ mem_bytes: Annotated[
int | None, int | None,
Field(description="Resident memory in bytes (VmRSS of Firecracker process)"), Field(description='Resident memory in bytes (VmRSS of Firecracker process)'),
] = None ] = None
disk_bytes: Annotated[ disk_bytes: Annotated[
int | None, Field(description="Allocated disk bytes for the CoW sparse file") int | None, Field(description='Allocated disk bytes for the CoW sparse file')
] = None ] = None
class Provider(StrEnum): class Provider(StrEnum):
discord = "discord" discord = 'discord'
slack = "slack" slack = 'slack'
teams = "teams" teams = 'teams'
googlechat = "googlechat" googlechat = 'googlechat'
telegram = "telegram" telegram = 'telegram'
matrix = "matrix" matrix = 'matrix'
webhook = "webhook" webhook = 'webhook'
class Event(StrEnum): class Event(StrEnum):
capsule_created = "capsule.created" capsule_created = 'capsule.created'
capsule_running = "capsule.running" capsule_running = 'capsule.running'
capsule_paused = "capsule.paused" capsule_paused = 'capsule.paused'
capsule_destroyed = "capsule.destroyed" capsule_destroyed = 'capsule.destroyed'
template_snapshot_created = "template.snapshot.created" template_snapshot_created = 'template.snapshot.created'
template_snapshot_deleted = "template.snapshot.deleted" template_snapshot_deleted = 'template.snapshot.deleted'
host_up = "host.up" host_up = 'host.up'
host_down = "host.down" host_down = 'host.down'
class CreateChannelRequest(BaseModel): class CreateChannelRequest(BaseModel):
name: Annotated[str, Field(description="Unique channel name within the team.")] name: Annotated[str, Field(description='Unique channel name within the team.')]
provider: Provider provider: Provider
config: Annotated[ config: Annotated[
dict[str, str], dict[str, str],
@ -460,7 +460,7 @@ class TestChannelRequest(BaseModel):
config: Annotated[ config: Annotated[
dict[str, str], dict[str, str],
Field( Field(
description="Provider-specific configuration fields (same as CreateChannelRequest.config)." description='Provider-specific configuration fields (same as CreateChannelRequest.config).'
), ),
] ]
@ -489,7 +489,7 @@ class ChannelResponse(BaseModel):
updated_at: AwareDatetime | None = None updated_at: AwareDatetime | None = None
secret: Annotated[ secret: Annotated[
str | None, str | None,
Field(description="Webhook secret. Only returned on creation, never again."), Field(description='Webhook secret. Only returned on creation, never again.'),
] = None ] = None
@ -511,7 +511,7 @@ class CreateHostResponse(BaseModel):
registration_token: Annotated[ registration_token: Annotated[
str | None, str | None,
Field( Field(
description="One-time registration token for the host agent. Expires in 1 hour." description='One-time registration token for the host agent. Expires in 1 hour.'
), ),
] = None ] = None
@ -520,17 +520,17 @@ class RegisterHostResponse(BaseModel):
host: Host | None = None host: Host | None = None
token: Annotated[ token: Annotated[
str | None, str | None,
Field(description="Host JWT for X-Host-Token header. Valid for 7 days."), Field(description='Host JWT for X-Host-Token header. Valid for 7 days.'),
] = None ] = None
refresh_token: Annotated[ refresh_token: Annotated[
str | None, str | None,
Field( Field(
description="Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use." description='Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use.'
), ),
] = None ] = None
class SandboxMetrics(BaseModel): class CapsuleMetrics(BaseModel):
sandbox_id: str | None = None sandbox_id: str | None = None
range: Range1 | None = None range: Range1 | None = None
points: list[MetricPoint] | None = None points: list[MetricPoint] | None = None

View File

@ -66,9 +66,9 @@ class PtySession:
break break
""" """
def __init__(self, ws: httpx_ws.WebSocketSession, sandbox_id: str) -> None: def __init__(self, ws: httpx_ws.WebSocketSession, capsule_id: str) -> None:
self._ws = ws self._ws = ws
self._sandbox_id = sandbox_id self._capsule_id = capsule_id
self._tag: str | None = None self._tag: str | None = None
self._pid: int | None = None self._pid: int | None = None
self._done = False self._done = False
@ -192,9 +192,9 @@ class AsyncPtySession:
break break
""" """
def __init__(self, ws: httpx_ws.AsyncWebSocketSession, sandbox_id: str) -> None: def __init__(self, ws: httpx_ws.AsyncWebSocketSession, capsule_id: str) -> None:
self._ws = ws self._ws = ws
self._sandbox_id = sandbox_id self._capsule_id = capsule_id
self._tag: str | None = None self._tag: str | None = None
self._pid: int | None = None self._pid: int | None = None
self._done = False self._done = False

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,10 @@
from __future__ import annotations from __future__ import annotations
import pytest import pytest
import respx import respx
from wrenn.capsule import Capsule, CodeResult, _build_proxy_url
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.sandbox import CodeResult, Sandbox, _build_proxy_url
@pytest.fixture @pytest.fixture
@ -32,14 +31,14 @@ class TestBuildProxyUrl:
assert url == "ws://5000-sb-2.192.168.1.1" assert url == "ws://5000-sb-2.192.168.1.1"
class TestSandboxGetUrl: class TestCapsuleGetUrl:
@respx.mock @respx.mock
def test_get_url_returns_proxy_url(self, client): def test_get_url_returns_proxy_url(self, client):
respx.post("https://api.wrenn.dev/v1/sandboxes").respond( respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, json={"id": "cl-abc", "status": "pending"} 201, json={"id": "cl-abc", "status": "pending"}
) )
sb = client.sandboxes.create(template="minimal") cap = client.capsules.create(template="minimal")
url = sb.get_url(8888) url = cap.get_url(8888)
assert url == "wss://8888-cl-abc.api.wrenn.dev" assert url == "wss://8888-cl-abc.api.wrenn.dev"
@respx.mock @respx.mock
@ -48,22 +47,22 @@ class TestSandboxGetUrl:
api_key="wrn_test1234567890abcdef12345678", api_key="wrn_test1234567890abcdef12345678",
base_url="http://localhost:8080", base_url="http://localhost:8080",
) as c: ) as c:
respx.post("http://localhost:8080/v1/sandboxes").respond( respx.post("http://localhost:8080/v1/capsules").respond(
201, json={"id": "cl-xyz", "status": "pending"} 201, json={"id": "cl-xyz", "status": "pending"}
) )
sb = c.sandboxes.create() cap = c.capsules.create()
url = sb.get_url(3000) url = cap.get_url(3000)
assert url == "ws://3000-cl-xyz.localhost:8080" assert url == "ws://3000-cl-xyz.localhost:8080"
class TestSandboxHttpClient: class TestCapsuleHttpClient:
@respx.mock @respx.mock
def test_http_client_has_api_key_header(self, client): def test_http_client_has_api_key_header(self, client):
respx.post("https://api.wrenn.dev/v1/sandboxes").respond( respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, json={"id": "cl-abc", "status": "pending"} 201, json={"id": "cl-abc", "status": "pending"}
) )
sb = client.sandboxes.create() cap = client.capsules.create()
hc = sb.http_client hc = cap.http_client
assert hc.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678" assert hc.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
@respx.mock @respx.mock
@ -71,51 +70,51 @@ class TestSandboxHttpClient:
route = respx.get("https://8888-cl-abc.api.wrenn.dev/api/kernels").respond( route = respx.get("https://8888-cl-abc.api.wrenn.dev/api/kernels").respond(
200, json=[] 200, json=[]
) )
respx.post("https://api.wrenn.dev/v1/sandboxes").respond( respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, json={"id": "cl-abc", "status": "pending"} 201, json={"id": "cl-abc", "status": "pending"}
) )
sb = client.sandboxes.create() cap = client.capsules.create()
resp = sb.http_client.get("/api/kernels") resp = cap.http_client.get("/api/kernels")
assert resp.status_code == 200 assert resp.status_code == 200
assert route.called assert route.called
def test_jwt_only_get_url_works(self): def test_jwt_only_get_url_works(self):
with WrennClient(token="jwt-abc") as c: with WrennClient(token="jwt-abc") as c:
sb = Sandbox(id="cl-abc") cap = Capsule(id="cl-abc")
sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") cap._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc")
url = sb.get_url(8888) url = cap.get_url(8888)
assert "8888-cl-abc" in url assert "8888-cl-abc" in url
def test_jwt_only_http_client_has_bearer_header(self): def test_jwt_only_http_client_has_bearer_header(self):
with WrennClient(token="jwt-abc") as c: with WrennClient(token="jwt-abc") as c:
sb = Sandbox(id="cl-abc") cap = Capsule(id="cl-abc")
sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") cap._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc")
hc = sb.http_client hc = cap.http_client
assert hc.headers["Authorization"] == "Bearer jwt-abc" assert hc.headers["Authorization"] == "Bearer jwt-abc"
class TestCreateReturnsBoundSandbox: class TestCreateReturnsBoundCapsule:
@respx.mock @respx.mock
def test_create_returns_sandbox_subclass(self, client): def test_create_returns_capsule_subclass(self, client):
respx.post("https://api.wrenn.dev/v1/sandboxes").respond( respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, json={"id": "cl-1", "status": "pending", "template": "minimal"} 201, json={"id": "cl-1", "status": "pending", "template": "minimal"}
) )
sb = client.sandboxes.create(template="minimal") cap = client.capsules.create(template="minimal")
assert isinstance(sb, Sandbox) assert isinstance(cap, Capsule)
assert sb.id == "cl-1" assert cap.id == "cl-1"
assert hasattr(sb, "exec") assert hasattr(cap, "exec")
assert hasattr(sb, "run_code") assert hasattr(cap, "run_code")
assert hasattr(sb, "get_url") assert hasattr(cap, "get_url")
@respx.mock @respx.mock
def test_create_context_manager(self, client): def test_create_context_manager(self, client):
route = respx.delete("https://api.wrenn.dev/v1/sandboxes/cl-1").respond(204) route = respx.delete("https://api.wrenn.dev/v1/capsules/cl-1").respond(204)
respx.post("https://api.wrenn.dev/v1/sandboxes").respond( respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, json={"id": "cl-1", "status": "pending"} 201, json={"id": "cl-1", "status": "pending"}
) )
sb = client.sandboxes.create() cap = client.capsules.create()
with sb: with cap:
assert sb.id == "cl-1" assert cap.id == "cl-1"
assert route.called assert route.called
@ -147,8 +146,8 @@ class TestCodeResult:
class TestJupyterMessageFormat: class TestJupyterMessageFormat:
def test_execute_request_structure(self): def test_execute_request_structure(self):
sb = Sandbox(id="test") cap = Capsule(id="test")
msg = sb._jupyter_execute_request("x = 42") msg = cap._jupyter_execute_request("x = 42")
assert msg["msg_type"] == "execute_request" assert msg["msg_type"] == "execute_request"
assert msg["content"]["code"] == "x = 42" assert msg["content"]["code"] == "x = 42"
assert msg["content"]["silent"] is False assert msg["content"]["silent"] is False
@ -157,7 +156,45 @@ class TestJupyterMessageFormat:
assert msg["header"]["msg_type"] == "execute_request" assert msg["header"]["msg_type"] == "execute_request"
def test_execute_request_unique_ids(self): def test_execute_request_unique_ids(self):
sb = Sandbox(id="test") cap = Capsule(id="test")
m1 = sb._jupyter_execute_request("a") m1 = cap._jupyter_execute_request("a")
m2 = sb._jupyter_execute_request("b") m2 = cap._jupyter_execute_request("b")
assert m1["msg_id"] != m2["msg_id"] assert m1["msg_id"] != m2["msg_id"]
class TestDeprecationWarnings:
def test_import_sandbox_from_capsule_warns(self):
import importlib
import warnings
import wrenn.capsule as capsule_mod
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
klass = capsule_mod.Sandbox
assert klass is Capsule
assert len(w) == 1
assert issubclass(w[0].category, DeprecationWarning)
assert "Sandbox" in str(w[0].message)
def test_import_sandbox_from_wrenn_warns(self):
import warnings
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
from wrenn import Sandbox
assert Sandbox is Capsule
assert any(issubclass(x.category, DeprecationWarning) for x in w)
def test_client_sandboxes_property_warns(self):
import warnings
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
resource = c.sandboxes
assert resource is c.capsules
assert len(w) == 1
assert issubclass(w[0].category, DeprecationWarning)
assert "sandboxes" in str(w[0].message)

View File

@ -9,7 +9,7 @@ from wrenn.exceptions import (
WrennAuthenticationError, WrennAuthenticationError,
WrennConflictError, WrennConflictError,
WrennForbiddenError, WrennForbiddenError,
WrennHostHasSandboxesError, WrennHostHasCapsulesError,
WrennInternalError, WrennInternalError,
WrennNotFoundError, WrennNotFoundError,
WrennValidationError, WrennValidationError,
@ -17,9 +17,9 @@ from wrenn.exceptions import (
from wrenn.models import ( from wrenn.models import (
APIKeyResponse, APIKeyResponse,
AuthResponse, AuthResponse,
Capsule,
CreateHostResponse, CreateHostResponse,
Host, Host,
Sandbox,
Status, Status,
Template, Template,
) )
@ -97,10 +97,10 @@ class TestAPIKeys:
assert route.called assert route.called
class TestSandboxes: class TestCapsules:
@respx.mock @respx.mock
def test_create(self, client): def test_create(self, client):
respx.post("https://api.wrenn.dev/v1/sandboxes").respond( respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, 201,
json={ json={
"id": "sb-1", "id": "sb-1",
@ -110,40 +110,40 @@ class TestSandboxes:
"memory_mb": 1024, "memory_mb": 1024,
}, },
) )
resp = client.sandboxes.create(template="base-python", vcpus=2, memory_mb=1024) resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024)
assert isinstance(resp, Sandbox) assert isinstance(resp, Capsule)
assert resp.id == "sb-1" assert resp.id == "sb-1"
assert resp.status == Status.pending assert resp.status == Status.pending
@respx.mock @respx.mock
def test_create_defaults(self, client): def test_create_defaults(self, client):
respx.post("https://api.wrenn.dev/v1/sandboxes").respond( respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, json={"id": "sb-2", "status": "pending"} 201, json={"id": "sb-2", "status": "pending"}
) )
resp = client.sandboxes.create() resp = client.capsules.create()
assert resp.id == "sb-2" assert resp.id == "sb-2"
@respx.mock @respx.mock
def test_list(self, client): def test_list(self, client):
respx.get("https://api.wrenn.dev/v1/sandboxes").respond( respx.get("https://api.wrenn.dev/v1/capsules").respond(
200, json=[{"id": "sb-1", "status": "running"}] 200, json=[{"id": "sb-1", "status": "running"}]
) )
boxes = client.sandboxes.list() boxes = client.capsules.list()
assert len(boxes) == 1 assert len(boxes) == 1
assert boxes[0].status == Status.running assert boxes[0].status == Status.running
@respx.mock @respx.mock
def test_get(self, client): def test_get(self, client):
respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond( respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond(
200, json={"id": "sb-1", "status": "running"} 200, json={"id": "sb-1", "status": "running"}
) )
resp = client.sandboxes.get("sb-1") resp = client.capsules.get("sb-1")
assert resp.id == "sb-1" assert resp.id == "sb-1"
@respx.mock @respx.mock
def test_destroy(self, client): def test_destroy(self, client):
route = respx.delete("https://api.wrenn.dev/v1/sandboxes/sb-1").respond(204) route = respx.delete("https://api.wrenn.dev/v1/capsules/sb-1").respond(204)
client.sandboxes.destroy("sb-1") client.capsules.destroy("sb-1")
assert route.called assert route.called
@ -154,7 +154,7 @@ class TestSnapshots:
201, 201,
json={"name": "snap-1", "type": "snapshot", "vcpus": 1}, json={"name": "snap-1", "type": "snapshot", "vcpus": 1},
) )
resp = client.snapshots.create(sandbox_id="sb-1", name="snap-1") resp = client.snapshots.create(capsule_id="sb-1", name="snap-1")
assert isinstance(resp, Template) assert isinstance(resp, Template)
assert resp.name == "snap-1" assert resp.name == "snap-1"
@ -163,7 +163,7 @@ class TestSnapshots:
route = respx.post("https://api.wrenn.dev/v1/snapshots").respond( route = respx.post("https://api.wrenn.dev/v1/snapshots").respond(
201, json={"name": "snap-1", "type": "snapshot"} 201, json={"name": "snap-1", "type": "snapshot"}
) )
client.snapshots.create(sandbox_id="sb-1", overwrite=True) client.snapshots.create(capsule_id="sb-1", overwrite=True)
req = route.calls[0].request req = route.calls[0].request
assert "overwrite=true" in str(req.url) assert "overwrite=true" in str(req.url)
@ -262,23 +262,23 @@ class TestHosts:
class TestErrorHandling: class TestErrorHandling:
@respx.mock @respx.mock
def test_validation_error(self, client): def test_validation_error(self, client):
respx.post("https://api.wrenn.dev/v1/sandboxes").respond( respx.post("https://api.wrenn.dev/v1/capsules").respond(
400, 400,
json={"error": {"code": "invalid_request", "message": "bad input"}}, json={"error": {"code": "invalid_request", "message": "bad input"}},
) )
with pytest.raises(WrennValidationError) as exc_info: with pytest.raises(WrennValidationError) as exc_info:
client.sandboxes.create() client.capsules.create()
assert exc_info.value.code == "invalid_request" assert exc_info.value.code == "invalid_request"
assert exc_info.value.status_code == 400 assert exc_info.value.status_code == 400
@respx.mock @respx.mock
def test_auth_error(self, client): def test_auth_error(self, client):
respx.get("https://api.wrenn.dev/v1/sandboxes").respond( respx.get("https://api.wrenn.dev/v1/capsules").respond(
401, 401,
json={"error": {"code": "unauthorized", "message": "bad key"}}, json={"error": {"code": "unauthorized", "message": "bad key"}},
) )
with pytest.raises(WrennAuthenticationError): with pytest.raises(WrennAuthenticationError):
client.sandboxes.list() client.capsules.list()
@respx.mock @respx.mock
def test_forbidden_error(self, client): def test_forbidden_error(self, client):
@ -291,66 +291,66 @@ class TestErrorHandling:
@respx.mock @respx.mock
def test_not_found_error(self, client): def test_not_found_error(self, client):
respx.get("https://api.wrenn.dev/v1/sandboxes/nope").respond( respx.get("https://api.wrenn.dev/v1/capsules/nope").respond(
404, 404,
json={"error": {"code": "not_found", "message": "sandbox not found"}}, json={"error": {"code": "not_found", "message": "capsule not found"}},
) )
with pytest.raises(WrennNotFoundError): with pytest.raises(WrennNotFoundError):
client.sandboxes.get("nope") client.capsules.get("nope")
@respx.mock @respx.mock
def test_conflict_error(self, client): def test_conflict_error(self, client):
respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond( respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond(
409, 409,
json={"error": {"code": "invalid_state", "message": "not running"}}, json={"error": {"code": "invalid_state", "message": "not running"}},
) )
with pytest.raises(WrennConflictError): with pytest.raises(WrennConflictError):
client.sandboxes.get("sb-1") client.capsules.get("sb-1")
@respx.mock @respx.mock
def test_host_has_sandboxes_error(self, client): def test_host_has_capsules_error(self, client):
respx.delete("https://api.wrenn.dev/v1/hosts/h-1").respond( respx.delete("https://api.wrenn.dev/v1/hosts/h-1").respond(
409, 409,
json={ json={
"error": { "error": {
"code": "host_has_sandboxes", "code": "host_has_capsules",
"message": "host has running sandboxes", "message": "host has running capsules",
}, },
"sandbox_ids": ["sb-1", "sb-2"], "sandbox_ids": ["sb-1", "sb-2"],
}, },
) )
with pytest.raises(WrennHostHasSandboxesError) as exc_info: with pytest.raises(WrennHostHasCapsulesError) as exc_info:
client.hosts.delete("h-1") client.hosts.delete("h-1")
assert exc_info.value.sandbox_ids == ["sb-1", "sb-2"] assert exc_info.value.capsule_ids == ["sb-1", "sb-2"]
@respx.mock @respx.mock
def test_agent_error(self, client): def test_agent_error(self, client):
respx.post("https://api.wrenn.dev/v1/sandboxes").respond( respx.post("https://api.wrenn.dev/v1/capsules").respond(
502, 502,
json={"error": {"code": "agent_error", "message": "host agent failed"}}, json={"error": {"code": "agent_error", "message": "host agent failed"}},
) )
with pytest.raises(WrennAgentError): with pytest.raises(WrennAgentError):
client.sandboxes.create() client.capsules.create()
@respx.mock @respx.mock
def test_internal_error(self, client): def test_internal_error(self, client):
respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond( respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond(
500, 500,
json={"error": {"code": "internal_error", "message": "oops"}}, json={"error": {"code": "internal_error", "message": "oops"}},
) )
with pytest.raises(WrennInternalError): with pytest.raises(WrennInternalError):
client.sandboxes.get("sb-1") client.capsules.get("sb-1")
@respx.mock @respx.mock
def test_unknown_error_code_falls_back(self, client): def test_unknown_error_code_falls_back(self, client):
respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond( respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond(
418, 418,
json={"error": {"code": "teapot", "message": "I'm a teapot"}}, json={"error": {"code": "teapot", "message": "I'm a teapot"}},
) )
from wrenn.exceptions import WrennError from wrenn.exceptions import WrennError
with pytest.raises(WrennError) as exc_info: with pytest.raises(WrennError) as exc_info:
client.sandboxes.get("sb-1") client.capsules.get("sb-1")
assert exc_info.value.code == "teapot" assert exc_info.value.code == "teapot"
@ -379,22 +379,22 @@ class TestAuthModes:
class TestAsyncClient: class TestAsyncClient:
@pytest.mark.asyncio @pytest.mark.asyncio
@respx.mock @respx.mock
async def test_async_sandboxes_create(self, async_client): async def test_async_capsules_create(self, async_client):
async with async_client: async with async_client:
respx.post("https://api.wrenn.dev/v1/sandboxes").respond( respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, json={"id": "sb-1", "status": "pending"} 201, json={"id": "sb-1", "status": "pending"}
) )
resp = await async_client.sandboxes.create(template="base-python") resp = await async_client.capsules.create(template="base-python")
assert resp.id == "sb-1" assert resp.id == "sb-1"
@pytest.mark.asyncio @pytest.mark.asyncio
@respx.mock @respx.mock
async def test_async_sandboxes_list(self, async_client): async def test_async_capsules_list(self, async_client):
async with async_client: async with async_client:
respx.get("https://api.wrenn.dev/v1/sandboxes").respond( respx.get("https://api.wrenn.dev/v1/capsules").respond(
200, json=[{"id": "sb-1"}] 200, json=[{"id": "sb-1"}]
) )
boxes = await async_client.sandboxes.list() boxes = await async_client.capsules.list()
assert len(boxes) == 1 assert len(boxes) == 1
@pytest.mark.asyncio @pytest.mark.asyncio
@ -409,9 +409,9 @@ class TestAsyncClient:
@respx.mock @respx.mock
async def test_async_error_handling(self, async_client): async def test_async_error_handling(self, async_client):
async with async_client: async with async_client:
respx.get("https://api.wrenn.dev/v1/sandboxes/nope").respond( respx.get("https://api.wrenn.dev/v1/capsules/nope").respond(
404, 404,
json={"error": {"code": "not_found", "message": "not found"}}, json={"error": {"code": "not_found", "message": "not found"}},
) )
with pytest.raises(WrennNotFoundError): with pytest.raises(WrennNotFoundError):
await async_client.sandboxes.get("nope") await async_client.capsules.get("nope")

View File

@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
import respx import respx
from wrenn.capsule import Capsule
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.models import FileEntry from wrenn.models import FileEntry
from wrenn.pty import ( from wrenn.pty import (
@ -15,7 +16,6 @@ from wrenn.pty import (
PtySession, PtySession,
_parse_pty_event, _parse_pty_event,
) )
from wrenn.sandbox import Sandbox
@pytest.fixture @pytest.fixture
@ -24,18 +24,18 @@ def client():
yield c yield c
def _make_sandbox(client: WrennClient, sb_id: str = "cl-abc") -> Sandbox: def _make_capsule(client: WrennClient, cap_id: str = "cl-abc") -> Capsule:
respx.post("https://api.wrenn.dev/v1/sandboxes").respond( respx.post("https://api.wrenn.dev/v1/capsules").respond(
201, json={"id": sb_id, "status": "running"} 201, json={"id": cap_id, "status": "running"}
) )
return client.sandboxes.create() return client.capsules.create()
class TestListDir: class TestListDir:
@respx.mock @respx.mock
def test_list_dir_returns_entries(self, client): def test_list_dir_returns_entries(self, client):
sb = _make_sandbox(client) cap = _make_capsule(client)
respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list").respond( respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond(
200, 200,
json={ json={
"entries": [ "entries": [
@ -66,7 +66,7 @@ class TestListDir:
] ]
}, },
) )
entries = sb.list_dir("/home/user") entries = cap.list_dir("/home/user")
assert len(entries) == 2 assert len(entries) == 2
assert isinstance(entries[0], FileEntry) assert isinstance(entries[0], FileEntry)
assert entries[0].name == "main.py" assert entries[0].name == "main.py"
@ -76,27 +76,27 @@ class TestListDir:
@respx.mock @respx.mock
def test_list_dir_with_depth(self, client): def test_list_dir_with_depth(self, client):
sb = _make_sandbox(client) cap = _make_capsule(client)
route = respx.post( route = respx.post(
"https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list" "https://api.wrenn.dev/v1/capsules/cl-abc/files/list"
).respond(200, json={"entries": []}) ).respond(200, json={"entries": []})
sb.list_dir("/home/user", depth=3) cap.list_dir("/home/user", depth=3)
body = json.loads(route.calls[0].request.content) body = json.loads(route.calls[0].request.content)
assert body["depth"] == 3 assert body["depth"] == 3
@respx.mock @respx.mock
def test_list_dir_empty(self, client): def test_list_dir_empty(self, client):
sb = _make_sandbox(client) cap = _make_capsule(client)
respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list").respond( respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond(
200, json={"entries": []} 200, json={"entries": []}
) )
entries = sb.list_dir("/empty") entries = cap.list_dir("/empty")
assert entries == [] assert entries == []
@respx.mock @respx.mock
def test_list_dir_symlink(self, client): def test_list_dir_symlink(self, client):
sb = _make_sandbox(client) cap = _make_capsule(client)
respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list").respond( respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond(
200, 200,
json={ json={
"entries": [ "entries": [
@ -115,7 +115,7 @@ class TestListDir:
] ]
}, },
) )
entries = sb.list_dir("/home/user") entries = cap.list_dir("/home/user")
assert len(entries) == 1 assert len(entries) == 1
assert entries[0].type == "symlink" assert entries[0].type == "symlink"
assert entries[0].symlink_target == "/bin" assert entries[0].symlink_target == "/bin"
@ -124,8 +124,8 @@ class TestListDir:
class TestMkdir: class TestMkdir:
@respx.mock @respx.mock
def test_mkdir_returns_entry(self, client): def test_mkdir_returns_entry(self, client):
sb = _make_sandbox(client) cap = _make_capsule(client)
respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/mkdir").respond( respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/mkdir").respond(
200, 200,
json={ json={
"entry": { "entry": {
@ -142,19 +142,19 @@ class TestMkdir:
} }
}, },
) )
entry = sb.mkdir("/home/user/data") entry = cap.mkdir("/home/user/data")
assert isinstance(entry, FileEntry) assert isinstance(entry, FileEntry)
assert entry.name == "data" assert entry.name == "data"
assert entry.type == "directory" assert entry.type == "directory"
@respx.mock @respx.mock
def test_mkdir_existing_returns_gracefully(self, client): def test_mkdir_existing_returns_gracefully(self, client):
sb = _make_sandbox(client) cap = _make_capsule(client)
respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/mkdir").respond( respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/mkdir").respond(
409, 409,
json={"error": {"code": "conflict", "message": "already exists"}}, json={"error": {"code": "conflict", "message": "already exists"}},
) )
respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list").respond( respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond(
200, 200,
json={ json={
"entries": [ "entries": [
@ -173,27 +173,27 @@ class TestMkdir:
] ]
}, },
) )
entry = sb.mkdir("/home/user/data") entry = cap.mkdir("/home/user/data")
assert entry.name == "data" assert entry.name == "data"
class TestRemove: class TestRemove:
@respx.mock @respx.mock
def test_remove_succeeds(self, client): def test_remove_succeeds(self, client):
sb = _make_sandbox(client) cap = _make_capsule(client)
route = respx.post( route = respx.post(
"https://api.wrenn.dev/v1/sandboxes/cl-abc/files/remove" "https://api.wrenn.dev/v1/capsules/cl-abc/files/remove"
).respond(204) ).respond(204)
sb.remove("/home/user/old_data") cap.remove("/home/user/old_data")
assert route.called assert route.called
@respx.mock @respx.mock
def test_remove_sends_path(self, client): def test_remove_sends_path(self, client):
sb = _make_sandbox(client) cap = _make_capsule(client)
route = respx.post( route = respx.post(
"https://api.wrenn.dev/v1/sandboxes/cl-abc/files/remove" "https://api.wrenn.dev/v1/capsules/cl-abc/files/remove"
).respond(204) ).respond(204)
sb.remove("/tmp/test.txt") cap.remove("/tmp/test.txt")
body = json.loads(route.calls[0].request.content) body = json.loads(route.calls[0].request.content)
assert body["path"] == "/tmp/test.txt" assert body["path"] == "/tmp/test.txt"
@ -201,23 +201,23 @@ class TestRemove:
class TestUpload: class TestUpload:
@respx.mock @respx.mock
def test_upload_sends_multipart(self, client): def test_upload_sends_multipart(self, client):
sb = _make_sandbox(client) cap = _make_capsule(client)
route = respx.post( route = respx.post(
"https://api.wrenn.dev/v1/sandboxes/cl-abc/files/write" "https://api.wrenn.dev/v1/capsules/cl-abc/files/write"
).respond(204) ).respond(204)
sb.upload("/app/main.py", b"print('hello')") cap.upload("/app/main.py", b"print('hello')")
assert route.called assert route.called
req = route.calls[0].request req = route.calls[0].request
assert b"multipart/form-data" in req.headers.get("content-type", "").encode() assert b"multipart/form-data" in req.headers.get("content-type", "").encode()
@respx.mock @respx.mock
def test_download_returns_bytes(self, client): def test_download_returns_bytes(self, client):
sb = _make_sandbox(client) cap = _make_capsule(client)
content = b"file contents here" content = b"file contents here"
respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/read").respond( respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/read").respond(
200, content=content 200, content=content
) )
data = sb.download("/app/main.py") data = cap.download("/app/main.py")
assert data == content assert data == content
@ -500,7 +500,8 @@ class TestExports:
assert APS is not None assert APS is not None
def test_pty_event_importable(self): def test_pty_event_importable(self):
from wrenn import PtyEvent as PE, PtyEventType as PET from wrenn import PtyEvent as PE
from wrenn import PtyEventType as PET
assert PE is not None assert PE is not None
assert PET is not None assert PET is not None

View File

@ -64,74 +64,74 @@ def bearer_client() -> Generator[WrennClient, None, None]:
@requires_auth @requires_auth
class TestSandboxLifecycle: class TestCapsuleLifecycle:
def test_create_exec_destroy(self, client): def test_create_exec_destroy(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
result = sb.exec("echo", args=["hello"]) result = cap.exec("echo", args=["hello"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "hello" in result.stdout assert "hello" in result.stdout
def test_exec_with_args(self, client): def test_exec_with_args(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
result = sb.exec("echo", args=["hello", "world"]) result = cap.exec("echo", args=["hello", "world"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "hello world" in result.stdout assert "hello world" in result.stdout
def test_exec_nonzero_exit(self, client): def test_exec_nonzero_exit(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
result = sb.exec("sh", args=["-c", "exit 42"]) result = cap.exec("sh", args=["-c", "exit 42"])
assert result.exit_code == 42 assert result.exit_code == 42
def test_exec_stderr(self, client): def test_exec_stderr(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
result = sb.exec("sh", args=["-c", "echo err>&2"]) result = cap.exec("sh", args=["-c", "echo err>&2"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "err" in result.stderr assert "err" in result.stderr
def test_context_manager_cleanup(self, client): def test_context_manager_cleanup(self, client):
sb = client.sandboxes.create(template="minimal", timeout_sec=120) cap = client.capsules.create(template="minimal", timeout_sec=120)
sb_id = sb.id cap_id = cap.id
with sb: with cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
fetched = client.sandboxes.get(sb_id) fetched = client.capsules.get(cap_id)
assert fetched.status in ("stopped", "destroyed") assert fetched.status in ("stopped", "destroyed")
@requires_auth @requires_auth
class TestFileIO: class TestFileIO:
def test_upload_and_download(self, client): def test_upload_and_download(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
content = b"Hello from integration test!" content = b"Hello from integration test!"
sb.upload("/tmp/test_file.txt", content) cap.upload("/tmp/test_file.txt", content)
downloaded = sb.download("/tmp/test_file.txt") downloaded = cap.download("/tmp/test_file.txt")
assert downloaded == content assert downloaded == content
def test_download_nonexistent_file(self, client): def test_download_nonexistent_file(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
with pytest.raises(Exception): with pytest.raises(Exception):
sb.download("/tmp/no_such_file_12345") cap.download("/tmp/no_such_file_12345")
@requires_auth @requires_auth
class TestPauseResume: class TestPauseResume:
def test_pause_and_resume(self, client): def test_pause_and_resume(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
sb.pause() cap.pause()
assert sb.status == "paused" assert cap.status == "paused"
sb.resume() cap.resume()
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
result = sb.exec("echo", args=["resumed"]) result = cap.exec("echo", args=["resumed"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "resumed" in result.stdout assert "resumed" in result.stdout
@ -139,10 +139,10 @@ class TestPauseResume:
@requires_auth @requires_auth
class TestPing: class TestPing:
def test_ping_resets_timer(self, client): def test_ping_resets_timer(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
sb.ping() cap.ping()
result = sb.exec("echo", args=["still_alive"]) result = cap.exec("echo", args=["still_alive"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "still_alive" in result.stdout assert "still_alive" in result.stdout
@ -150,32 +150,32 @@ class TestPing:
@requires_auth @requires_auth
class TestProxy: class TestProxy:
def test_get_url(self, client): def test_get_url(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
url = sb.get_url(8888) url = cap.get_url(8888)
assert sb.id in url assert cap.id in url
assert "8888" in url assert "8888" in url
@requires_auth @requires_auth
class TestListAndGet: class TestListAndGet:
def test_list_sandboxes(self, client): def test_list_capsules(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
boxes = client.sandboxes.list() boxes = client.capsules.list()
ids = [b.id for b in boxes] ids = [b.id for b in boxes]
assert sb.id in ids assert cap.id in ids
def test_get_existing_sandbox(self, client): def test_get_existing_capsule(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
fetched = client.sandboxes.get(sb.id) fetched = client.capsules.get(cap.id)
assert fetched.id == sb.id assert fetched.id == cap.id
assert fetched.status == "running" assert fetched.status == "running"
def test_get_nonexistent_sandbox(self, client): def test_get_nonexistent_capsule(self, client):
with pytest.raises((WrennNotFoundError, WrennValidationError)): with pytest.raises((WrennNotFoundError, WrennValidationError)):
client.sandboxes.get("cl-nonexistent00000000000000000") client.capsules.get("cl-nonexistent00000000000000000")
@requires_auth @requires_auth
@ -204,117 +204,117 @@ class TestAPIKeys:
@requires_auth @requires_auth
class TestRunCode: class TestRunCode:
def test_basic_execution(self, client): def test_basic_execution(self, client):
with client.sandboxes.create( with client.capsules.create(
template="python-interpreter-v0-beta", timeout_sec=120 template="python-interpreter-v0-beta", timeout_sec=120
) as sb: ) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
r = sb.run_code("x = 42") r = cap.run_code("x = 42")
assert r.error is None assert r.error is None
r = sb.run_code("x * 2") r = cap.run_code("x * 2")
assert r.text == "84" assert r.text == "84"
def test_state_persists(self, client): def test_state_persists(self, client):
with client.sandboxes.create( with client.capsules.create(
template="python-interpreter-v0-beta", timeout_sec=120 template="python-interpreter-v0-beta", timeout_sec=120
) as sb: ) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
sb.run_code("def greet(name): return f'hello {name}'") cap.run_code("def greet(name): return f'hello {name}'")
r = sb.run_code("greet('sandbox')") r = cap.run_code("greet('capsule')")
assert "hello sandbox" in (r.text or "") assert "hello capsule" in (r.text or "")
def test_error_traceback(self, client): def test_error_traceback(self, client):
with client.sandboxes.create( with client.capsules.create(
template="python-interpreter-v0-beta", timeout_sec=120 template="python-interpreter-v0-beta", timeout_sec=120
) as sb: ) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
r = sb.run_code("1/0") r = cap.run_code("1/0")
assert r.error is not None assert r.error is not None
assert "ZeroDivisionError" in r.error assert "ZeroDivisionError" in r.error
def test_stdout_capture(self, client): def test_stdout_capture(self, client):
with client.sandboxes.create( with client.capsules.create(
template="python-interpreter-v0-beta", timeout_sec=120 template="python-interpreter-v0-beta", timeout_sec=120
) as sb: ) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
r = sb.run_code("print('hello from kernel')") r = cap.run_code("print('hello from kernel')")
assert "hello from kernel" in r.stdout assert "hello from kernel" in r.stdout
@requires_auth @requires_auth
class TestAsyncSandboxLifecycle: class TestAsyncCapsuleLifecycle:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_async_create_exec_destroy(self, async_client): async def test_async_create_exec_destroy(self, async_client):
async with async_client: async with async_client:
sb = await async_client.sandboxes.create( cap = await async_client.capsules.create(
template="minimal", timeout_sec=120 template="minimal", timeout_sec=120
) )
try: try:
await sb.async_wait_ready(timeout=60, interval=1) await cap.async_wait_ready(timeout=60, interval=1)
result = await sb.async_exec("echo", args=["async_hello"]) result = await cap.async_exec("echo", args=["async_hello"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "async_hello" in result.stdout assert "async_hello" in result.stdout
finally: finally:
await sb.async_destroy() await cap.async_destroy()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_async_upload_download(self, async_client): async def test_async_upload_download(self, async_client):
async with async_client: async with async_client:
sb = await async_client.sandboxes.create( cap = await async_client.capsules.create(
template="minimal", timeout_sec=120 template="minimal", timeout_sec=120
) )
try: try:
await sb.async_wait_ready(timeout=60, interval=1) await cap.async_wait_ready(timeout=60, interval=1)
content = b"Async upload test" content = b"Async upload test"
await sb.async_upload("/tmp/async_test.txt", content) await cap.async_upload("/tmp/async_test.txt", content)
downloaded = await sb.async_download("/tmp/async_test.txt") downloaded = await cap.async_download("/tmp/async_test.txt")
assert downloaded == content assert downloaded == content
finally: finally:
await sb.async_destroy() await cap.async_destroy()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_async_run_code(self, async_client): async def test_async_run_code(self, async_client):
async with async_client: async with async_client:
sb = await async_client.sandboxes.create( cap = await async_client.capsules.create(
template="python-interpreter-v0-beta", timeout_sec=120 template="python-interpreter-v0-beta", timeout_sec=120
) )
try: try:
await sb.async_wait_ready(timeout=60, interval=1) await cap.async_wait_ready(timeout=60, interval=1)
r = await sb.async_run_code("42 * 2") r = await cap.async_run_code("42 * 2")
assert r.text == "84" assert r.text == "84"
finally: finally:
await sb.async_destroy() await cap.async_destroy()
@requires_auth @requires_auth
class TestFilesystemListDir: class TestFilesystemListDir:
def test_list_dir_root(self, client: WrennClient): def test_list_dir_root(self, client: WrennClient):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
sb.mkdir("/tmp/ls_test_root") cap.mkdir("/tmp/ls_test_root")
sb.upload("/tmp/ls_test_root/hello.txt", b"hello") cap.upload("/tmp/ls_test_root/hello.txt", b"hello")
entries = sb.list_dir("/tmp/ls_test_root") entries = cap.list_dir("/tmp/ls_test_root")
assert isinstance(entries, list) assert isinstance(entries, list)
names = [e.name for e in entries] names = [e.name for e in entries]
assert "hello.txt" in names assert "hello.txt" in names
def test_list_dir_after_mkdir(self, client): def test_list_dir_after_mkdir(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
sb.mkdir("/tmp/fs_test_dir") cap.mkdir("/tmp/fs_test_dir")
entries = sb.list_dir("/tmp") entries = cap.list_dir("/tmp")
names = [e.name for e in entries] names = [e.name for e in entries]
assert "fs_test_dir" in names assert "fs_test_dir" in names
def test_list_dir_file_metadata(self, client): def test_list_dir_file_metadata(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
sb.upload("/tmp/meta_test.txt", b"hello world") cap.upload("/tmp/meta_test.txt", b"hello world")
entries = sb.list_dir("/tmp") entries = cap.list_dir("/tmp")
match = [e for e in entries if e.name == "meta_test.txt"] match = [e for e in entries if e.name == "meta_test.txt"]
assert len(match) == 1 assert len(match) == 1
f = match[0] f = match[0]
@ -326,100 +326,100 @@ class TestFilesystemListDir:
assert f.modified_at is not None assert f.modified_at is not None
def test_list_dir_depth(self, client): def test_list_dir_depth(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
sb.mkdir("/tmp/depth_a/depth_b") cap.mkdir("/tmp/depth_a/depth_b")
sb.upload("/tmp/depth_a/depth_b/nested.txt", b"deep") cap.upload("/tmp/depth_a/depth_b/nested.txt", b"deep")
entries = sb.list_dir("/tmp/depth_a", depth=2) entries = cap.list_dir("/tmp/depth_a", depth=2)
paths = [e.path for e in entries] paths = [e.path for e in entries]
assert any("nested.txt" in p for p in paths) assert any("nested.txt" in p for p in paths)
def test_list_dir_empty_directory(self, client): def test_list_dir_empty_directory(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
sb.mkdir("/tmp/empty_dir_test") cap.mkdir("/tmp/empty_dir_test")
entries = sb.list_dir("/tmp/empty_dir_test") entries = cap.list_dir("/tmp/empty_dir_test")
assert entries == [] assert entries == []
@requires_auth @requires_auth
class TestFilesystemMkdir: class TestFilesystemMkdir:
def test_mkdir_creates_directory(self, client): def test_mkdir_creates_directory(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
entry = sb.mkdir("/tmp/mkdir_test") entry = cap.mkdir("/tmp/mkdir_test")
assert entry.name == "mkdir_test" assert entry.name == "mkdir_test"
assert entry.type == "directory" assert entry.type == "directory"
assert entry.path == "/tmp/mkdir_test" assert entry.path == "/tmp/mkdir_test"
def test_mkdir_creates_parents(self, client): def test_mkdir_creates_parents(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
entry = sb.mkdir("/tmp/a/b/c/d") entry = cap.mkdir("/tmp/a/b/c/d")
assert entry.type == "directory" assert entry.type == "directory"
def test_mkdir_already_exists(self, client: WrennClient): def test_mkdir_already_exists(self, client: WrennClient):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
sb.mkdir("/tmp/exist_test") cap.mkdir("/tmp/exist_test")
entry = sb.mkdir("/tmp/exist_test") entry = cap.mkdir("/tmp/exist_test")
assert entry.type == "directory" assert entry.type == "directory"
@requires_auth @requires_auth
class TestFilesystemRemove: class TestFilesystemRemove:
def test_remove_file(self, client): def test_remove_file(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
sb.upload("/tmp/rm_test.txt", b"delete me") cap.upload("/tmp/rm_test.txt", b"delete me")
entries_before = sb.list_dir("/tmp") entries_before = cap.list_dir("/tmp")
assert any(e.name == "rm_test.txt" for e in entries_before) assert any(e.name == "rm_test.txt" for e in entries_before)
sb.remove("/tmp/rm_test.txt") cap.remove("/tmp/rm_test.txt")
entries_after = sb.list_dir("/tmp") entries_after = cap.list_dir("/tmp")
assert not any(e.name == "rm_test.txt" for e in entries_after) assert not any(e.name == "rm_test.txt" for e in entries_after)
def test_remove_directory(self, client): def test_remove_directory(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
sb.mkdir("/tmp/rm_dir_test") cap.mkdir("/tmp/rm_dir_test")
sb.upload("/tmp/rm_dir_test/file.txt", b"inside") cap.upload("/tmp/rm_dir_test/file.txt", b"inside")
sb.remove("/tmp/rm_dir_test") cap.remove("/tmp/rm_dir_test")
entries = sb.list_dir("/tmp") entries = cap.list_dir("/tmp")
assert not any(e.name == "rm_dir_test" for e in entries) assert not any(e.name == "rm_dir_test" for e in entries)
def test_upload_download_remove_roundtrip(self, client): def test_upload_download_remove_roundtrip(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
content = b"round trip test data " * 100 content = b"round trip test data " * 100
sb.upload("/tmp/rt.txt", content) cap.upload("/tmp/rt.txt", content)
downloaded = sb.download("/tmp/rt.txt") downloaded = cap.download("/tmp/rt.txt")
assert downloaded == content assert downloaded == content
sb.remove("/tmp/rt.txt") cap.remove("/tmp/rt.txt")
with pytest.raises(Exception): with pytest.raises(Exception):
sb.download("/tmp/rt.txt") cap.download("/tmp/rt.txt")
@requires_auth @requires_auth
class TestStreamUploadDownload: class TestStreamUploadDownload:
def test_stream_upload_and_download(self, client: WrennClient): def test_stream_upload_and_download(self, client: WrennClient):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
chunks = [b"chunk0_", b"chunk1_", b"chunk2"] chunks = [b"chunk0_", b"chunk1_", b"chunk2"]
def data_gen(): def data_gen():
yield from chunks yield from chunks
sb.stream_upload("/tmp/stream_test.bin", data_gen()) cap.stream_upload("/tmp/stream_test.bin", data_gen())
downloaded = sb.download("/tmp/stream_test.bin") downloaded = cap.download("/tmp/stream_test.bin")
assert downloaded == b"chunk0_chunk1_chunk2" assert downloaded == b"chunk0_chunk1_chunk2"
def test_stream_download_large(self, client): def test_stream_download_large(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
content = b"x" * 65536 * 3 content = b"x" * 65536 * 3
sb.upload("/tmp/large.bin", content) cap.upload("/tmp/large.bin", content)
collected = b"" collected = b""
for chunk in sb.stream_download("/tmp/large.bin"): for chunk in cap.stream_download("/tmp/large.bin"):
collected += chunk collected += chunk
assert collected == content assert collected == content
@ -427,9 +427,9 @@ class TestStreamUploadDownload:
@requires_auth @requires_auth
class TestPty: class TestPty:
def test_pty_basic_output(self, client): def test_pty_basic_output(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
with sb.pty(cmd="/bin/sh", cwd="/tmp") as term: with cap.pty(cmd="/bin/sh", cwd="/tmp") as term:
term.write(b"echo pty_hello\n") term.write(b"echo pty_hello\n")
output = b"" output = b""
for event in term: for event in term:
@ -442,9 +442,9 @@ class TestPty:
assert b"pty_hello" in output assert b"pty_hello" in output
def test_pty_tag_and_pid(self, client): def test_pty_tag_and_pid(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
with sb.pty(cmd="/bin/sh") as term: with cap.pty(cmd="/bin/sh") as term:
started = False started = False
for event in term: for event in term:
if event.type == PtyEventType.started: if event.type == PtyEventType.started:
@ -459,18 +459,18 @@ class TestPty:
assert started assert started
def test_pty_exit_on_command_exit(self, client): def test_pty_exit_on_command_exit(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
with sb.pty(cmd="/bin/echo", args=["immediate"]) as term: with cap.pty(cmd="/bin/echo", args=["immediate"]) as term:
events = list(term) events = list(term)
types = [e.type for e in events] types = [e.type for e in events]
assert PtyEventType.started in types assert PtyEventType.started in types
assert PtyEventType.output in types or PtyEventType.exit in types assert PtyEventType.output in types or PtyEventType.exit in types
def test_pty_resize(self, client): def test_pty_resize(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
with sb.pty(cmd="/bin/sh", cols=80, rows=24) as term: with cap.pty(cmd="/bin/sh", cols=80, rows=24) as term:
for event in term: for event in term:
if event.type == PtyEventType.started: if event.type == PtyEventType.started:
term.resize(120, 40) term.resize(120, 40)
@ -479,9 +479,9 @@ class TestPty:
break break
def test_pty_envs(self, client): def test_pty_envs(self, client):
with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
sb.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)
with sb.pty(cmd="/bin/sh", envs={"MY_VAR": "hello_env"}) as term: with cap.pty(cmd="/bin/sh", envs={"MY_VAR": "hello_env"}) as term:
output = b"" output = b""
for event in term: for event in term:
if event.type == PtyEventType.started: if event.type == PtyEventType.started:
@ -500,69 +500,69 @@ class TestAsyncFilesystem:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_async_list_dir(self, async_client): async def test_async_list_dir(self, async_client):
async with async_client: async with async_client:
sb = await async_client.sandboxes.create( cap = await async_client.capsules.create(
template="minimal", timeout_sec=120 template="minimal", timeout_sec=120
) )
try: try:
await sb.async_wait_ready(timeout=60, interval=1) await cap.async_wait_ready(timeout=60, interval=1)
await sb.async_mkdir("/tmp/async_ls_test") await cap.async_mkdir("/tmp/async_ls_test")
await sb.async_upload("/tmp/async_ls_test/file.txt", b"data") await cap.async_upload("/tmp/async_ls_test/file.txt", b"data")
entries = await sb.async_list_dir("/tmp/async_ls_test") entries = await cap.async_list_dir("/tmp/async_ls_test")
assert isinstance(entries, list) assert isinstance(entries, list)
assert any(e.name == "file.txt" for e in entries) assert any(e.name == "file.txt" for e in entries)
finally: finally:
await sb.async_destroy() await cap.async_destroy()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_async_mkdir(self, async_client): async def test_async_mkdir(self, async_client):
async with async_client: async with async_client:
sb = await async_client.sandboxes.create( cap = await async_client.capsules.create(
template="minimal", timeout_sec=120 template="minimal", timeout_sec=120
) )
try: try:
await sb.async_wait_ready(timeout=60, interval=1) await cap.async_wait_ready(timeout=60, interval=1)
entry = await sb.async_mkdir("/tmp/async_mkdir_test") entry = await cap.async_mkdir("/tmp/async_mkdir_test")
assert entry.type == "directory" assert entry.type == "directory"
assert entry.name == "async_mkdir_test" assert entry.name == "async_mkdir_test"
finally: finally:
await sb.async_destroy() await cap.async_destroy()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_async_remove(self, async_client): async def test_async_remove(self, async_client):
async with async_client: async with async_client:
sb = await async_client.sandboxes.create( cap = await async_client.capsules.create(
template="minimal", timeout_sec=120 template="minimal", timeout_sec=120
) )
try: try:
await sb.async_wait_ready(timeout=60, interval=1) await cap.async_wait_ready(timeout=60, interval=1)
await sb.async_upload("/tmp/async_rm.txt", b"bye") await cap.async_upload("/tmp/async_rm.txt", b"bye")
entries = await sb.async_list_dir("/tmp") entries = await cap.async_list_dir("/tmp")
assert any(e.name == "async_rm.txt" for e in entries) assert any(e.name == "async_rm.txt" for e in entries)
await sb.async_remove("/tmp/async_rm.txt") await cap.async_remove("/tmp/async_rm.txt")
entries = await sb.async_list_dir("/tmp") entries = await cap.async_list_dir("/tmp")
assert not any(e.name == "async_rm.txt" for e in entries) assert not any(e.name == "async_rm.txt" for e in entries)
finally: finally:
await sb.async_destroy() await cap.async_destroy()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_async_full_filesystem_roundtrip(self, async_client): async def test_async_full_filesystem_roundtrip(self, async_client):
async with async_client: async with async_client:
sb = await async_client.sandboxes.create( cap = await async_client.capsules.create(
template="minimal", timeout_sec=120 template="minimal", timeout_sec=120
) )
try: try:
await sb.async_wait_ready(timeout=60, interval=1) await cap.async_wait_ready(timeout=60, interval=1)
await sb.async_mkdir("/tmp/async_rt") await cap.async_mkdir("/tmp/async_rt")
await sb.async_upload("/tmp/async_rt/file.txt", b"async content") await cap.async_upload("/tmp/async_rt/file.txt", b"async content")
entries = await sb.async_list_dir("/tmp/async_rt") entries = await cap.async_list_dir("/tmp/async_rt")
assert any(e.name == "file.txt" for e in entries) assert any(e.name == "file.txt" for e in entries)
data = await sb.async_download("/tmp/async_rt/file.txt") data = await cap.async_download("/tmp/async_rt/file.txt")
assert data == b"async content" assert data == b"async content"
await sb.async_remove("/tmp/async_rt/file.txt") await cap.async_remove("/tmp/async_rt/file.txt")
entries = await sb.async_list_dir("/tmp/async_rt") entries = await cap.async_list_dir("/tmp/async_rt")
assert not any(e.name == "file.txt" for e in entries) assert not any(e.name == "file.txt" for e in entries)
finally: finally:
await sb.async_destroy() await cap.async_destroy()