35 Commits

Author SHA1 Message Date
e5e4e1a85b fix: update SDK for v0.2.0 API compatibility
Some checks failed
ci/woodpecker/pr/check Pipeline failed
Sync OpenAPI spec to v0.2.0, fix type annotation shadowing by using
builtins.list in annotated signatures, guard poll interval lookup
against None status, and reorder capsule ID assignment to validate
before storing.
2026-05-16 17:57:20 +06:00
d9c028564e Merge branch 'bugfix/timeout-related-issues' into dev
Some checks failed
ci/woodpecker/pr/check Pipeline failed
2026-05-02 21:53:33 +06:00
06b4a8cbcb Merge issues fixed
All checks were successful
ci/woodpecker/pr/check Pipeline was successful
2026-05-02 21:46:16 +06:00
04e5dc652f Fix error handling, resource leaks, and logic bugs across the SDK
Bugs fixed:
- files.py: use typed error checking (_raise_for_status) instead of raw
  raise_for_status(), ensuring WrennNotFoundError etc. are raised
  correctly
- exceptions.py: check both "capsule_ids" and "sandbox_ids" response
  keys
  for backwards compatibility
- code_interpreter: retry _ensure_kernel on 5xx errors (only fail on
  4xx),
  remove redundant TimeoutError in bare except, clean up non-standard
  top-level msg_id/msg_type from Jupyter messages

Resource leaks fixed:
- capsule.py: close WrennClient if capsule creation or init fails
- code_interpreter: add close()/__del__ for _proxy_client cleanup when
  not using context manager

Logic fixes:
- pty.py: yield exit events to callers instead of silently discarding
  them
- capsule.py: auto-resume paused capsules in wait_ready instead of
  failing
- capsule.py: log warnings on destroy failure in __exit__ instead of
  silently swallowing errors
2026-05-02 21:34:02 +06:00
4a7db8e204 fix: set httpx read timeout for long-running commands and handle
non-JSON error responses
- Set per-request httpx timeout (command timeout + 10s buffer) in
  Commands.run() and AsyncCommands.run() for foreground exec calls,
  preventing HTTP read timeouts on long-running commands
- Raise WrennInternalError instead of raw httpx.HTTPStatusError when
  handle_response() encounters a non-JSON error body (e.g. 502 from
  a reverse proxy)
