v0.1.4 #9
@ -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
|
||||||
|
|||||||
262
api/openapi.yaml
262
api/openapi.yaml
@ -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:
|
||||||
|
|||||||
@ -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
1171
src/wrenn/capsule.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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()
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
1197
src/wrenn/sandbox.py
1197
src/wrenn/sandbox.py
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user