Updated SDK to match v0.1.1

This commit is contained in:
Tasnim Kabir Sadik
2026-04-20 02:51:58 +06:00
parent 2002c3f7a7
commit c4296ddd22
9 changed files with 1733 additions and 248 deletions

View File

@ -2,7 +2,7 @@ openapi: "3.1.0"
info: info:
title: Wrenn 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.2"
servers: servers:
- url: http://localhost:8080 - url: http://localhost:8080
@ -16,6 +16,10 @@ paths:
summary: Create a new account summary: Create a new account
operationId: signup operationId: signup
tags: [auth] tags: [auth]
description: |
Creates an inactive user account and sends an activation email.
The user must activate their account within 30 minutes.
Does not return a JWT — the user must activate first, then sign in.
requestBody: requestBody:
required: true required: true
content: content:
@ -24,11 +28,11 @@ paths:
$ref: "#/components/schemas/SignupRequest" $ref: "#/components/schemas/SignupRequest"
responses: responses:
"201": "201":
description: Account created description: Account created, activation email sent
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/AuthResponse" $ref: "#/components/schemas/SignupResponse"
"400": "400":
description: Invalid request (bad email, short password) description: Invalid request (bad email, short password)
content: content:
@ -36,7 +40,39 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "409":
description: Email already registered description: Email already registered or signup cooldown active
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/auth/activate:
post:
summary: Activate account via email token
operationId: activate
tags: [auth]
description: |
Consumes the activation token sent via email and activates the user account.
Creates a default team and returns a JWT to log the user in.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [token]
properties:
token:
type: string
responses:
"200":
description: Account activated, JWT issued
content:
application/json:
schema:
$ref: "#/components/schemas/AuthResponse"
"400":
description: Invalid or expired token
content: content:
application/json: application/json:
schema: schema:
@ -175,6 +211,252 @@ paths:
"302": "302":
description: Redirect to frontend with token or error description: Redirect to frontend with token or error
/v1/me:
get:
summary: Get current user profile
operationId: getMe
tags: [account]
security:
- bearerAuth: []
responses:
"200":
description: User profile
content:
application/json:
schema:
$ref: "#/components/schemas/MeResponse"
patch:
summary: Update display name
operationId: updateName
tags: [account]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
minLength: 1
maxLength: 100
responses:
"200":
description: Name updated, new JWT issued
content:
application/json:
schema:
$ref: "#/components/schemas/AuthResponse"
"400":
description: Invalid name
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
delete:
summary: Delete current account
operationId: deleteAccount
tags: [account]
security:
- bearerAuth: []
description: |
Soft-deletes the account (sets status=deleted, deleted_at=now).
The account is permanently removed after 15 days. Blocked if the user
owns any team that has other members.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [confirmation]
properties:
confirmation:
type: string
description: Must match the user's email address (case-insensitive)
responses:
"204":
description: Account scheduled for deletion
"400":
description: Confirmation does not match email
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: User owns teams with other members
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/me/password:
post:
summary: Change or add password
operationId: changePassword
tags: [account]
security:
- bearerAuth: []
description: |
For users with an existing password: requires `current_password` and `new_password`.
For OAuth-only users adding a password: requires `new_password` and `confirm_password`.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ChangePasswordRequest"
responses:
"204":
description: Password updated
"400":
description: Invalid request (short password, mismatch, etc.)
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: Current password is incorrect
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/me/password/reset:
post:
summary: Request a password reset email
operationId: requestPasswordReset
tags: [account]
description: |
Sends a password reset link to the given email. Always returns 200
regardless of whether the email exists, to prevent account enumeration.
The reset token expires in 15 minutes.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email:
type: string
format: email
responses:
"204":
description: Request accepted (email sent if account exists)
/v1/me/password/reset/confirm:
post:
summary: Confirm password reset
operationId: confirmPasswordReset
tags: [account]
description: |
Consumes a password reset token and sets a new password. The token is
single-use and expires after 15 minutes.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [token, new_password]
properties:
token:
type: string
description: Raw reset token from the email link
new_password:
type: string
minLength: 8
responses:
"204":
description: Password reset successful
"400":
description: Invalid or expired token, or password too short
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/me/providers/{provider}/connect:
parameters:
- name: provider
in: path
required: true
schema:
type: string
enum: [github]
description: OAuth provider name
get:
summary: Initiate OAuth provider link
operationId: connectProvider
tags: [account]
security:
- bearerAuth: []
description: |
Sets OAuth state and link cookies, then returns the provider's
authorization URL. The frontend navigates to this URL to start the
OAuth flow. On callback, the provider is linked to the current account
(not a new registration).
responses:
"200":
description: Authorization URL
content:
application/json:
schema:
type: object
properties:
auth_url:
type: string
format: uri
"404":
description: Provider not found or not configured
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/me/providers/{provider}:
parameters:
- name: provider
in: path
required: true
schema:
type: string
enum: [github]
description: OAuth provider name
delete:
summary: Disconnect an OAuth provider
operationId: disconnectProvider
tags: [account]
security:
- bearerAuth: []
description: |
Unlinks the OAuth provider from the current account. Blocked if this
is the user's only login method (no password and no other providers).
responses:
"204":
description: Provider disconnected
"400":
description: Cannot disconnect last login method
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: Provider not connected
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/api-keys: /v1/api-keys:
post: post:
summary: Create an API key summary: Create an API key
@ -639,6 +921,38 @@ paths:
"400": "400":
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
/v1/capsules/usage:
get:
summary: Get daily CPU and RAM usage for your team
operationId: getCapsuleUsage
tags: [capsules]
security:
- apiKeyAuth: []
parameters:
- name: from
in: query
required: false
schema:
type: string
format: date
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: to
in: query
required: false
schema:
type: string
format: date
description: End date (YYYY-MM-DD). Defaults to today.
responses:
"200":
description: Daily usage data for the team
content:
application/json:
schema:
$ref: "#/components/schemas/UsageResponse"
"400":
$ref: "#/components/responses/BadRequest"
/v1/capsules/{id}: /v1/capsules/{id}:
parameters: parameters:
- name: id - name: id
@ -699,11 +1013,17 @@ paths:
$ref: "#/components/schemas/ExecRequest" $ref: "#/components/schemas/ExecRequest"
responses: responses:
"200": "200":
description: Command output description: Command output (foreground exec)
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/ExecResponse" $ref: "#/components/schemas/ExecResponse"
"202":
description: Background process started
content:
application/json:
schema:
$ref: "#/components/schemas/BackgroundExecResponse"
"404": "404":
description: Capsule not found description: Capsule not found
content: content:
@ -717,6 +1037,122 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/capsules/{id}/processes:
parameters:
- name: id
in: path
required: true
schema:
type: string
get:
summary: List running processes
operationId: listProcesses
tags: [capsules]
security:
- apiKeyAuth: []
description: |
Returns all running processes inside the capsule, including background
processes and any processes started by templates or init scripts.
responses:
"200":
description: Process list
content:
application/json:
schema:
$ref: "#/components/schemas/ProcessListResponse"
"404":
description: Capsule not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: Capsule not running
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/capsules/{id}/processes/{selector}:
parameters:
- name: id
in: path
required: true
schema:
type: string
- name: selector
in: path
required: true
description: Process PID (numeric) or tag (string)
schema:
type: string
delete:
summary: Kill a process
operationId: killProcess
tags: [capsules]
security:
- apiKeyAuth: []
parameters:
- name: signal
in: query
required: false
description: Signal to send (SIGKILL or SIGTERM, default SIGKILL)
schema:
type: string
enum: [SIGKILL, SIGTERM]
default: SIGKILL
responses:
"204":
description: Process killed
"404":
description: Capsule or process not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: Capsule not running
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/capsules/{id}/processes/{selector}/stream:
parameters:
- name: id
in: path
required: true
schema:
type: string
- name: selector
in: path
required: true
description: Process PID (numeric) or tag (string)
schema:
type: string
get:
summary: Stream process output via WebSocket
operationId: connectProcess
tags: [capsules]
security:
- apiKeyAuth: []
description: |
Opens a WebSocket connection to stream stdout/stderr from a running
background process. The selector can be a numeric PID or a string tag.
Server sends JSON messages:
- `{"type": "start", "pid": 42}` — connected to process
- `{"type": "stdout", "data": "..."}` — stdout output
- `{"type": "stderr", "data": "..."}` — stderr output
- `{"type": "exit", "exit_code": 0}` — process exited
- `{"type": "error", "data": "..."}` — error message
responses:
"101":
description: WebSocket upgrade
/v1/capsules/{id}/ping: /v1/capsules/{id}/ping:
parameters: parameters:
- name: id - name: id
@ -1264,7 +1700,6 @@ paths:
PTY data (input and output) is base64-encoded because it contains raw PTY data (input and output) is base64-encoded because it contains raw
terminal bytes (escape sequences, control codes) that are not valid UTF-8. terminal bytes (escape sequences, control codes) that are not valid UTF-8.
Sessions have a 120-second inactivity timeout (reset on input/resize).
Sessions persist across WebSocket disconnections — the process keeps Sessions persist across WebSocket disconnections — the process keeps
running in the capsule. Use the `tag` from the "started" response to running in the capsule. Use the `tag` from the "started" response to
reconnect later. reconnect later.
@ -1956,6 +2391,13 @@ components:
password: password:
type: string type: string
SignupResponse:
type: object
properties:
message:
type: string
description: Confirmation message instructing user to check email
AuthResponse: AuthResponse:
type: object type: object
properties: properties:
@ -2022,6 +2464,28 @@ components:
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.
UsageResponse:
type: object
properties:
from:
type: string
format: date
to:
type: string
format: date
points:
type: array
items:
type: object
properties:
date:
type: string
format: date
cpu_minutes:
type: number
ram_mb_minutes:
type: number
CapsuleStats: CapsuleStats:
type: object type: object
properties: properties:
@ -2153,6 +2617,56 @@ components:
timeout_sec: timeout_sec:
type: integer type: integer
default: 30 default: 30
description: Timeout in seconds (foreground exec only, default 30)
background:
type: boolean
default: false
description: If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202)
tag:
type: string
description: Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true.
envs:
type: object
additionalProperties:
type: string
description: Environment variables for the process (background exec only)
cwd:
type: string
description: Working directory for the process (background exec only)
BackgroundExecResponse:
type: object
properties:
sandbox_id:
type: string
cmd:
type: string
pid:
type: integer
tag:
type: string
ProcessEntry:
type: object
properties:
pid:
type: integer
tag:
type: string
cmd:
type: string
args:
type: array
items:
type: string
ProcessListResponse:
type: object
properties:
processes:
type: array
items:
$ref: "#/components/schemas/ProcessEntry"
ExecResponse: ExecResponse:
type: object type: object
@ -2609,6 +3123,37 @@ components:
nullable: true nullable: true
description: Webhook secret. Only returned on creation, never again. description: Webhook secret. Only returned on creation, never again.
MeResponse:
type: object
properties:
name:
type: string
email:
type: string
format: email
has_password:
type: boolean
description: Whether the user has a password set (false for OAuth-only accounts)
providers:
type: array
items:
type: string
description: List of linked OAuth provider names (e.g. ["github"])
ChangePasswordRequest:
type: object
required: [new_password]
properties:
current_password:
type: string
description: Required when changing an existing password
new_password:
type: string
minLength: 8
confirm_password:
type: string
description: Required when adding a password to an OAuth-only account (must match new_password)
Error: Error:
type: object type: object
properties: properties:

View File

@ -15,14 +15,19 @@ import httpx
import httpx_ws import httpx_ws
from wrenn.exceptions import handle_response from wrenn.exceptions import handle_response
from wrenn.models import Capsule as CapsuleModel
from wrenn.models import ( from wrenn.models import (
BackgroundExecResponse,
CapsuleMetrics,
ExecResponse, ExecResponse,
FileEntry, FileEntry,
ListDirResponse, ListDirResponse,
MakeDirResponse, MakeDirResponse,
ProcessListResponse,
Status, Status,
) )
from wrenn.models import (
Capsule as CapsuleModel,
)
from wrenn.pty import AsyncPtySession, PtySession from wrenn.pty import AsyncPtySession, PtySession
@ -164,16 +169,16 @@ class Capsule(CapsuleModel):
helpers, and context-manager support for automatic cleanup. helpers, and context-manager support for automatic cleanup.
""" """
_http: httpx.Client | None _http: httpx.Client | None = None
_async_http: httpx.AsyncClient | None _async_http: httpx.AsyncClient | None = None
_base_url: str _base_url: str = ""
_api_key: str | None _api_key: str | None = None
_token: str | None _token: str | None = None
_proxy_client: httpx.Client | None _proxy_client: httpx.Client | None = None
_async_proxy_client: httpx.AsyncClient | None _async_proxy_client: httpx.AsyncClient | None = None
_kernel_id: str | None _kernel_id: str | None = None
_jupyter_ws: Any _jupyter_ws: Any = None
_async_jupyter_ws: Any _async_jupyter_ws: Any = None
def _bind( def _bind(
self, self,
@ -296,16 +301,25 @@ class Capsule(CapsuleModel):
cmd: str, cmd: str,
args: list[str] | None = None, args: list[str] | None = None,
timeout_sec: int | None = 30, timeout_sec: int | None = 30,
) -> ExecResult: background: bool = False,
tag: str | None = None,
envs: dict[str, str] | None = None,
cwd: str | None = None,
) -> ExecResult | BackgroundExecResponse:
"""Execute a command synchronously inside the capsule. """Execute a command synchronously inside the capsule.
Args: Args:
cmd: Command to run. cmd: Command to run.
args: Optional positional arguments. args: Optional positional arguments.
timeout_sec: Execution timeout in seconds. timeout_sec: Execution timeout in seconds (foreground only).
background: If true, start as a background process and return immediately.
tag: Optional tag for the background process.
envs: Environment variables (background only).
cwd: Working directory (background only).
Returns: Returns:
An ``ExecResult`` with ``stdout``, ``stderr``, ``exit_code``, ``duration_ms``. An ``ExecResult`` for foreground exec, or ``BackgroundExecResponse``
when ``background=True`` (HTTP 202).
""" """
assert self._http is not None assert self._http is not None
payload: dict = {"cmd": cmd} payload: dict = {"cmd": cmd}
@ -313,7 +327,17 @@ class Capsule(CapsuleModel):
payload["args"] = args payload["args"] = args
if timeout_sec is not None: if timeout_sec is not None:
payload["timeout_sec"] = timeout_sec payload["timeout_sec"] = timeout_sec
if background:
payload["background"] = True
if tag is not None:
payload["tag"] = tag
if envs is not None:
payload["envs"] = envs
if cwd is not None:
payload["cwd"] = cwd
resp = self._http.post(f"/v1/capsules/{self.id}/exec", json=payload) resp = self._http.post(f"/v1/capsules/{self.id}/exec", json=payload)
if resp.status_code == 202:
return BackgroundExecResponse.model_validate(resp.json())
resp.raise_for_status() resp.raise_for_status()
er = ExecResponse.model_validate(resp.json()) er = ExecResponse.model_validate(resp.json())
stdout = er.stdout or "" stdout = er.stdout or ""
@ -335,7 +359,11 @@ class Capsule(CapsuleModel):
cmd: str, cmd: str,
args: list[str] | None = None, args: list[str] | None = None,
timeout_sec: int | None = 30, timeout_sec: int | None = 30,
) -> ExecResult: background: bool = False,
tag: str | None = None,
envs: dict[str, str] | None = None,
cwd: str | None = None,
) -> ExecResult | BackgroundExecResponse:
"""Async version of ``exec``.""" """Async version of ``exec``."""
assert self._async_http is not None assert self._async_http is not None
payload: dict = {"cmd": cmd} payload: dict = {"cmd": cmd}
@ -343,7 +371,17 @@ class Capsule(CapsuleModel):
payload["args"] = args payload["args"] = args
if timeout_sec is not None: if timeout_sec is not None:
payload["timeout_sec"] = timeout_sec payload["timeout_sec"] = timeout_sec
if background:
payload["background"] = True
if tag is not None:
payload["tag"] = tag
if envs is not None:
payload["envs"] = envs
if cwd is not None:
payload["cwd"] = cwd
resp = await self._async_http.post(f"/v1/capsules/{self.id}/exec", json=payload) resp = await self._async_http.post(f"/v1/capsules/{self.id}/exec", json=payload)
if resp.status_code == 202:
return BackgroundExecResponse.model_validate(resp.json())
resp.raise_for_status() resp.raise_for_status()
er = ExecResponse.model_validate(resp.json()) er = ExecResponse.model_validate(resp.json())
stdout = er.stdout or "" stdout = er.stdout or ""
@ -861,12 +899,18 @@ class Capsule(CapsuleModel):
resp = self._http.delete(f"/v1/capsules/{self.id}") resp = self._http.delete(f"/v1/capsules/{self.id}")
resp.raise_for_status() resp.raise_for_status()
if self._proxy_client is not None:
self._proxy_client.close()
async def async_destroy(self) -> None: async def async_destroy(self) -> None:
"""Async version of ``destroy``.""" """Async version of ``destroy``."""
assert self._async_http is not None assert self._async_http is not None
resp = await self._async_http.delete(f"/v1/capsules/{self.id}") resp = await self._async_http.delete(f"/v1/capsules/{self.id}")
resp.raise_for_status() resp.raise_for_status()
if self._async_proxy_client is not None:
await self._async_proxy_client.aclose()
def _ensure_kernel(self, jupyter_timeout: float = 30) -> str: def _ensure_kernel(self, jupyter_timeout: float = 30) -> str:
"""Ensure a Jupyter kernel is running, creating one if needed. """Ensure a Jupyter kernel is running, creating one if needed.
@ -1113,6 +1157,115 @@ class Capsule(CapsuleModel):
return result return result
def metrics(self, range: str = "10m") -> CapsuleMetrics:
"""Get per-capsule resource metrics.
Args:
range: Time range filter (5m, 10m, 1h, 2h, 6h, 12h, 24h).
Returns:
``CapsuleMetrics`` with time-series CPU, memory, and disk data.
"""
assert self._http is not None
resp = self._http.get(
f"/v1/capsules/{self.id}/metrics", params={"range": range}
)
data = handle_response(resp)
return CapsuleMetrics.model_validate(data)
async def async_metrics(self, range: str = "10m") -> CapsuleMetrics:
"""Async version of ``metrics``."""
assert self._async_http is not None
resp = await self._async_http.get(
f"/v1/capsules/{self.id}/metrics", params={"range": range}
)
data = handle_response(resp)
return CapsuleMetrics.model_validate(data)
def list_processes(self) -> ProcessListResponse:
"""List all running processes inside the capsule.
Returns:
``ProcessListResponse`` with a list of ``ProcessEntry`` objects.
"""
assert self._http is not None
resp = self._http.get(f"/v1/capsules/{self.id}/processes")
data = handle_response(resp)
return ProcessListResponse.model_validate(data)
async def async_list_processes(self) -> ProcessListResponse:
"""Async version of ``list_processes``."""
assert self._async_http is not None
resp = await self._async_http.get(f"/v1/capsules/{self.id}/processes")
data = handle_response(resp)
return ProcessListResponse.model_validate(data)
def kill_process(self, selector: str, signal: str = "SIGKILL") -> None:
"""Kill a running process inside the capsule.
Args:
selector: Process PID (numeric) or tag (string).
signal: Signal to send (SIGKILL or SIGTERM).
"""
assert self._http is not None
resp = self._http.delete(
f"/v1/capsules/{self.id}/processes/{selector}",
params={"signal": signal},
)
handle_response(resp)
async def async_kill_process(self, selector: str, signal: str = "SIGKILL") -> None:
"""Async version of ``kill_process``."""
assert self._async_http is not None
resp = await self._async_http.delete(
f"/v1/capsules/{self.id}/processes/{selector}",
params={"signal": signal},
)
handle_response(resp)
def connect_process(self, selector: str) -> Iterator[StreamEvent]:
"""Stream output from a background process via WebSocket.
Args:
selector: Process PID (numeric) or tag (string).
Yields:
``StreamStartEvent``, ``StreamStdoutEvent``, ``StreamStderrEvent``,
``StreamExitEvent``, or ``StreamErrorEvent``.
"""
assert self._http is not None
ws: httpx_ws.WebSocketSession
with httpx_ws.connect_ws(
f"/v1/capsules/{self.id}/processes/{selector}/stream",
self._http,
) as ws:
while True:
try:
raw_data: dict = ws.receive_json()
event = _parse_stream_event(raw_data)
yield event
if event.type in ("exit", "error"):
break
except httpx_ws.WebSocketDisconnect:
break
async def async_connect_process(self, selector: str) -> AsyncIterator[StreamEvent]:
"""Async version of ``connect_process``."""
assert self._async_http is not None
async with httpx_ws.aconnect_ws(
f"/v1/capsules/{self.id}/processes/{selector}/stream",
self._async_http,
) as ws:
try:
while True:
raw_data = await ws.receive_json()
event = _parse_stream_event(raw_data)
yield event
if event.type in ("exit", "error"):
break
except httpx_ws.WebSocketDisconnect:
pass
def _cleanup(self) -> None: def _cleanup(self) -> None:
if self._proxy_client is not None: if self._proxy_client is not None:
try: try:

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,15 @@
from wrenn.models._generated import ( from wrenn.models._generated import (
APIKeyResponse, APIKeyResponse,
AuthResponse, AuthResponse,
BackgroundExecResponse,
Capsule, Capsule,
CapsuleMetrics,
CapsuleStats,
ChangePasswordRequest,
ChannelResponse,
CreateAPIKeyRequest, CreateAPIKeyRequest,
CreateCapsuleRequest, CreateCapsuleRequest,
CreateChannelRequest,
CreateHostRequest, CreateHostRequest,
CreateHostResponse, CreateHostResponse,
CreateSnapshotRequest, CreateSnapshotRequest,
@ -14,31 +20,55 @@ from wrenn.models._generated import (
ExecResponse, ExecResponse,
FileEntry, FileEntry,
Host, Host,
HostDeletePreview,
ListDirRequest, ListDirRequest,
ListDirResponse, ListDirResponse,
LoginRequest, LoginRequest,
MakeDirRequest, MakeDirRequest,
MakeDirResponse, MakeDirResponse,
MeResponse,
MetricPoint,
ProcessEntry,
ProcessListResponse,
ReadFileRequest, ReadFileRequest,
RefreshHostTokenRequest,
RefreshHostTokenResponse,
RegisterHostRequest, RegisterHostRequest,
RegisterHostResponse, RegisterHostResponse,
RemoveRequest, RemoveRequest,
RotateConfigRequest,
SignupRequest, SignupRequest,
SignupResponse,
Status, Status,
Status1, Status1,
Template, Template,
Team,
TeamDetail,
TeamMember,
TeamWithRole,
TestChannelRequest,
Type, Type,
Type1, Type1,
Type2, Type2,
UpdateChannelRequest,
UsageResponse,
UserSearchResult,
) )
__all__ = [ __all__ = [
"APIKeyResponse", "APIKeyResponse",
"AuthResponse", "AuthResponse",
"BackgroundExecResponse",
"Capsule",
"CapsuleMetrics",
"CapsuleStats",
"ChangePasswordRequest",
"ChannelResponse",
"CreateAPIKeyRequest", "CreateAPIKeyRequest",
"CreateCapsuleRequest",
"CreateChannelRequest",
"CreateHostRequest", "CreateHostRequest",
"CreateHostResponse", "CreateHostResponse",
"CreateCapsuleRequest",
"CreateSnapshotRequest", "CreateSnapshotRequest",
"Encoding", "Encoding",
"Error", "Error",
@ -47,21 +77,37 @@ __all__ = [
"ExecResponse", "ExecResponse",
"FileEntry", "FileEntry",
"Host", "Host",
"HostDeletePreview",
"ListDirRequest", "ListDirRequest",
"ListDirResponse", "ListDirResponse",
"LoginRequest", "LoginRequest",
"MakeDirRequest", "MakeDirRequest",
"MakeDirResponse", "MakeDirResponse",
"MeResponse",
"MetricPoint",
"ProcessEntry",
"ProcessListResponse",
"ReadFileRequest", "ReadFileRequest",
"RefreshHostTokenRequest",
"RefreshHostTokenResponse",
"RegisterHostRequest", "RegisterHostRequest",
"RegisterHostResponse", "RegisterHostResponse",
"RemoveRequest", "RemoveRequest",
"Capsule", "RotateConfigRequest",
"SignupRequest", "SignupRequest",
"SignupResponse",
"Status", "Status",
"Status1", "Status1",
"Template", "Template",
"Team",
"TeamDetail",
"TeamMember",
"TeamWithRole",
"TestChannelRequest",
"Type", "Type",
"Type1", "Type1",
"Type2", "Type2",
"UpdateChannelRequest",
"UsageResponse",
"UserSearchResult",
] ]

View File

@ -1,9 +1,10 @@
# generated by datamodel-codegen: # generated by datamodel-codegen:
# filename: openapi.yaml # filename: openapi.yaml
# timestamp: 2026-04-12T20:56:29+00:00 # timestamp: 2026-04-19T19:56:15+00:00
from __future__ import annotations from __future__ import annotations
from datetime import date as date_aliased
from enum import StrEnum from enum import StrEnum
from typing import Annotated from typing import Annotated
@ -21,8 +22,15 @@ class LoginRequest(BaseModel):
password: str password: str
class SignupResponse(BaseModel):
message: Annotated[
str | None,
Field(description="Confirmation message instructing user to check email"),
] = None
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 +40,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 +55,41 @@ 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 CreateCapsuleRequest(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 capsule 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 Point(BaseModel):
date: date_aliased | None = None
cpu_minutes: float | None = None
ram_mb_minutes: float | None = None
class UsageResponse(BaseModel):
from_: Annotated[date_aliased | None, Field(alias="from")] = None
to: date_aliased | None = None
points: list[Point] | None = None
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):
@ -104,22 +124,22 @@ 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 Capsule(BaseModel): class Capsule(BaseModel):
@ -139,17 +159,17 @@ class Capsule(BaseModel):
class CreateSnapshotRequest(BaseModel): class CreateSnapshotRequest(BaseModel):
sandbox_id: Annotated[ sandbox_id: Annotated[
str, Field(description='ID of the running capsule 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):
@ -164,7 +184,50 @@ class Template(BaseModel):
class ExecRequest(BaseModel): class ExecRequest(BaseModel):
cmd: str cmd: str
args: list[str] | None = None args: list[str] | None = None
timeout_sec: int | None = 30 timeout_sec: Annotated[
int | None,
Field(description="Timeout in seconds (foreground exec only, default 30)"),
] = 30
background: Annotated[
bool | None,
Field(
description="If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202)"
),
] = False
tag: Annotated[
str | None,
Field(
description="Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true."
),
] = None
envs: Annotated[
dict[str, str] | None,
Field(
description="Environment variables for the process (background exec only)"
),
] = None
cwd: Annotated[
str | None,
Field(description="Working directory for the process (background exec only)"),
] = None
class BackgroundExecResponse(BaseModel):
sandbox_id: str | None = None
cmd: str | None = None
pid: int | None = None
tag: str | None = None
class ProcessEntry(BaseModel):
pid: int | None = None
tag: str | None = None
cmd: str | None = None
args: list[str] | None = None
class ProcessListResponse(BaseModel):
processes: list[ProcessEntry] | None = None
class Encoding(StrEnum): class Encoding(StrEnum):
@ -172,8 +235,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 +255,23 @@ class ExecResponse(BaseModel):
class ReadFileRequest(BaseModel): class ReadFileRequest(BaseModel):
path: Annotated[str, Field(description='Absolute file path inside the capsule')] 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 capsule')] 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 +286,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 capsule') str, Field(description="Directory path to create inside the capsule")
] ]
@ -239,7 +302,7 @@ class MakeDirResponse(BaseModel):
class RemoveRequest(BaseModel): class RemoveRequest(BaseModel):
path: Annotated[str, Field(description='Path to remove inside the capsule')] path: Annotated[str, Field(description="Path to remove inside the capsule")]
class Type2(StrEnum): class Type2(StrEnum):
@ -247,51 +310,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 +379,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 +387,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,16 +401,16 @@ 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 capsulees 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 capsulees blocking deletion.'), Field(description="IDs of active capsulees blocking deletion."),
] = None ] = None
@ -368,15 +431,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 +459,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 +473,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 +523,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 +552,35 @@ 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
class MeResponse(BaseModel):
name: str | None = None
email: EmailStr | None = None
has_password: Annotated[
bool | None,
Field(
description="Whether the user has a password set (false for OAuth-only accounts)"
),
] = None
providers: Annotated[
list[str] | None,
Field(description='List of linked OAuth provider names (e.g. ["github"])'),
] = None
class ChangePasswordRequest(BaseModel):
current_password: Annotated[
str | None, Field(description="Required when changing an existing password")
] = None
new_password: Annotated[str, Field(min_length=8)]
confirm_password: Annotated[
str | None,
Field(
description="Required when adding a password to an OAuth-only account (must match new_password)"
),
] = None ] = None
@ -511,7 +602,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,12 +611,12 @@ 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