2026-05-02 19:02:39 +06:00
a76be96682 Merge branch 'main' of git.omukk.dev:wrenn/python-sdk into dev 2026-05-02 05:07:13 +06:00
dc66ac24d5 Updated woodpecker def
All checks were successful
ci/woodpecker/pr/check Pipeline was successful
2026-05-02 04:50:11 +06:00
b5e2b12ef1 Version bump and other minor changes 2026-05-02 04:45:05 +06:00
213af4aee7 Increased timeout for long running API calls and updated typehints 2026-05-02 04:44:26 +06:00
aa9477ffe8 Added doc generator for SDK
All checks were successful
ci/woodpecker/push/check Pipeline was successful
2026-04-24 00:01:20 +06:00
2bb3dbd71d Merge branch 'main' of git.omukk.dev:wrenn/python-sdk into dev 2026-04-23 23:53:15 +06:00
3f26a2fbcf Merge branch 'main' into dev
Some checks failed
ci/woodpecker/push/check Pipeline was canceled
2026-04-23 12:38:41 +00:00
2faf0dd0ae Updated woodpecker config
All checks were successful
ci/woodpecker/push/check Pipeline was successful
2026-04-23 18:36:35 +06:00
68c7d0de42 ci: add test pipeline, PyPI release workflow, and lint fixes
- Update Woodpecker to run unit and integration tests in parallel
- Add GitHub Actions workflow for PyPI trusted publishing on main
- Add license, classifiers, keywords, and URLs to pyproject.toml
- Fix ruff lint errors (unused imports, duplicate class name) and formatting
2026-04-23 18:32:59 +06:00
ad64c85393 Merge pull request 'Feat: Added git support' (#5) from feat/git-support into dev
Some checks failed
ci/woodpecker/push/check Pipeline failed
Reviewed-on: #5
2026-04-22 23:45:36 +00:00
bab53aedbe Updated readme 2026-04-23 05:44:49 +06:00
82e181dd7e test: add integration tests for capsule lifecycle, commands, files, and git
43 tests across 4 classes hitting the live API. Shared capsule per class
to minimize VM boot overhead. All capsules destroyed in teardown.
Skips automatically when WRENN_API_KEY is not available.
2026-04-23 05:40:06 +06:00
ee1f55635f fix: wrap commands in /bin/sh -c for proper server-side argv expansion
The server-side agent runs commands through a nice wrapper that uses
"${@}" expansion. Sending the full command string as a single cmd field
caused nice to treat it as one executable name. Now Commands.run sends
cmd=/bin/sh args=["-c", cmd_string] so "${@}" expands into proper argv.
2026-04-23 05:16:08 +06:00
6bdf28e2ae Added git integration 2026-04-23 04:46:57 +06:00
61bc040098 Minor patches
Some checks failed
ci/woodpecker/push/check Pipeline failed
2026-04-23 02:31:47 +06:00
7b35ffb60c docs: add Google-style docstrings to all public SDK methods
Some checks failed
ci/woodpecker/push/check Pipeline failed
2026-04-17 04:29:34 +06:00
42bcc792d6 Updated dependency
Some checks failed
ci/woodpecker/push/check Pipeline failed
2026-04-17 03:29:45 +06:00
3f97c73b2f feat: redesign code interpreter with structured Execution model
Some checks failed
ci/woodpecker/push/check Pipeline failed
Replace flat CodeResult with a proper model hierarchy: Execution
(top-level), Result (per-output with typed MIME fields), Logs
(stdout/stderr as lists), and ExecutionError (structured
name/value/traceback). Handle display_data messages for rich output,
add streaming callbacks (on_result, on_stdout, on_stderr, on_error),
and remove the misleading stdout-to-text fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 03:16:39 +06:00
7e7ecbd48a Merge pull request 'feat: implement client architecture and sandbox environment' (#3) from feat/client-and-sandbox-support into dev
Some checks failed
ci/woodpecker/push/check Pipeline failed
Reviewed-on: #3
2026-04-15 15:35:40 +00:00
7b9a06d1b5 chore: add python-dotenv dependency
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:33:53 +06:00
3d0eda5c60 feat: rename kill to destroy, improve code interpreter, update README
- Rename Capsule.kill/AsyncCapsule.kill to destroy for frontend consistency
- Add Sandbox deprecation alias to wrenn.code_interpreter module
- run_code text falls back to stripped stdout when no expression result
- Strip quotes from string expression results (matching e2b behavior)
- _ensure_kernel reuses existing Jupyter kernels before creating new ones
- Rewrite README with complete examples for capsules and code interpreter
- Remove stale AGENTS.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:58:59 +06:00
eecf1dc65b chore: update OpenAPI schema, generated models, and build config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:31:07 +06:00
3cced768a4 feat: redesign SDK with e2b-compatible interface
Replace the WrennClient-centric API with a top-level Capsule class that
mirrors e2b's Sandbox interface, enabling drop-in migration. Key changes:

- Capsule/AsyncCapsule with direct construction (reads WRENN_API_KEY and
  WRENN_BASE_URL env vars), namespaced sub-objects (capsule.commands,
  capsule.files), dual instance/static lifecycle methods via _DualMethod
  descriptor (capsule.kill() and Capsule.kill(id))
- WrennClient simplified to API-key-only endpoints (capsules, snapshots);
  JWT-based resources (auth, hosts, teams) removed
- wrenn.code_interpreter submodule with Capsule subclass defaulting to
  code-runner-beta template and run_code() support
- Sandbox alias emits FutureWarning instead of DeprecationWarning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:19:23 +06:00
0ac9bf79ee feat: created README 2026-04-13 03:16:44 +06:00
bf5914c0a8 fix: renamed sandbox to capsule 2026-04-13 03:16:27 +06:00
976af9a209 ci: woodpecker doesn't support variable expansions outside of commands 2026-04-12 03:08:34 +06:00
f3fd6865f9 ci: bug fixes 2026-04-12 03:03:33 +06:00
340ed46df6 CI for linting and testing 2026-04-12 02:51:14 +06:00
a5bf66c199 feat: add sandbox filesystem and terminal support
Add sandbox filesystem methods (list_dir, mkdir, remove, upload,
download, stream_upload, stream_download) and interactive PTY sessions
(PtySession, AsyncPtySession) with reconnect support per
FILE_TERMINAL.md spec. Refactor error handling into exceptions.py as
shared handle_response(). Replace API-key-only proxy auth with unified
_proxy_headers() supporting both API key and JWT. Fix stream_upload to
build multipart manually instead of relying on httpx files= with
generators. Switch Makefile SPEC_URL from main to dev branch. Regenerate
models from updated OpenAPI spec (adds teams, channels, metrics, PTY
endpoints). Add comprehensive unit and integration tests. Trim AGENTS.md
to verified facts only.
2026-04-12 02:35:20 +06:00
f51a962fff feat: implement client architecture and sandbox environment
Introduces the core Wrenn client and a dedicated sandbox execution
environment. This includes automated model generation and a custom
exception hierarchy to support robust integration.

- Add `WrennClient` in `src/wrenn/client.py` for API interaction.
- Implement `Sandbox` in `src/wrenn/sandbox.py` for isolated execution.
- Add Pydantic/model support via `_generated.py`.
- Define project-specific error types in `exceptions.py`.
- Include AGENTS.md documentation for specialized logic.
- Add comprehensive unit and integration tests.
- Update build system (Makefile, uv.lock, pyproject.toml) and LICENSE.
2026-04-10 22:24:50 +06:00
18 changed files with 366 additions and 176 deletions

2
.gitignore vendored
View File

@ -176,7 +176,9 @@ cython_debug/
CODE_EXECUTION.md CODE_EXECUTION.md
.opencode/
# AI # AI
.code-review-graph/ .code-review-graph/
.claude .claude
.mcp.json .mcp.json
AGENTS.md

View File

@ -1,8 +1,8 @@
openapi: "3.1.0" openapi: "3.1.0"
info: info:
title: Wrenn API title: Wrenn API
description: MicroVM-based code execution platform API. description: AI agent execution platform API.
version: "0.1.3" version: "0.2.0"
servers: servers:
- url: http://localhost:8080 - url: http://localhost:8080
@ -866,8 +866,8 @@ paths:
schema: schema:
$ref: "#/components/schemas/CreateCapsuleRequest" $ref: "#/components/schemas/CreateCapsuleRequest"
responses: responses:
"201": "202":
description: Capsule created description: Capsule creation initiated (status will be "starting")
content: content:
application/json: application/json:
schema: schema:
@ -988,8 +988,8 @@ paths:
security: security:
- apiKeyAuth: [] - apiKeyAuth: []
responses: responses:
"204": "202":
description: Capsule destroyed description: Capsule destruction initiated
/v1/capsules/{id}/exec: /v1/capsules/{id}/exec:
parameters: parameters:
@ -1260,8 +1260,8 @@ paths:
destroys all running resources. The capsule 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": "202":
description: Capsule paused (snapshot taken, resources released) description: Capsule pause initiated (status will be "pausing")
content: content:
application/json: application/json:
schema: schema:
@ -1292,8 +1292,8 @@ paths:
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": "202":
description: Capsule resumed (new VM booted from snapshot) description: Capsule resume initiated (status will be "resuming")
content: content:
application/json: application/json:
schema: schema:
@ -2035,6 +2035,51 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/hosts/sandbox-events:
post:
summary: Sandbox lifecycle event callback
operationId: sandboxEventCallback
tags: [hosts]
security:
- hostTokenAuth: []
description: |
Receives autonomous lifecycle events from host agents (e.g. auto-pause
from the TTL reaper). The event is published to an internal Redis stream
for the control plane's event consumer to process.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [event, sandbox_id, host_id]
properties:
event:
type: string
enum: [sandbox.auto_paused]
sandbox_id:
type: string
host_id:
type: string
timestamp:
type: integer
format: int64
responses:
"204":
description: Event accepted
"400":
description: Invalid request
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
description: Host ID mismatch
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/hosts/auth/refresh: /v1/hosts/auth/refresh:
post: post:
summary: Refresh host JWT summary: Refresh host JWT
@ -2346,6 +2391,54 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/admin/users/{id}/admin:
put:
summary: Grant or revoke platform admin
operationId: setUserAdmin
tags: [admin]
description: |
Sets the platform admin flag on a user. Cannot remove the last admin.
Requires platform admin access (JWT + is_admin).
The target user's JWT is not re-issued — their frontend will reflect the
change on next login or team switch.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
example: "usr-a1b2c3d4"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [admin]
properties:
admin:
type: boolean
description: true to grant admin, false to revoke.
responses:
"204":
description: Admin status updated
"400":
$ref: "#/components/responses/BadRequest"
"403":
description: Caller is not a platform admin
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: User not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
components: components:
securitySchemes: securitySchemes:
apiKeyAuth: apiKeyAuth:
@ -2544,7 +2637,7 @@ components:
type: string type: string
status: status:
type: string type: string
enum: [pending, starting, running, paused, hibernated, stopped, missing, error] enum: [pending, starting, running, pausing, paused, resuming, stopping, hibernated, stopped, missing, error]
template: template:
type: string type: string
vcpus: vcpus:

View File

@ -1964,15 +1964,17 @@ inactivity TTL is set.
#### wait\_ready #### wait\_ready
```python ```python
async def wait_ready(timeout: float = 30, interval: float = 0.5) -> None async def wait_ready(timeout: float = 30) -> None
``` ```
Await until the capsule status is ``running``. Await until the capsule status is ``running``.
Polling interval adapts to the current transient status:
0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
**Arguments**: **Arguments**:
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``. - `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
- `interval` _float_ - Polling interval in seconds. Defaults to ``0.5``.
**Raises**: **Raises**:
@ -2534,15 +2536,17 @@ inactivity TTL is set.
#### wait\_ready #### wait\_ready
```python ```python
def wait_ready(timeout: float = 30, interval: float = 0.5) -> None def wait_ready(timeout: float = 30) -> None
``` ```
Block until the capsule status is ``running``. Block until the capsule status is ``running``.
Polling interval adapts to the current transient status:
0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
**Arguments**: **Arguments**:
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``. - `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
- `interval` _float_ - Polling interval in seconds. Defaults to ``0.5``.
**Raises**: **Raises**:
@ -2700,17 +2704,6 @@ Create a snapshot template from this capsule's current state.
# wrenn.\_config # wrenn.\_config
<a id="wrenn._config.ConnectionConfig"></a>
## ConnectionConfig Objects
```python
@dataclass(frozen=True)
class ConnectionConfig()
```
Resolved credentials and base URL for Wrenn API calls.
<a id="wrenn._git._auth"></a> <a id="wrenn._git._auth"></a>
# wrenn.\_git.\_auth # wrenn.\_git.\_auth

View File

@ -1,33 +1,5 @@
from __future__ import annotations from __future__ import annotations
import os
from dataclasses import dataclass
DEFAULT_BASE_URL = "https://app.wrenn.dev/api" DEFAULT_BASE_URL = "https://app.wrenn.dev/api"
ENV_API_KEY = "WRENN_API_KEY" ENV_API_KEY = "WRENN_API_KEY"
ENV_BASE_URL = "WRENN_BASE_URL" ENV_BASE_URL = "WRENN_BASE_URL"
@dataclass(frozen=True)
class ConnectionConfig:
"""Resolved credentials and base URL for Wrenn API calls."""
api_key: str
base_url: str
@classmethod
def from_env(
cls,
api_key: str | None = None,
base_url: str | None = None,
) -> ConnectionConfig:
resolved_key = api_key or os.environ.get(ENV_API_KEY)
if not resolved_key:
raise ValueError(
f"No API key provided. Pass api_key= or set the {ENV_API_KEY} environment variable."
)
resolved_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL)
return cls(api_key=resolved_key, base_url=resolved_url)
def auth_headers(self) -> dict[str, str]:
return {"X-API-Key": self.api_key}

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import builtins import builtins
import logging
import time import time
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -139,14 +140,19 @@ class AsyncCapsule:
info = await client.capsules.get(capsule_id) info = await client.capsules.get(capsule_id)
if info.status == Status.paused: if info.status == Status.paused:
info = await client.capsules.resume(capsule_id) await client.capsules.resume(capsule_id)
return cls( capsule = cls(
_capsule_id=capsule_id, _capsule_id=capsule_id,
_client=client, _client=client,
_info=info, _info=info,
) )
if info.status != Status.running:
await capsule.wait_ready()
return capsule
# ── Dual instance/static lifecycle ────────────────────────── # ── Dual instance/static lifecycle ──────────────────────────
destroy = _DualMethod("_instance_destroy", "_static_destroy") destroy = _DualMethod("_instance_destroy", "_static_destroy")
@ -223,12 +229,21 @@ class AsyncCapsule:
""" """
await self._client.capsules.ping(self._id) await self._client.capsules.ping(self._id)
async def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None: _POLL_INTERVALS: dict[Status, float] = {
Status.starting: 0.5,
Status.resuming: 0.5,
Status.pausing: 2.0,
Status.stopping: 1.0,
}
async def wait_ready(self, timeout: float = 30) -> None:
"""Await until the capsule status is ``running``. """Await until the capsule status is ``running``.
Polling interval adapts to the current transient status:
0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
Args: Args:
timeout (float): Maximum seconds to wait. Defaults to ``30``. timeout (float): Maximum seconds to wait. Defaults to ``30``.
interval (float): Polling interval in seconds. Defaults to ``0.5``.
Raises: Raises:
TimeoutError: If the capsule does not reach ``running`` state TimeoutError: If the capsule does not reach ``running`` state
@ -242,8 +257,13 @@ class AsyncCapsule:
if info.status == Status.running: if info.status == Status.running:
self._info = info self._info = info
return return
if info.status in (Status.error, Status.stopped, Status.paused): if info.status in (Status.error, Status.stopped):
raise RuntimeError(f"Capsule entered {info.status} state while waiting") raise RuntimeError(f"Capsule entered {info.status} state while waiting")
if info.status == Status.paused:
await self._client.capsules.resume(self._id)
interval = (
self._POLL_INTERVALS.get(info.status, 0.5) if info.status else 0.5
)
await asyncio.sleep(interval) await asyncio.sleep(interval)
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s") raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
@ -389,8 +409,8 @@ class AsyncCapsule:
) -> None: ) -> None:
try: try:
await self._instance_destroy() await self._instance_destroy()
except Exception: except Exception as exc:
pass logging.warning("Failed to destroy capsule %s: %s", self._id, exc)
try: try:
await self._client.aclose() await self._client.aclose()
except Exception: except Exception:

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import builtins import builtins
import logging
import time import time
from collections.abc import Iterator from collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
@ -99,17 +100,24 @@ class Capsule:
self._id: str = _capsule_id self._id: str = _capsule_id
self._client = _client self._client = _client
self._info = _info self._info = _info
if self._id is None:
self._client.close()
raise RuntimeError("API returned a capsule without an ID")
else: else:
# Public construction: create a capsule immediately
self._client = WrennClient(api_key=api_key, base_url=base_url) self._client = WrennClient(api_key=api_key, base_url=base_url)
try:
self._info = self._client.capsules.create( self._info = self._client.capsules.create(
template=template, template=template,
vcpus=vcpus, vcpus=vcpus,
memory_mb=memory_mb, memory_mb=memory_mb,
timeout_sec=timeout, timeout_sec=timeout,
) )
assert self._info.id is not None if self._info.id is None:
raise RuntimeError("API returned a capsule without an ID")
self._id = self._info.id self._id = self._info.id
except Exception:
self._client.close()
raise
self.commands = Commands(self._id, self._client.http) self.commands = Commands(self._id, self._client.http)
self.files = Files(self._id, self._client.http) self.files = Files(self._id, self._client.http)
@ -206,14 +214,19 @@ class Capsule:
info = client.capsules.get(capsule_id) info = client.capsules.get(capsule_id)
if info.status == Status.paused: if info.status == Status.paused:
info = client.capsules.resume(capsule_id) client.capsules.resume(capsule_id)
return cls( capsule = cls(
_capsule_id=capsule_id, _capsule_id=capsule_id,
_client=client, _client=client,
_info=info, _info=info,
) )
if info.status != Status.running:
capsule.wait_ready()
return capsule
# ── Dual instance/static lifecycle ────────────────────────── # ── Dual instance/static lifecycle ──────────────────────────
destroy = _DualMethod("_instance_destroy", "_static_destroy") destroy = _DualMethod("_instance_destroy", "_static_destroy")
@ -298,12 +311,21 @@ class Capsule:
""" """
self._client.capsules.ping(self._id) self._client.capsules.ping(self._id)
def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None: _POLL_INTERVALS: dict[Status, float] = {
Status.starting: 0.5,
Status.resuming: 0.5,
Status.pausing: 2.0,
Status.stopping: 1.0,
}
def wait_ready(self, timeout: float = 30) -> None:
"""Block until the capsule status is ``running``. """Block until the capsule status is ``running``.
Polling interval adapts to the current transient status:
0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
Args: Args:
timeout (float): Maximum seconds to wait. Defaults to ``30``. timeout (float): Maximum seconds to wait. Defaults to ``30``.
interval (float): Polling interval in seconds. Defaults to ``0.5``.
Raises: Raises:
TimeoutError: If the capsule does not reach ``running`` state TimeoutError: If the capsule does not reach ``running`` state
@ -317,8 +339,13 @@ class Capsule:
if info.status == Status.running: if info.status == Status.running:
self._info = info self._info = info
return return
if info.status in (Status.error, Status.stopped, Status.paused): if info.status in (Status.error, Status.stopped):
raise RuntimeError(f"Capsule entered {info.status} state while waiting") raise RuntimeError(f"Capsule entered {info.status} state while waiting")
if info.status == Status.paused:
self._client.capsules.resume(self._id)
interval = (
self._POLL_INTERVALS.get(info.status, 0.5) if info.status else 0.5
)
time.sleep(interval) time.sleep(interval)
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s") raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
@ -463,8 +490,8 @@ class Capsule:
) -> None: ) -> None:
try: try:
self._instance_destroy() self._instance_destroy()
except Exception: except Exception as exc:
pass logging.warning("Failed to destroy capsule %s: %s", self._id, exc)
try: try:
self._client.close() self._client.close()
except Exception: except Exception:

View File

@ -111,7 +111,7 @@ class CapsulesResource:
Raises: Raises:
WrennNotFoundError: If no capsule with the given ID exists. WrennNotFoundError: If no capsule with the given ID exists.
""" """
resp = self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT) resp = self._http.post(f"/v1/capsules/{id}/pause")
return CapsuleModel.model_validate(handle_response(resp)) return CapsuleModel.model_validate(handle_response(resp))
def resume(self, id: str) -> CapsuleModel: def resume(self, id: str) -> CapsuleModel:
@ -227,7 +227,7 @@ class AsyncCapsulesResource:
Raises: Raises:
WrennNotFoundError: If no capsule with the given ID exists. WrennNotFoundError: If no capsule with the given ID exists.
""" """
resp = await self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT) resp = await self._http.post(f"/v1/capsules/{id}/pause")
return CapsuleModel.model_validate(handle_response(resp)) return CapsuleModel.model_validate(handle_response(resp))
async def resume(self, id: str) -> CapsuleModel: async def resume(self, id: str) -> CapsuleModel:

View File

@ -40,6 +40,28 @@ class AsyncCapsule(BaseAsyncCapsule):
self._kernel_id = None self._kernel_id = None
self._proxy_client = None self._proxy_client = None
async def close(self) -> None:
if self._proxy_client is not None:
try:
await self._proxy_client.aclose()
except Exception:
pass
self._proxy_client = None
def __del__(self) -> None:
if self._proxy_client is not None:
try:
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
loop.create_task(self._proxy_client.aclose())
else:
loop.run_until_complete(self._proxy_client.aclose())
except Exception:
pass
self._proxy_client = None
@classmethod @classmethod
async def create( async def create(
cls, cls,
@ -126,8 +148,10 @@ class AsyncCapsule(BaseAsyncCapsule):
request=resp.request, request=resp.request,
response=resp, response=resp,
) )
except httpx.HTTPStatusError: except httpx.HTTPStatusError as exc:
if exc.response.status_code < 500:
raise raise
last_exc = exc
except Exception as exc: except Exception as exc:
last_exc = exc last_exc = exc
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
@ -164,8 +188,6 @@ class AsyncCapsule(BaseAsyncCapsule):
}, },
"buffers": [], "buffers": [],
"channel": "shell", "channel": "shell",
"msg_id": msg_id,
"msg_type": "execute_request",
} }
async def run_code( async def run_code(
@ -201,7 +223,7 @@ class AsyncCapsule(BaseAsyncCapsule):
ws_url = self._jupyter_ws_url(kernel_id) ws_url = self._jupyter_ws_url(kernel_id)
msg = self._jupyter_execute_request(code) msg = self._jupyter_execute_request(code)
msg_id = msg["msg_id"] msg_id = msg["header"]["msg_id"]
execution = Execution() execution = Execution()
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
@ -215,7 +237,7 @@ class AsyncCapsule(BaseAsyncCapsule):
break break
try: try:
data = await asyncio.wait_for(ws.receive_json(), timeout=time_left) data = await asyncio.wait_for(ws.receive_json(), timeout=time_left)
except (asyncio.TimeoutError, Exception): except Exception:
break break
if not data: if not data:
break break

View File

@ -70,6 +70,17 @@ class Capsule(BaseCapsule):
self._kernel_id = None self._kernel_id = None
self._proxy_client = None self._proxy_client = None
def close(self) -> None:
if self._proxy_client is not None:
try:
self._proxy_client.close()
except Exception:
pass
self._proxy_client = None
def __del__(self) -> None:
self.close()
@classmethod @classmethod
def create( def create(
cls, cls,
@ -150,8 +161,10 @@ class Capsule(BaseCapsule):
request=resp.request, request=resp.request,
response=resp, response=resp,
) )
except httpx.HTTPStatusError: except httpx.HTTPStatusError as exc:
if exc.response.status_code < 500:
raise raise
last_exc = exc
except Exception as exc: except Exception as exc:
last_exc = exc last_exc = exc
time.sleep(0.5) time.sleep(0.5)
@ -188,8 +201,6 @@ class Capsule(BaseCapsule):
}, },
"buffers": [], "buffers": [],
"channel": "shell", "channel": "shell",
"msg_id": msg_id,
"msg_type": "execute_request",
} }
def run_code( def run_code(
@ -227,7 +238,7 @@ class Capsule(BaseCapsule):
ws_url = self._jupyter_ws_url(kernel_id) ws_url = self._jupyter_ws_url(kernel_id)
msg = self._jupyter_execute_request(code) msg = self._jupyter_execute_request(code)
msg_id = msg["msg_id"] msg_id = msg["header"]["msg_id"]
execution = Execution() execution = Execution()
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
@ -241,7 +252,7 @@ class Capsule(BaseCapsule):
break break
try: try:
data = ws.receive_json(timeout=time_left) data = ws.receive_json(timeout=time_left)
except (TimeoutError, Exception): except Exception:
break break
if not data: if not data:
break break

View File

@ -5,7 +5,7 @@ import builtins
import json import json
from collections.abc import AsyncIterator, Iterator from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass from dataclasses import dataclass
from typing import overload, Literal from typing import Literal, overload
import httpx import httpx
import httpx_ws import httpx_ws
@ -198,7 +198,15 @@ class Commands:
if tag is not None: if tag is not None:
payload["tag"] = tag payload["tag"] = tag
resp = self._http.post(f"/v1/capsules/{self._capsule_id}/exec", json=payload) http_timeout: httpx.Timeout | None = None
if not background and timeout is not None:
http_timeout = httpx.Timeout(timeout + 10, connect=5.0)
resp = self._http.post(
f"/v1/capsules/{self._capsule_id}/exec",
json=payload,
timeout=http_timeout,
)
data = handle_response(resp) data = handle_response(resp)
assert isinstance(data, dict) assert isinstance(data, dict)
@ -379,8 +387,14 @@ class AsyncCommands:
if tag is not None: if tag is not None:
payload["tag"] = tag payload["tag"] = tag
http_timeout: httpx.Timeout | None = None
if not background and timeout is not None:
http_timeout = httpx.Timeout(timeout + 10, connect=5.0)
resp = await self._http.post( resp = await self._http.post(
f"/v1/capsules/{self._capsule_id}/exec", json=payload f"/v1/capsules/{self._capsule_id}/exec",
json=payload,
timeout=http_timeout,
) )
data = handle_response(resp) data = handle_response(resp)
assert isinstance(data, dict) assert isinstance(data, dict)

View File

@ -110,13 +110,18 @@ _ERROR_MAP: dict[str, type[WrennError]] = {
} }
def handle_response(resp: httpx.Response) -> dict | list: def _raise_for_status(resp: httpx.Response) -> None:
if resp.status_code >= 400: if resp.status_code < 400:
return
try: try:
body = resp.json() body = resp.json()
except Exception: except Exception:
resp.raise_for_status() raise WrennInternalError(
raise code="internal_error",
message=resp.text or f"HTTP {resp.status_code}",
status_code=resp.status_code,
)
err = body.get("error", {}) err = body.get("error", {})
code = err.get("code", "internal_error") code = err.get("code", "internal_error")
@ -129,7 +134,7 @@ def handle_response(resp: httpx.Response) -> dict | list:
code=code, code=code,
message=message, message=message,
status_code=resp.status_code, status_code=resp.status_code,
capsule_ids=body.get("sandbox_ids", []), capsule_ids=body.get("capsule_ids") or body.get("sandbox_ids", []),
) )
raise exc_cls( raise exc_cls(
@ -138,9 +143,16 @@ def handle_response(resp: httpx.Response) -> dict | list:
status_code=resp.status_code, status_code=resp.status_code,
) )
def handle_response(resp: httpx.Response) -> dict | list:
_raise_for_status(resp)
if resp.status_code == 204: if resp.status_code == 204:
return {} return {}
if not resp.content:
return {}
return resp.json() return resp.json()

View File

@ -5,7 +5,7 @@ from collections.abc import AsyncIterator, Iterator
import httpx import httpx
from wrenn.exceptions import WrennNotFoundError, handle_response from wrenn.exceptions import WrennNotFoundError, _raise_for_status, handle_response
from wrenn.models import FileEntry, ListDirResponse, MakeDirResponse from wrenn.models import FileEntry, ListDirResponse, MakeDirResponse
@ -46,7 +46,7 @@ class Files:
f"/v1/capsules/{self._capsule_id}/files/read", f"/v1/capsules/{self._capsule_id}/files/read",
json={"path": path}, json={"path": path},
) )
resp.raise_for_status() _raise_for_status(resp)
return resp.content return resp.content
def write(self, path: str, data: str | bytes) -> None: def write(self, path: str, data: str | bytes) -> None:
@ -65,7 +65,7 @@ class Files:
files={"file": ("upload", data)}, files={"file": ("upload", data)},
data={"path": path}, data={"path": path},
) )
resp.raise_for_status() _raise_for_status(resp)
def list(self, path: str, depth: int = 1) -> list[FileEntry]: def list(self, path: str, depth: int = 1) -> list[FileEntry]:
"""List directory contents. """List directory contents.
@ -179,7 +179,7 @@ class Files:
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}" "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}"
}, },
) )
resp.raise_for_status() _raise_for_status(resp)
def download_stream(self, path: str) -> Iterator[bytes]: def download_stream(self, path: str) -> Iterator[bytes]:
"""Stream a large file out of the capsule. """Stream a large file out of the capsule.
@ -243,7 +243,7 @@ class AsyncFiles:
f"/v1/capsules/{self._capsule_id}/files/read", f"/v1/capsules/{self._capsule_id}/files/read",
json={"path": path}, json={"path": path},
) )
resp.raise_for_status() _raise_for_status(resp)
return resp.content return resp.content
async def write(self, path: str, data: str | bytes) -> None: async def write(self, path: str, data: str | bytes) -> None:
@ -262,7 +262,7 @@ class AsyncFiles:
files={"file": ("upload", data)}, files={"file": ("upload", data)},
data={"path": path}, data={"path": path},
) )
resp.raise_for_status() _raise_for_status(resp)
async def list(self, path: str, depth: int = 1) -> list[FileEntry]: async def list(self, path: str, depth: int = 1) -> list[FileEntry]:
"""List directory contents. """List directory contents.
@ -377,7 +377,7 @@ class AsyncFiles:
"Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}" "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}"
}, },
) )
resp.raise_for_status() _raise_for_status(resp)
async def download_stream(self, path: str) -> AsyncIterator[bytes]: async def download_stream(self, path: str) -> AsyncIterator[bytes]:
"""Stream a large file out of the capsule. """Stream a large file out of the capsule.

View File

@ -1,6 +1,6 @@
# generated by datamodel-codegen: # generated by datamodel-codegen:
# filename: openapi.yaml # filename: openapi.yaml
# timestamp: 2026-04-22T20:21:34+00:00 # timestamp: 2026-05-15T07:57:28+00:00
from __future__ import annotations from __future__ import annotations
from pydantic import AwareDatetime, BaseModel, EmailStr, Field from pydantic import AwareDatetime, BaseModel, EmailStr, Field
@ -133,7 +133,10 @@ class Status(StrEnum):
pending = "pending" pending = "pending"
starting = "starting" starting = "starting"
running = "running" running = "running"
pausing = "pausing"
paused = "paused" paused = "paused"
resuming = "resuming"
stopping = "stopping"
hibernated = "hibernated" hibernated = "hibernated"
stopped = "stopped" stopped = "stopped"
missing = "missing" missing = "missing"

View File

@ -153,7 +153,8 @@ class PtySession:
if event.pid is not None: if event.pid is not None:
self._pid = event.pid self._pid = event.pid
if event.type == PtyEventType.exit: if event.type == PtyEventType.exit:
raise StopIteration self._done = True
return event
if event.type == PtyEventType.error and event.fatal: if event.type == PtyEventType.error and event.fatal:
self._done = True self._done = True
return event return event
@ -281,7 +282,8 @@ class AsyncPtySession:
if event.pid is not None: if event.pid is not None:
self._pid = event.pid self._pid = event.pid
if event.type == PtyEventType.exit: if event.type == PtyEventType.exit:
raise StopAsyncIteration self._done = True
return event
if event.type == PtyEventType.error and event.fatal: if event.type == PtyEventType.error and event.fatal:
self._done = True self._done = True
return event return event

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import httpx
import respx import respx
from wrenn.capsule import Capsule, _build_proxy_url from wrenn.capsule import Capsule, _build_proxy_url
@ -30,9 +31,13 @@ class TestCapsuleCreate:
@respx.mock @respx.mock
def test_capsule_constructor_creates(self): def test_capsule_constructor_creates(self):
respx.post(f"{BASE}/v1/capsules").respond( respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-1", "status": "pending", "template": "minimal"} 202, json={"id": "cl-1", "status": "starting", "template": "minimal"}
)
cap = Capsule(
template="minimal",
api_key="wrn_test1234567890abcdef12345678",
base_url=BASE,
) )
cap = Capsule(template="minimal", api_key="wrn_test1234567890abcdef12345678")
assert cap.capsule_id == "cl-1" assert cap.capsule_id == "cl-1"
assert hasattr(cap, "commands") assert hasattr(cap, "commands")
assert hasattr(cap, "files") assert hasattr(cap, "files")
@ -40,18 +45,18 @@ class TestCapsuleCreate:
@respx.mock @respx.mock
def test_capsule_create_classmethod(self): def test_capsule_create_classmethod(self):
respx.post(f"{BASE}/v1/capsules").respond( respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-2", "status": "pending"} 202, json={"id": "cl-2", "status": "starting"}
) )
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678") cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert cap.capsule_id == "cl-2" assert cap.capsule_id == "cl-2"
@respx.mock @respx.mock
def test_capsule_context_manager_kills(self): def test_capsule_context_manager_kills(self):
respx.post(f"{BASE}/v1/capsules").respond( respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-1", "status": "pending"} 202, json={"id": "cl-1", "status": "starting"}
) )
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204) kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
with Capsule(api_key="wrn_test1234567890abcdef12345678") as cap: with Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as cap:
assert cap.capsule_id == "cl-1" assert cap.capsule_id == "cl-1"
assert kill_route.called assert kill_route.called
@ -59,33 +64,37 @@ class TestCapsuleCreate:
def test_capsule_env_var(self, monkeypatch): def test_capsule_env_var(self, monkeypatch):
monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env_key") monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env_key")
respx.post(f"{BASE}/v1/capsules").respond( respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-3", "status": "pending"} 202, json={"id": "cl-3", "status": "starting"}
) )
cap = Capsule() cap = Capsule(base_url=BASE)
assert cap.capsule_id == "cl-3" assert cap.capsule_id == "cl-3"
class TestCapsuleStaticMethods: class TestCapsuleStaticMethods:
@respx.mock @respx.mock
def test_static_destroy(self): def test_static_destroy(self):
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204) route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678") Capsule._static_destroy(
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
)
assert route.called assert route.called
@respx.mock @respx.mock
def test_static_pause(self): def test_static_pause(self):
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond( respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
200, json={"id": "cl-1", "status": "paused"} 202, json={"id": "cl-1", "status": "pausing"}
) )
info = Capsule._static_pause("cl-1", api_key="wrn_test1234567890abcdef12345678") info = Capsule._static_pause(
assert info.status.value == "paused" "cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
)
assert info.status.value == "pausing"
@respx.mock @respx.mock
def test_static_list(self): def test_static_list(self):
respx.get(f"{BASE}/v1/capsules").respond( respx.get(f"{BASE}/v1/capsules").respond(
200, json=[{"id": "cl-1", "status": "running"}] 200, json=[{"id": "cl-1", "status": "running"}]
) )
items = Capsule.list(api_key="wrn_test1234567890abcdef12345678") items = Capsule.list(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert len(items) == 1 assert len(items) == 1
assert items[0].id == "cl-1" assert items[0].id == "cl-1"
@ -95,7 +104,7 @@ class TestCapsuleStaticMethods:
200, json={"id": "cl-1", "status": "running"} 200, json={"id": "cl-1", "status": "running"}
) )
info = Capsule._static_get_info( info = Capsule._static_get_info(
"cl-1", api_key="wrn_test1234567890abcdef12345678" "cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
) )
assert info.id == "cl-1" assert info.id == "cl-1"
@ -106,18 +115,24 @@ class TestCapsuleConnect:
respx.get(f"{BASE}/v1/capsules/cl-1").respond( respx.get(f"{BASE}/v1/capsules/cl-1").respond(
200, json={"id": "cl-1", "status": "running"} 200, json={"id": "cl-1", "status": "running"}
) )
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678") cap = Capsule.connect(
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
)
assert cap.capsule_id == "cl-1" assert cap.capsule_id == "cl-1"
@respx.mock @respx.mock
def test_connect_paused_resumes(self): def test_connect_paused_resumes(self):
respx.get(f"{BASE}/v1/capsules/cl-1").respond( get_route = respx.get(f"{BASE}/v1/capsules/cl-1")
200, json={"id": "cl-1", "status": "paused"} get_route.side_effect = [
) httpx.Response(200, json={"id": "cl-1", "status": "paused"}),
httpx.Response(200, json={"id": "cl-1", "status": "running"}),
]
respx.post(f"{BASE}/v1/capsules/cl-1/resume").respond( respx.post(f"{BASE}/v1/capsules/cl-1/resume").respond(
200, json={"id": "cl-1", "status": "running"} 202, json={"id": "cl-1", "status": "resuming"}
)
cap = Capsule.connect(
"cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
) )
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678")
assert cap.capsule_id == "cl-1" assert cap.capsule_id == "cl-1"

View File

@ -23,23 +23,23 @@ BASE = "https://app.wrenn.dev/api"
@pytest.fixture @pytest.fixture
def client(): def client():
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: with WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) 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", base_url=BASE)
class TestCapsules: class TestCapsules:
@respx.mock @respx.mock
def test_create(self, client): def test_create(self, client):
respx.post(f"{BASE}/v1/capsules").respond( respx.post(f"{BASE}/v1/capsules").respond(
201, 202,
json={ json={
"id": "sb-1", "id": "sb-1",
"status": "pending", "status": "starting",
"template": "base-python", "template": "base-python",
"vcpus": 2, "vcpus": 2,
"memory_mb": 1024, "memory_mb": 1024,
@ -48,12 +48,12 @@ class TestCapsules:
resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024) resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024)
assert isinstance(resp, Capsule) assert isinstance(resp, Capsule)
assert resp.id == "sb-1" assert resp.id == "sb-1"
assert resp.status == Status.pending assert resp.status == Status.starting
@respx.mock @respx.mock
def test_create_defaults(self, client): def test_create_defaults(self, client):
respx.post(f"{BASE}/v1/capsules").respond( respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "sb-2", "status": "pending"} 202, json={"id": "sb-2", "status": "starting"}
) )
resp = client.capsules.create() resp = client.capsules.create()
assert resp.id == "sb-2" assert resp.id == "sb-2"
@ -77,25 +77,25 @@ class TestCapsules:
@respx.mock @respx.mock
def test_destroy(self, client): def test_destroy(self, client):
route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(204) route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(202)
client.capsules.destroy("sb-1") client.capsules.destroy("sb-1")
assert route.called assert route.called
@respx.mock @respx.mock
def test_pause(self, client): def test_pause(self, client):
respx.post(f"{BASE}/v1/capsules/sb-1/pause").respond( respx.post(f"{BASE}/v1/capsules/sb-1/pause").respond(
200, json={"id": "sb-1", "status": "paused"} 202, json={"id": "sb-1", "status": "pausing"}
) )
resp = client.capsules.pause("sb-1") resp = client.capsules.pause("sb-1")
assert resp.status == Status.paused assert resp.status == Status.pausing
@respx.mock @respx.mock
def test_resume(self, client): def test_resume(self, client):
respx.post(f"{BASE}/v1/capsules/sb-1/resume").respond( respx.post(f"{BASE}/v1/capsules/sb-1/resume").respond(
200, json={"id": "sb-1", "status": "running"} 202, json={"id": "sb-1", "status": "resuming"}
) )
resp = client.capsules.resume("sb-1") resp = client.capsules.resume("sb-1")
assert resp.status == Status.running assert resp.status == Status.resuming
@respx.mock @respx.mock
def test_ping(self, client): def test_ping(self, client):
@ -221,7 +221,8 @@ class TestAuthModes:
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._http.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678"
def test_no_auth_raises(self): def test_no_auth_raises(self, monkeypatch):
monkeypatch.delenv("WRENN_API_KEY", raising=False)
with pytest.raises(ValueError, match="No API key"): with pytest.raises(ValueError, match="No API key"):
WrennClient() WrennClient()
@ -237,7 +238,7 @@ class TestAsyncClient:
async def test_async_capsules_create(self, async_client): async def test_async_capsules_create(self, async_client):
async with async_client: async with async_client:
respx.post(f"{BASE}/v1/capsules").respond( respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "sb-1", "status": "pending"} 202, json={"id": "sb-1", "status": "starting"}
) )
resp = await async_client.capsules.create(template="base-python") resp = await async_client.capsules.create(template="base-python")
assert resp.id == "sb-1" assert resp.id == "sb-1"

View File

@ -23,7 +23,7 @@ def _make_capsule(cap_id: str = "cl-abc") -> Capsule:
respx.post(f"{BASE}/v1/capsules").respond( respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": cap_id, "status": "running"} 201, json={"id": cap_id, "status": "running"}
) )
return Capsule(api_key="wrn_test1234567890abcdef12345678") return Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
class TestFilesRead: class TestFilesRead:
@ -311,12 +311,14 @@ class TestPtySessionIteration:
ws.receive_text.side_effect = messages ws.receive_text.side_effect = messages
session = PtySession(ws, "cl-abc") session = PtySession(ws, "cl-abc")
events = list(session) events = list(session)
assert len(events) == 2 assert len(events) == 3
assert events[0].type == PtyEventType.started assert events[0].type == PtyEventType.started
assert session.tag == "pty-abc12345" assert session.tag == "pty-abc12345"
assert session.pid == 1 assert session.pid == 1
assert events[1].type == PtyEventType.output assert events[1].type == PtyEventType.output
assert events[1].data == b"hello" assert events[1].data == b"hello"
assert events[2].type == PtyEventType.exit
assert events[2].exit_code == 0
def test_iter_stops_on_fatal_error(self): def test_iter_stops_on_fatal_error(self):
ws = MagicMock() ws = MagicMock()
@ -461,10 +463,11 @@ class TestAsyncPtySession:
events = [] events = []
async for event in session: async for event in session:
events.append(event) events.append(event)
assert len(events) == 2 assert len(events) == 3
assert events[0].type == PtyEventType.started assert events[0].type == PtyEventType.started
assert session.tag == "pty-xyz" assert session.tag == "pty-xyz"
assert session.pid == 5 assert session.pid == 5
assert events[2].type == PtyEventType.exit
class TestExports: class TestExports:

