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
This commit is contained in:
Tasnim Kabir Sadik
2026-05-02 21:34:02 +06:00
parent 4a7db8e204
commit 04e5dc652f
13 changed files with 142 additions and 244 deletions

132
CLAUDE.md
View File

@ -1,132 +0,0 @@
## Design Context
### Users
Developers across the full spectrum — solo engineers building side projects, startup teams integrating sandboxed execution into products, and platform/infra engineers at larger organizations running production workloads on Firecracker microVMs. They arrive with context: they know what a process is, what a rootfs is, what a TTY means. The interface must feel at home for all three: approachable enough not to intimidate a hacker, precise enough to earn the trust of a production ops team. Never condescend, never oversimplify. Trust the user to understand what they're looking at.
**Primary job to be done:** Understand what's running, act on it confidently, and get back to code.
### Brand Personality
**Precise. Warm. Uncompromising.**
Wrenn is an engineer's favorite tool — built with visible care, not assembled from defaults. It runs real infrastructure (Firecracker microVMs), so the UI should reflect that seriousness without becoming cold or corporate. The warmth comes from the typography and color palette; the precision comes from hierarchy, density, and data fidelity.
Emotional goal: **in control.** Users leave a session with full confidence in what's running, what happened, and what comes next. Nothing is hidden, nothing is ambiguous.
### Aesthetic Direction
**Dark-only (permanently), industrial-warm, data-forward.**
No light mode planned. All design decisions should optimize for dark. The near-black-green background palette (`#0a0c0b` through `#2a302d`) reads as "black with intention" — not pitch black (cold) and not charcoal (dated). The sage green accent (`#5e8c58`) is muted and organic, a meaningful departure from the startup-green neon that saturates the developer tool space.
**Anti-references:**
- **Supabase**: avoid the friendly, approachable startup-green energy — too generic, too eager to please
- **AWS / GCP consoles**: avoid utility-first density without craft — functional but joyless, visually dated
**References that capture the right spirit:**
- The precision of a well-calibrated instrument
- Editorial typography from technical publications
- The quiet confidence of tools that don't need to explain themselves
### Type System
Four fonts with strict roles — this is the design system's strongest personality trait and must be respected:
| Font | CSS Class | Role | When to use |
|------|-----------|------|-------------|
| **Manrope** (variable, sans) | `font-sans` | UI workhorse | All body copy, nav, labels, buttons, form text |
| **Instrument Serif** | `font-serif` | Display / editorial | Page titles (h1), dialog headings, metric values, hero moments |
| **JetBrains Mono** (variable) | `font-mono` | Data / code | IDs, timestamps, key prefixes, file paths, terminal output, metrics |
| **Alice** | brand wordmark only | Brand wordmark | "Wrenn" in sidebar and login only — nowhere else |
Instrument Serif at scale creates the signature editorial moments. Mono provides the precision signal for technical data. Never swap these roles.
**Tracking overrides (app.css):**
- `.font-serif``letter-spacing: 0.015em` (positive tracking; Instrument Serif reads less condensed at display sizes)
- `.font-mono``font-variant-numeric: tabular-nums` (numbers align in tables and metric displays)
**Type scale (root: 87.5% = 14px base):**
| Token | Value | Use |
|---|---|---|
| `--text-display` | 2.571rem (~36px) | Auth section headings |
| `--text-page` | 2rem (~28px) | Page h1 titles |
| `--text-heading` | 1.429rem (~20px) | Dialog headings, empty states |
| `--text-body` | 1rem (~14px) | Primary body, buttons, inputs |
| `--text-ui` | 0.929rem (~13px) | Nav labels, table cells |
| `--text-meta` | 0.857rem (~12px) | Key prefixes, minor info |
| `--text-label` | 0.786rem (~11px) | Uppercase section labels |
| `--text-badge` | 0.714rem (~10px) | Live badges, tiny indicators |
### Color System
All values are CSS custom properties in `frontend/src/app.css`.
**Backgrounds (6-step near-black-green scale):**
| Token | Value | Use |
|---|---|---|
| `--color-bg-0` | `#0a0c0b` | Page base, sidebar deepest layer |
| `--color-bg-1` | `#0f1211` | Sidebar surface |
| `--color-bg-2` | `#141817` | Card backgrounds |
| `--color-bg-3` | `#1a1e1c` | Table headers, elevated surfaces |
| `--color-bg-4` | `#212624` | Hover states, inputs |
| `--color-bg-5` | `#2a302d` | Highlighted items, selected rows |
**Text (5-level hierarchy):**
| Token | Value | Use |
|---|---|---|
| `--color-text-bright` | `#eae7e2` | H1s, dialog headings |
| `--color-text-primary` | `#d0cdc6` | Body copy, primary labels |
| `--color-text-secondary` | `#9b9790` | Secondary labels, descriptions |
| `--color-text-tertiary` | `#6b6862` | Hints, placeholders |
| `--color-text-muted` | `#454340` | Dividers as text, ultra-subtle |
**Accent (sage green — use sparingly, must feel earned):**
| Token | Value | Use |
|---|---|---|
| `--color-accent` | `#5e8c58` | Primary CTA, live indicators, focus rings, active nav |
| `--color-accent-mid` | `#89a785` | Hover accent text |
| `--color-accent-bright` | `#a4c89f` | Accent on dark backgrounds |
| `--color-accent-glow` | `rgba(94,140,88,0.07)` | Subtle tinted backgrounds |
| `--color-accent-glow-mid` | `rgba(94,140,88,0.14)` | Hover tint on accent items |
**Status semantics:**
| Token | Value | Use |
|---|---|---|
| `--color-amber` | `#d4a73c` | Warning, paused state |
| `--color-red` | `#cf8172` | Error, destructive actions |
| `--color-blue` | `#5a9fd4` | Info, neutral system states |
**Borders:** `--color-border` (`#1f2321`) default; `--color-border-mid` (`#2a2f2c`) for inputs/hover.
### Component Patterns
**Buttons:**
- Primary: solid sage green (`--color-accent`), hover brightness boost + micro-lift (`-translate-y-px`)
- Secondary: bordered (`--color-border-mid`), text transitions to accent on hover
- Danger: red text + subtle red background on hover
- All: `transition-all duration-150`
**Inputs:**
- Border `--color-border`, background `--color-bg-2`; focus transitions border and icon to accent
- Group focus pattern: `group` wrapper + `group-focus-within:text-[var(--color-accent)]` on icon
**Tables / data lists:**
- Grid layout; header `bg-3` + uppercase `--text-label`; row hover `hover:bg-[var(--color-bg-3)]`
- Status stripe: left border color matches sandbox state
**Status indicators:** Running = animated ping + sage green dot; Paused = amber dot; Stopped = muted gray. Color is never the sole differentiator.
**Modals & dialogs:** Border + shadow only — no accent gradient bars/strips. `fadeUp` 0.35s entrance.
**Empty states:** Large icon with glow, Instrument Serif heading, secondary body text, CTA below, `iconFloat` 4s animation.
**Animations (always respect `prefers-reduced-motion`):** `fadeUp` (entrance), `status-ping` (live indicator), `iconFloat` (empty states), `spin-once` (refresh), staggered `animation-delay` on lists.
### Design Principles
1. **Precision over friendliness.** Every element earns its place. Wrenn doesn't need to tell you it's developer-friendly — that should be self-evident from the quality of the information architecture.
2. **Density with breathing room.** Data-forward doesn't mean cramped. Strategic whitespace creates calm hierarchy within dense contexts. Sections breathe; rows don't waste space.
3. **Industrial warmth.** The serif + mono + warm-black combination prevents sterility. This is a forge, not a gallery. The warmth is in the details, not the primary colors.
4. **Legible at speed.** Users scan dashboards in seconds. Strong typographic contrast (serif h1, mono IDs, sans body), consistent patterns, and predictable placement let users orientate instantly without reading everything.
5. **Craft signals trust.** For infrastructure that runs production code, the quality of the UI is a proxy for the quality of the product. Pixel-level decisions matter. Polish is not decoration — it's a trust signal.