View File

@ -7,6 +7,7 @@ import pytest
import pytest_asyncio import pytest_asyncio
from typing_extensions import AsyncGenerator from typing_extensions import AsyncGenerator
from wrenn.capsule import Capsule
from wrenn.client import AsyncWrennClient, WrennClient from wrenn.client import AsyncWrennClient, WrennClient
WRENN_API_KEY = os.environ.get("WRENN_API_KEY") WRENN_API_KEY = os.environ.get("WRENN_API_KEY")
@ -61,7 +62,9 @@ def bearer_client() -> Generator[WrennClient, None, None]:
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def async_minimal_capsule(async_client: AsyncWrennClient): async def async_minimal_capsule(
async_client: AsyncWrennClient,
) -> AsyncGenerator[Capsule, None]:
"""Provides a ready-to-use minimal capsule and cleans it up afterward.""" """Provides a ready-to-use minimal capsule and cleans it up afterward."""
cap = await async_client.capsules.create(template="minimal", timeout_sec=120) cap = await async_client.capsules.create(template="minimal", timeout_sec=120)
await cap.async_wait_ready(timeout=60, interval=1) await cap.async_wait_ready(timeout=60, interval=1)
@ -70,7 +73,9 @@ async def async_minimal_capsule(async_client: AsyncWrennClient):
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def async_python_capsule(async_client: AsyncWrennClient): async def async_python_capsule(
async_client: AsyncWrennClient,
) -> AsyncGenerator[Capsule, None]:
"""Provides a ready-to-use Python interpreter capsule.""" """Provides a ready-to-use Python interpreter capsule."""
cap = await async_client.capsules.create( cap = await async_client.capsules.create(
template="python-interpreter-v0-beta", timeout_sec=120 template="python-interpreter-v0-beta", timeout_sec=120
@ -83,7 +88,7 @@ async def async_python_capsule(async_client: AsyncWrennClient):
@pytest.fixture @pytest.fixture
def minimal_capsule( def minimal_capsule(
client: WrennClient, client: WrennClient,
) -> Generator[Any, None, None]: # Replace Any with your Capsule type ) -> Generator[Capsule, None, None]:
"""Provides a ready-to-use minimal capsule and cleans it up afterward.""" """Provides a ready-to-use minimal capsule and cleans it up afterward."""
with client.capsules.create(template="minimal", timeout_sec=120) as cap: with client.capsules.create(template="minimal", timeout_sec=120) as cap:
cap.wait_ready(timeout=60, interval=1) cap.wait_ready(timeout=60, interval=1)

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import pytest import pytest
from wrenn.capsule import Capsule from wrenn.capsule import Capsule, ExecResult
from .conftest import requires_auth from .conftest import requires_auth
@ -14,6 +14,7 @@ class TestAsyncCapsuleLifecycle:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_async_create_exec_destroy(self, async_minimal_capsule: Capsule): async def test_async_create_exec_destroy(self, async_minimal_capsule: Capsule):
result = await async_minimal_capsule.async_exec("echo", args=["async_hello"]) result = await async_minimal_capsule.async_exec("echo", args=["async_hello"])
assert isinstance(result, ExecResult)
assert result.exit_code == 0 assert result.exit_code == 0
assert "async_hello" in result.stdout assert "async_hello" in result.stdout

View File

@ -9,7 +9,9 @@ from wrenn.client import WrennClient
@pytest.fixture @pytest.fixture
def client(): def client():
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: with WrennClient(
api_key="wrn_test1234567890abcdef12345678", token="jwt-test-token-abc123"
) as c:
yield c yield c
@ -81,14 +83,20 @@ class TestCapsuleHttpClient:
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:
cap = Capsule(id="cl-abc") cap = Capsule(id="cl-abc")
cap._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") assert c._mgmt_http is not None
cap._bind(
c._mgmt_http, str(c._mgmt_http.base_url), api_key=None, token="jwt-abc"
)
url = cap.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:
cap = Capsule(id="cl-abc") cap = Capsule(id="cl-abc")
cap._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") assert c._mgmt_http is not None
cap._bind(
c._mgmt_http, str(c._mgmt_http.base_url), api_key=None, token="jwt-abc"
)
hc = cap.http_client hc = cap.http_client
assert hc.headers["Authorization"] == "Bearer jwt-abc" assert hc.headers["Authorization"] == "Bearer jwt-abc"
@ -136,6 +144,7 @@ class TestCodeResult:
error=None, error=None,
) )
assert r.text == "84" assert r.text == "84"
assert r.data is not None
assert r.data["text/plain"] == "84" assert r.data["text/plain"] == "84"
def test_error_result(self): def test_error_result(self):
@ -164,7 +173,6 @@ class TestJupyterMessageFormat:
class TestDeprecationWarnings: class TestDeprecationWarnings:
def test_import_sandbox_from_capsule_warns(self): def test_import_sandbox_from_capsule_warns(self):
import importlib
import warnings import warnings
import wrenn.capsule as capsule_mod import wrenn.capsule as capsule_mod

View File

@ -16,24 +16,29 @@ from wrenn.exceptions import (
) )
from wrenn.models import ( from wrenn.models import (
APIKeyResponse, APIKeyResponse,
AuthResponse,
Capsule, Capsule,
CreateHostResponse, CreateHostResponse,
Host, Host,
SignupResponse,
Status, Status,
Template, Template,
UsageResponse,
) )
@pytest.fixture @pytest.fixture
def client(): def client():
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: with WrennClient(
api_key="wrn_test1234567890abcdef12345678", token="jwt-test-token-abc123"
) as c:
yield c yield c
@pytest.fixture @pytest.fixture
def async_client(): def async_client():
return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678") return AsyncWrennClient(
api_key="wrn_test1234567890abcdef12345678", token="jwt-test-token-abc123"
)
class TestAuth: class TestAuth:
@ -41,17 +46,21 @@ class TestAuth:
def test_signup(self, client): def test_signup(self, client):
respx.post("https://api.wrenn.dev/v1/auth/signup").respond( respx.post("https://api.wrenn.dev/v1/auth/signup").respond(
201, 201,
json={ json={"message": "Account created. Check your email to activate."},
"token": "jwt-token",
"user_id": "u-1",
"team_id": "t-1",
"email": "a@b.com",
},
) )
resp = client.auth.signup("a@b.com", "password123") resp = client.auth.signup("a@b.com", "password123", "Test User")
assert isinstance(resp, AuthResponse) assert isinstance(resp, SignupResponse)
assert resp.token == "jwt-token" assert resp.message is not None
assert resp.user_id == "u-1"
@respx.mock
def test_signup_no_creds(self):
respx.post("https://api.wrenn.dev/v1/auth/signup").respond(
201,
json={"message": "Account created."},
)
with WrennClient() as c:
resp = c.auth.signup("a@b.com", "password123", "Test User")
assert isinstance(resp, SignupResponse)
@respx.mock @respx.mock
def test_login(self, client): def test_login(self, client):
@ -146,6 +155,40 @@ class TestCapsules:
client.capsules.destroy("sb-1") client.capsules.destroy("sb-1")
assert route.called assert route.called
@respx.mock
def test_usage(self, client):
respx.get("https://api.wrenn.dev/v1/capsules/usage").respond(
200,
json={
"from": "2026-03-21",
"to": "2026-04-20",
"points": [
{
"date": "2026-04-19",
"cpu_minutes": 12.5,
"ram_mb_minutes": 640.0,
},
{"date": "2026-04-20", "cpu_minutes": 8.0, "ram_mb_minutes": 512.0},
],
},
)
resp = client.capsules.usage()
assert isinstance(resp, UsageResponse)
assert resp.points is not None
assert len(resp.points) == 2
assert resp.points[0].cpu_minutes == 12.5
@respx.mock
def test_usage_with_dates(self, client):
route = respx.get("https://api.wrenn.dev/v1/capsules/usage").respond(
200,
json={"from": "2026-04-01", "to": "2026-04-15", "points": []},
)
client.capsules.usage(from_date="2026-04-01", to_date="2026-04-15")
req = route.calls[0].request
assert "from=2026-04-01" in str(req.url)
assert "to=2026-04-15" in str(req.url)
class TestSnapshots: class TestSnapshots:
@respx.mock @respx.mock
@ -355,25 +398,92 @@ class TestErrorHandling:
class TestAuthModes: class TestAuthModes:
def test_api_key_header(self): def test_api_key_only_creates_data_client(self):
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
assert c._http.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678" assert c._data_http is not None
assert (
c._data_http.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
)
assert c._mgmt_http is None
def test_token_header(self): def test_token_only_creates_mgmt_client(self):
with WrennClient(token="jwt-token-abc") as c: with WrennClient(token="jwt-token-abc") as c:
assert c._http.headers["Authorization"] == "Bearer jwt-token-abc" assert c._mgmt_http is not None
assert c._mgmt_http.headers["Authorization"] == "Bearer jwt-token-abc"
assert c._data_http is None
def test_no_auth_raises(self): def test_no_auth_allowed(self):
with pytest.raises(ValueError, match="Either api_key or token"): with WrennClient() as c:
WrennClient() assert c._data_http is None
assert c._mgmt_http is None
assert c._public_http is not None
def test_both_creds_creates_both_clients(self):
with WrennClient(
api_key="wrn_test1234567890abcdef12345678", token="jwt-abc"
) as c:
assert c._data_http is not None
assert c._mgmt_http is not None
def test_capsule_ops_require_api_key(self):
with WrennClient(token="jwt-abc") as c:
with pytest.raises(ValueError, match="API key"):
c.capsules.list()
def test_snapshot_ops_require_api_key(self):
with WrennClient(token="jwt-abc") as c:
with pytest.raises(ValueError, match="API key"):
c.snapshots.list()
def test_mgmt_ops_require_token(self):
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
with pytest.raises(ValueError, match="JWT token"):
c.api_keys.list()
with pytest.raises(ValueError, match="JWT token"):
c.teams.list()
with pytest.raises(ValueError, match="JWT token"):
c.hosts.list()
with pytest.raises(ValueError, match="JWT token"):
c.channels.list()
with pytest.raises(ValueError, match="JWT token"):
c.users.search("a@b.com")
with pytest.raises(ValueError, match="JWT token"):
c.account.get()
with pytest.raises(ValueError, match="JWT token"):
c.auth.switch_team("team-1")
@respx.mock @respx.mock
def test_jwt_auth_on_api_keys(self): def test_mgmt_sends_bearer_only(self):
route = respx.get("https://api.wrenn.dev/v1/api-keys").respond(200, json=[]) route = respx.get("https://api.wrenn.dev/v1/api-keys").respond(200, json=[])
with WrennClient(token="jwt-abc") as c: with WrennClient(
api_key="wrn_test1234567890abcdef12345678", token="jwt-abc"
) as c:
c.api_keys.list() c.api_keys.list()
req = route.calls[0].request req = route.calls[0].request
assert req.headers["Authorization"] == "Bearer jwt-abc" assert req.headers["Authorization"] == "Bearer jwt-abc"
assert "X-API-Key" not in req.headers
@respx.mock
def test_data_sends_api_key_only(self):
route = respx.get("https://api.wrenn.dev/v1/capsules").respond(200, json=[])
with WrennClient(
api_key="wrn_test1234567890abcdef12345678", token="jwt-abc"
) as c:
c.capsules.list()
req = route.calls[0].request
assert req.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
assert "Authorization" not in req.headers
@respx.mock
def test_public_sends_no_auth(self):
route = respx.post("https://api.wrenn.dev/v1/auth/signup").respond(
201, json={"message": "ok"}
)
with WrennClient() as c:
c.auth.signup("a@b.com", "password123", "Test")
req = route.calls[0].request
assert "X-API-Key" not in req.headers
assert "Authorization" not in req.headers
class TestAsyncClient: class TestAsyncClient: