From a42f0b2e71aeba837a961013776dc882393dd397 Mon Sep 17 00:00:00 2001 From: Tasnim Kabir Sadik Date: Sat, 2 May 2026 21:59:18 +0600 Subject: [PATCH] v0.1.2 --- .gitignore | 1 + AGENTS.md | 56 +++++++++++++++++++++ pyproject.toml | 2 +- src/wrenn/_config.py | 28 ----------- src/wrenn/async_capsule.py | 9 ++-- src/wrenn/capsule.py | 34 ++++++++----- src/wrenn/code_interpreter/async_capsule.py | 34 ++++++++++--- src/wrenn/code_interpreter/capsule.py | 23 ++++++--- src/wrenn/commands.py | 20 ++++++-- src/wrenn/exceptions.py | 47 ++++++++++------- src/wrenn/files.py | 14 +++--- src/wrenn/pty.py | 6 ++- tests/test_capsule_features.py | 20 ++++---- tests/test_client.py | 7 +-- tests/test_filesystem_pty.py | 9 ++-- tests/test_git.py | 20 ++++---- 16 files changed, 217 insertions(+), 113 deletions(-) create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index 9670bd4..619209d 100644 --- a/.gitignore +++ b/.gitignore @@ -176,6 +176,7 @@ cython_debug/ CODE_EXECUTION.md +.opencode/ # AI .code-review-graph/ .claude diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..53b599d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# AGENTS.md + +## Project + +Wrenn Python SDK — a client library for the Wrenn microVM platform. e2b drop-in replacement. +Package name: `wrenn`. Python 3.13+, managed with [uv](https://docs.astral.sh/uv/). + +## Commands + +```bash +uv sync # install deps +make lint # ruff check + format check (no auto-fix) +make test # unit tests only (tests/test_client.py) +make test-integration # all tests including integration (needs live server) +make generate # regenerate models from OpenAPI spec (fetches from remote) +make check # lint + unit test +``` + +- `make test` only runs `tests/test_client.py`, not all unit tests. To run a specific test file: `uv run pytest tests/test_capsule_features.py -v` +- No typecheck step in Makefile or CI. `mypy` is a dev dependency but not wired up — do not assume it runs. + +## Architecture + +- `src/wrenn/` — the library package + - `capsule.py` / `async_capsule.py` — high-level `Capsule` / `AsyncCapsule` (main user-facing classes) + - `client.py` — low-level `WrennClient` / `AsyncWrennClient` + - `commands.py` — command execution and streaming + - `files.py` — filesystem operations + - `pty.py` — interactive terminal (PTY) over WebSocket + - `exceptions.py` — typed error hierarchy (`WrennError` base) + - `models/_generated.py` — **auto-generated** from OpenAPI spec via `datamodel-codegen` (never edit directly; run `make generate`) + - `sandbox.py` — deprecated `Sandbox` alias for `Capsule` + - `code_interpreter/` — specialized capsule for stateful Jupyter kernel execution +- `tests/` — unit tests use `respx` to mock `httpx`; integration tests are in `tests/integration/` +- `api/openapi.yaml` — downloaded OpenAPI spec used for code generation + +## Key Conventions + +- Generated code lives in `src/wrenn/models/_generated.py`. Never edit it. Run `make generate` to update. +- `Sandbox` is a deprecated alias for `Capsule`. New code should use `Capsule` / `AsyncCapsule`. +- Dual sync/async API: every major class has an `Async` counterpart. +- Uses `httpx` for HTTP, `httpx-ws` for WebSockets, `pydantic` for models. +- `__init__.py` uses `__getattr__` for lazy deprecated aliases (`Sandbox`, `WrennHostHasSandboxesError`). + +## Testing + +- Unit tests mock HTTP via `respx` (httpx mocking library). +- Integration tests require env vars: `WRENN_API_KEY` (or `WRENN_TOKEN`), optionally `WRENN_BASE_URL`. +- Integration test fixtures in `tests/integration/conftest.py` create real capsules and clean them up. +- `pytest` marker: `@pytest.mark.integration` for tests needing a live server. + +## CI + +Woodpecker CI (`.woodpecker/check.yml`) runs on push to `main` and `dev`: +1. `make lint` +2. `make test` (unit tests only — integration tests are not in CI) diff --git a/pyproject.toml b/pyproject.toml index c8f5d1a..365f7c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wrenn" -version = "0.1.1" +version = "0.1.2" description = "Python SDK for Wrenn" readme = "README.md" license = "MIT" diff --git a/src/wrenn/_config.py b/src/wrenn/_config.py index a9b57ad..fbdc889 100644 --- a/src/wrenn/_config.py +++ b/src/wrenn/_config.py @@ -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} diff --git a/src/wrenn/async_capsule.py b/src/wrenn/async_capsule.py index 509f53a..1d72408 100644 --- a/src/wrenn/async_capsule.py +++ b/src/wrenn/async_capsule.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import logging import builtins import time from collections.abc import AsyncIterator @@ -242,8 +243,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") @@ -389,8 +392,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: diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py index a5ed36a..29fe52f 100644 --- a/src/wrenn/capsule.py +++ b/src/wrenn/capsule.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import builtins import time from collections.abc import Iterator @@ -99,17 +100,24 @@ class Capsule: self._id: str = _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, - ) - assert self._info.id is not None - 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) @@ -317,8 +325,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") @@ -463,8 +473,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: diff --git a/src/wrenn/code_interpreter/async_capsule.py b/src/wrenn/code_interpreter/async_capsule.py index f61937c..b328f6b 100644 --- a/src/wrenn/code_interpreter/async_capsule.py +++ b/src/wrenn/code_interpreter/async_capsule.py @@ -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 diff --git a/src/wrenn/code_interpreter/capsule.py b/src/wrenn/code_interpreter/capsule.py index 344c3f3..7d70d91 100644 --- a/src/wrenn/code_interpreter/capsule.py +++ b/src/wrenn/code_interpreter/capsule.py @@ -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 diff --git a/src/wrenn/commands.py b/src/wrenn/commands.py index e24f898..98b596e 100644 --- a/src/wrenn/commands.py +++ b/src/wrenn/commands.py @@ -5,7 +5,7 @@ import builtins import json from collections.abc import AsyncIterator, Iterator from dataclasses import dataclass -from typing import overload, Literal +from typing import Literal, overload import httpx import httpx_ws @@ -198,7 +198,15 @@ class Commands: if tag is not None: 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) assert isinstance(data, dict) @@ -379,8 +387,14 @@ class AsyncCommands: if tag is not None: 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( - 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) assert isinstance(data, dict) diff --git a/src/wrenn/exceptions.py b/src/wrenn/exceptions.py index 438cfcb..af16f6c 100644 --- a/src/wrenn/exceptions.py +++ b/src/wrenn/exceptions.py @@ -110,34 +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: - resp.raise_for_status() - raise +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 {} diff --git a/src/wrenn/files.py b/src/wrenn/files.py index 94a1dcc..477aeca 100644 --- a/src/wrenn/files.py +++ b/src/wrenn/files.py @@ -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. diff --git a/src/wrenn/pty.py b/src/wrenn/pty.py index 83ee871..c116f2a 100644 --- a/src/wrenn/pty.py +++ b/src/wrenn/pty.py @@ -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 diff --git a/tests/test_capsule_features.py b/tests/test_capsule_features.py index 5c630da..825eb52 100644 --- a/tests/test_capsule_features.py +++ b/tests/test_capsule_features.py @@ -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" diff --git a/tests/test_client.py b/tests/test_client.py index 08168a6..36adce9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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() diff --git a/tests/test_filesystem_pty.py b/tests/test_filesystem_pty.py index 62ed91e..7de58e6 100644 --- a/tests/test_filesystem_pty.py +++ b/tests/test_filesystem_pty.py @@ -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: diff --git a/tests/test_git.py b/tests/test_git.py index e231834..2d1dcce 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -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"})