View File

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

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
@ -240,8 +241,10 @@ class AsyncCapsule:
if info.status == Status.running:
self._info = info
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")
if info.status == Status.paused:
info = await self._client.capsules.resume(self._id)
await asyncio.sleep(interval)
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
@ -387,8 +390,8 @@ class AsyncCapsule:
) -> None:
try:
await self._instance_destroy()
except Exception:
pass
except Exception as exc:
logging.warning("Failed to destroy capsule %s: %s", self._id, exc)
try:
await self._client.aclose()
except Exception:

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import logging
import time
from collections.abc import Iterator
from contextlib import contextmanager
@ -94,21 +95,28 @@ class Capsule:
``WRENN_BASE_URL`` or the default production endpoint.
"""
if _capsule_id is not None:
# Internal construction path (from create/connect classmethods)
assert _client is not None
self._id = _capsule_id
self._client = _client
self._info = _info
if self._id is None:
self._client.close()
raise RuntimeError("API returned a capsule without an ID")
else:
# Public construction: create a capsule immediately
self._client = WrennClient(api_key=api_key, base_url=base_url)
self._info = self._client.capsules.create(
template=template,
vcpus=vcpus,
memory_mb=memory_mb,
timeout_sec=timeout,
)
self._id = self._info.id
try:
self._info = self._client.capsules.create(
template=template,
vcpus=vcpus,
memory_mb=memory_mb,
timeout_sec=timeout,
)
self._id = self._info.id
if self._id is None:
raise RuntimeError("API returned a capsule without an ID")
except Exception:
self._client.close()
raise
self.commands = Commands(self._id, self._client.http)
self.files = Files(self._id, self._client.http)
@ -316,8 +324,10 @@ class Capsule:
if info.status == Status.running:
self._info = info
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")
if info.status == Status.paused:
info = self._client.capsules.resume(self._id)
time.sleep(interval)
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
@ -462,8 +472,8 @@ class Capsule:
) -> None:
try:
self._instance_destroy()
except Exception:
pass
except Exception as exc:
logging.warning("Failed to destroy capsule %s: %s", self._id, exc)
try:
self._client.close()
except Exception:

View File

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

View File

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

View File

@ -110,37 +110,43 @@ _ERROR_MAP: dict[str, type[WrennError]] = {
}
def handle_response(resp: httpx.Response) -> dict | list:
if resp.status_code >= 400:
try:
body = resp.json()
except Exception:
raise WrennInternalError(
code="internal_error",
message=resp.text or f"HTTP {resp.status_code}",
status_code=resp.status_code,
)
def _raise_for_status(resp: httpx.Response) -> None:
if resp.status_code < 400:
return
err = body.get("error", {})
code = err.get("code", "internal_error")
message = err.get("message", resp.text)
try:
body = resp.json()
except Exception:
raise WrennInternalError(
code="internal_error",
message=resp.text or f"HTTP {resp.status_code}",
status_code=resp.status_code,
)
exc_cls = _ERROR_MAP.get(code, WrennError)
err = body.get("error", {})
code = err.get("code", "internal_error")
message = err.get("message", resp.text)
if exc_cls is WrennHostHasCapsulesError:
raise WrennHostHasCapsulesError(
code=code,
message=message,
status_code=resp.status_code,
capsule_ids=body.get("sandbox_ids", []),
)
exc_cls = _ERROR_MAP.get(code, WrennError)
raise exc_cls(
if exc_cls is WrennHostHasCapsulesError:
raise WrennHostHasCapsulesError(
code=code,
message=message,
status_code=resp.status_code,
capsule_ids=body.get("capsule_ids") or body.get("sandbox_ids", []),
)
raise exc_cls(
code=code,
message=message,
status_code=resp.status_code,
)
def handle_response(resp: httpx.Response) -> dict | list:
_raise_for_status(resp)
if resp.status_code == 204:
return {}

View File

@ -5,7 +5,7 @@ from collections.abc import AsyncIterator, Iterator
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
@ -46,7 +46,7 @@ class Files:
f"/v1/capsules/{self._capsule_id}/files/read",
json={"path": path},
)
resp.raise_for_status()
_raise_for_status(resp)
return resp.content
def write(self, path: str, data: str | bytes) -> None:
@ -65,7 +65,7 @@ class Files:
files={"file": ("upload", data)},
data={"path": path},
)
resp.raise_for_status()
_raise_for_status(resp)
def list(self, path: str, depth: int = 1) -> list[FileEntry]:
"""List directory contents.
@ -179,7 +179,7 @@ class Files:
"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]:
"""Stream a large file out of the capsule.
@ -243,7 +243,7 @@ class AsyncFiles:
f"/v1/capsules/{self._capsule_id}/files/read",
json={"path": path},
)
resp.raise_for_status()
_raise_for_status(resp)
return resp.content
async def write(self, path: str, data: str | bytes) -> None:
@ -262,7 +262,7 @@ class AsyncFiles:
files={"file": ("upload", data)},
data={"path": path},
)
resp.raise_for_status()
_raise_for_status(resp)
async def list(self, path: str, depth: int = 1) -> list[FileEntry]:
"""List directory contents.
@ -377,7 +377,7 @@ class AsyncFiles:
"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]:
"""Stream a large file out of the capsule.

View File

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

View File

@ -32,7 +32,7 @@ class TestCapsuleCreate:
respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-1", "status": "pending", "template": "minimal"}
)
cap = Capsule(template="minimal", api_key="wrn_test1234567890abcdef12345678")
cap = Capsule(template="minimal", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert cap.capsule_id == "cl-1"
assert hasattr(cap, "commands")
assert hasattr(cap, "files")
@ -42,7 +42,7 @@ class TestCapsuleCreate:
respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-2", "status": "pending"}
)
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678")
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert cap.capsule_id == "cl-2"
@respx.mock
@ -51,7 +51,7 @@ class TestCapsuleCreate:
201, json={"id": "cl-1", "status": "pending"}
)
kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
with Capsule(api_key="wrn_test1234567890abcdef12345678") as cap:
with Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as cap:
assert cap.capsule_id == "cl-1"
assert kill_route.called
@ -61,7 +61,7 @@ class TestCapsuleCreate:
respx.post(f"{BASE}/v1/capsules").respond(
201, json={"id": "cl-3", "status": "pending"}
)
cap = Capsule()
cap = Capsule(base_url=BASE)
assert cap.capsule_id == "cl-3"
@ -69,7 +69,7 @@ class TestCapsuleStaticMethods:
@respx.mock
def test_static_destroy(self):
route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678")
Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert route.called
@respx.mock
@ -77,7 +77,7 @@ class TestCapsuleStaticMethods:
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
200, json={"id": "cl-1", "status": "paused"}
)
info = Capsule._static_pause("cl-1", api_key="wrn_test1234567890abcdef12345678")
info = Capsule._static_pause("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert info.status.value == "paused"
@respx.mock
@ -85,7 +85,7 @@ class TestCapsuleStaticMethods:
respx.get(f"{BASE}/v1/capsules").respond(
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 items[0].id == "cl-1"
@ -95,7 +95,7 @@ class TestCapsuleStaticMethods:
200, json={"id": "cl-1", "status": "running"}
)
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"
@ -106,7 +106,7 @@ class TestCapsuleConnect:
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
200, json={"id": "cl-1", "status": "running"}
)
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678")
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert cap.capsule_id == "cl-1"
@respx.mock
@ -117,7 +117,7 @@ class TestCapsuleConnect:
respx.post(f"{BASE}/v1/capsules/cl-1/resume").respond(
200, json={"id": "cl-1", "status": "running"}
)
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678")
cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert cap.capsule_id == "cl-1"

View File

@ -23,13 +23,13 @@ BASE = "https://app.wrenn.dev/api"
@pytest.fixture
def client():
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
with WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as c:
yield c
@pytest.fixture
def async_client():
return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678")
return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
class TestCapsules:
@ -221,7 +221,8 @@ class TestAuthModes:
with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c:
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"):
WrennClient()

View File

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

View File

@ -73,7 +73,7 @@ def _make_git(respx_mock=None) -> Git:
"""Create a Git instance bound to a test capsule."""
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)
@ -81,7 +81,7 @@ def _make_async_git() -> AsyncGit:
"""Create an AsyncGit instance bound to a test capsule."""
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)
@ -926,7 +926,7 @@ class TestCapsuleWiring:
respx.post(f"{BASE}/v1/capsules").respond(
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 isinstance(cap.git, Git)
@ -1017,7 +1017,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient
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)
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.commands import Commands
client = WrennClient(api_key="wrn_test1234567890abcdef12345678")
client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
commands = Commands(CAPSULE_ID, client.http)
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1045,7 +1045,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient
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)
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1059,7 +1059,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient
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)
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1073,7 +1073,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient
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)
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1089,7 +1089,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient
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)
route = respx.post(EXEC_URL).respond(200, json=_exec_response())
@ -1119,7 +1119,7 @@ class TestCommandPayloadWrapping:
from wrenn.client import WrennClient
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)
route = respx.post(EXEC_URL).respond(200, json={"pid": 42, "tag": "bg-1"})