View File

@ -73,7 +73,7 @@ def _make_git(respx_mock=None) -> Git:
"""Create a Git instance bound to a test capsule.""" """Create a Git instance bound to a test capsule."""
from wrenn.client import WrennClient from wrenn.client import WrennClient
client = WrennClient(api_key="wrn_test1234567890abcdef12345678") client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
return Git(CAPSULE_ID, client.http) return Git(CAPSULE_ID, client.http)
@ -81,7 +81,7 @@ def _make_async_git() -> AsyncGit:
"""Create an AsyncGit instance bound to a test capsule.""" """Create an AsyncGit instance bound to a test capsule."""
from wrenn.client import AsyncWrennClient from wrenn.client import AsyncWrennClient
client = AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678") client = AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
return AsyncGit(CAPSULE_ID, client.http) return AsyncGit(CAPSULE_ID, client.http)
@ -926,7 +926,7 @@ class TestCapsuleWiring:
respx.post(f"{BASE}/v1/capsules").respond( respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-1", "status": "pending"} 201, json={"id": "cl-1", "status": "pending"}
) )
cap = Capsule(api_key="wrn_test1234567890abcdef12345678") cap = Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert hasattr(cap, "git") assert hasattr(cap, "git")
assert isinstance(cap.git, Git) assert isinstance(cap.git, Git)
@ -1017,7 +1017,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.commands import Commands from wrenn.commands import Commands
client = WrennClient(api_key="wrn_test1234567890abcdef12345678") client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
commands = Commands(CAPSULE_ID, client.http) commands = Commands(CAPSULE_ID, client.http)
route = respx.post(EXEC_URL).respond(200, json=_exec_response(stdout="3\n")) route = respx.post(EXEC_URL).respond(200, json=_exec_response(stdout="3\n"))
@ -1031,7 +1031,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.commands import Commands from wrenn.commands import Commands
client = WrennClient(api_key="wrn_test1234567890abcdef12345678") client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
commands = Commands(CAPSULE_ID, client.http) commands = Commands(CAPSULE_ID, client.http)
route = respx.post(EXEC_URL).respond(200, json=_exec_response()) route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1045,7 +1045,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.commands import Commands from wrenn.commands import Commands
client = WrennClient(api_key="wrn_test1234567890abcdef12345678") client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
commands = Commands(CAPSULE_ID, client.http) commands = Commands(CAPSULE_ID, client.http)
route = respx.post(EXEC_URL).respond(200, json=_exec_response()) route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1059,7 +1059,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.commands import Commands from wrenn.commands import Commands
client = WrennClient(api_key="wrn_test1234567890abcdef12345678") client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
commands = Commands(CAPSULE_ID, client.http) commands = Commands(CAPSULE_ID, client.http)
route = respx.post(EXEC_URL).respond(200, json=_exec_response()) route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1073,7 +1073,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.commands import Commands from wrenn.commands import Commands
client = WrennClient(api_key="wrn_test1234567890abcdef12345678") client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
commands = Commands(CAPSULE_ID, client.http) commands = Commands(CAPSULE_ID, client.http)
route = respx.post(EXEC_URL).respond(200, json=_exec_response()) route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1089,7 +1089,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.commands import Commands from wrenn.commands import Commands
client = WrennClient(api_key="wrn_test1234567890abcdef12345678") client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
commands = Commands(CAPSULE_ID, client.http) commands = Commands(CAPSULE_ID, client.http)
route = respx.post(EXEC_URL).respond(200, json=_exec_response()) route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1119,7 +1119,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.commands import Commands from wrenn.commands import Commands
client = WrennClient(api_key="wrn_test1234567890abcdef12345678") client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
commands = Commands(CAPSULE_ID, client.http) commands = Commands(CAPSULE_ID, client.http)
route = respx.post(EXEC_URL).respond(200, json={"pid": 42, "tag": "bg-1"}) route = respx.post(EXEC_URL).respond(200, json={"pid": 42, "tag": "bg-1"})