From f51a962fff211e08919cde27149cd3e5691a8d01 Mon Sep 17 00:00:00 2001 From: Tasnim Kabir Sadik Date: Fri, 10 Apr 2026 22:24:50 +0600 Subject: [PATCH 01/44] 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. --- .gitignore | 1 + AGENTS.md | 252 +++++++++ LICENSE | 20 +- Makefile | 16 +- pyproject.toml | 8 + src/wrenn/__init__.py | 53 +- src/wrenn/client.py | 534 +++++++++++++++++++ src/wrenn/exceptions.py | 53 ++ src/wrenn/models/__init__.py | 55 ++ src/wrenn/models/_generated.py | 245 +++++++++ src/wrenn/sandbox.py | 928 +++++++++++++++++++++++++++++++++ tests/test_client.py | 417 +++++++++++++++ tests/test_integration.py | 289 ++++++++++ tests/test_sandbox_features.py | 175 +++++++ uv.lock | 67 +++ 15 files changed, 3099 insertions(+), 14 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/wrenn/client.py create mode 100644 src/wrenn/exceptions.py create mode 100644 src/wrenn/models/__init__.py create mode 100644 src/wrenn/models/_generated.py create mode 100644 src/wrenn/sandbox.py create mode 100644 tests/test_client.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_sandbox_features.py diff --git a/.gitignore b/.gitignore index 36b13f1..23b2ad4 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,4 @@ cython_debug/ # PyPI configuration file .pypirc +CODE_EXECUTION.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..405766b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,252 @@ +# AGENTS.md + +This file provides strict guidance to AI coding agents and assistants when modifying code in the `wrenn-python-sdk` repository. Read this entirely before writing or refactoring any code. + +## Project Overview + +This is the official Python SDK for **Wrenn**, a microVM-based code execution platform. The SDK provides developers and AI agents with a clean, typed interface to interact with the Wrenn Control Plane over REST and WebSockets. + +**Important:** The SDK communicates exclusively with the Control Plane over HTTP/HTTPS and WebSockets. It does **not** generate or use gRPC stubs. The `envd` guest agent and `HostAgentService` are internal RPCs between the control plane and host agents — they are never reachable from the SDK. All data-plane operations (exec, file I/O) are proxied through the control plane's REST/WS endpoints. + +## Repository Architecture & Structure + +This is a modern Python package managed entirely by `uv`. It uses a flattened `src/` layout. + +```text +. +├── LICENSE +├── Makefile # Central command runner +├── pyproject.toml # uv dependency and build config +├── uv.lock # Exact dependency resolution +├── internal/ +│ └── api/ +│ └── openapi.yaml # Cached OpenAPI spec from the Go backend +├── src/ +│ └── wrenn/ # The actual importable Python package +│ ├── __init__.py # Version + top-level re-exports +│ ├── client.py # WrennClient & AsyncWrennClient (httpx transport) +│ ├── sandbox.py # Sandbox class (exec, files, context manager) +│ ├── exceptions.py # Typed exception hierarchy +│ ├── py.typed # PEP 561 marker +│ └── models/ +│ ├── __init__.py # Public re-exports via __all__ +│ └── _generated.py # DO NOT EDIT — generated by datamodel-codegen +└── tests/ # Pytest suite +``` + +## Build & Development Commands + +Never use raw `pip`, `venv`, or `python -m venv`. **All dependency management and script execution goes through `uv` and the `Makefile`.** + +```bash +make generate # Fetches openapi.yaml and runs datamodel-codegen → models/_generated.py +make lint # Runs ruff check and ruff format +make test # Runs pytest +make check # Runs lint + test +``` + +There is no `make proto`. The SDK does not generate gRPC stubs — the `envd` and `HostAgentService` protos are internal to the Go backend. + +## Dependency Management (`uv`) + +- **Adding a runtime dependency:** `uv add ` (e.g., `uv add httpx pydantic`) +- **Adding a dev dependency:** `uv add --dev ` (e.g., `uv add --dev pytest ruff`) +- **Running isolated scripts:** Use `uv run `. `uv` implicitly manages the `.venv`; do not try to manually activate it in automation scripts. + +## Code Generation Invariants (CRITICAL) + +The data models for this SDK are generated directly from the Go backend's OpenAPI contract (`internal/api/openapi.yaml`). + +1. **Never manually edit `src/wrenn/models/_generated.py`.** Any custom logic placed here will be destroyed on the next `make generate`. +2. If the Go API contract changes, run `make generate`. +3. **Export routing:** The `_generated.py` file is large. Users must never import from it directly. All user-facing models must be explicitly re-exported in `src/wrenn/models/__init__.py` using the `__all__` dunder list. +4. **Extending models:** If a generated Pydantic model needs custom Python methods, subclass it in a new file (e.g., `src/wrenn/sandbox.py` extends the generated `Sandbox` model) and export the subclass. + +## Authentication + +The SDK supports two authentication mechanisms, set via the `WrennClient` constructor: + +1. **API Key (primary):** Pass `api_key="wrn_..."` to the constructor. Sent as `X-API-Key` header. Format: `wrn_` + 32 hex chars. Used for programmatic/agent access. +2. **JWT (secondary):** Pass `token=""` to the constructor. Sent as `Authorization: Bearer ` header. Used for user-facing tooling. Tokens expire after 6 hours. + +Host tokens (`X-Host-Token`) are for the host agent binary only and are **not** exposed in the SDK. + +```python +client = WrennClient(api_key="wrn_ab12cd34...") # typical usage +client = WrennClient(token="eyJhbGci...") # alternative +``` + +## Core SDK Design Patterns + +### 1. Sync and Async Parity + +The SDK must natively support both synchronous and asynchronous workflows. +- Core logic lives in `WrennClient` and `AsyncWrennClient` inside `client.py`. +- Under the hood, rely on `httpx.Client` and `httpx.AsyncClient`. +- Resource namespaces are injected via constructor. + +### 2. Resource Namespaces + +The client exposes resources as plural namespaces matching the API path convention: + +```python +client = WrennClient(api_key="wrn_...") +client.sandboxes.create(template="base-python") +client.sandboxes.list() +client.snapshots.create(sandbox_id="cl-...") +client.api_keys.create(name="my-key") +client.hosts.list() +client.teams.list() +client.audit.list(limit=50) +client.builds.list() # admin-only +``` + +### 3. The Sandbox Class + +The `Sandbox` object is the primary developer-facing interface. It wraps the generated `Sandbox` model with lifecycle and data-plane methods: + +```python +with client.sandboxes.create("base-python") as sb: + sb.wait_ready(timeout=30) + + result = sb.exec("echo hello") + print(result.stdout) # "hello\n" + print(result.exit_code) # 0 + + sb.upload("/app/main.py", b"print('hello')") + data = sb.download("/app/main.py") + + sb.ping() + sb.pause() + sb.resume() +# Exiting the block automatically calls sb.destroy() +``` + +**Key methods:** + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `sb.exec(cmd)` | `POST /v1/sandboxes/{id}/exec` | Synchronous exec. Returns `ExecResult` with `stdout`, `stderr`, `exit_code`, `duration_ms`. | +| `sb.exec_stream(cmd)` | `WS GET /v1/sandboxes/{id}/exec/stream` | Streaming exec via WebSocket. Returns an `Iterator[StreamEvent]` yielding `start`, `stdout`, `stderr`, `exit`, `error` events. | +| `sb.upload(path, data)` | `POST /v1/sandboxes/{id}/files/write` | Upload a small file (multipart form-data). | +| `sb.download(path)` | `POST /v1/sandboxes/{id}/files/read` | Download a small file. Returns bytes. | +| `sb.stream_upload(path, stream)` | `POST /v1/sandboxes/{id}/files/stream/write` | Streaming multipart upload for large files. No in-memory buffering. | +| `sb.stream_download(path)` | `POST /v1/sandboxes/{id}/files/stream/read` | Streaming chunked download for large files. Returns `Iterator[bytes]`. | +| `sb.wait_ready(timeout=30)` | Polls `GET /v1/sandboxes/{id}` | Blocks until status is `running`. Raises `TimeoutError` on expiry. | +| `sb.ping()` | `POST /v1/sandboxes/{id}/ping` | Resets inactivity timer. | +| `sb.pause()` | `POST /v1/sandboxes/{id}/pause` | Snapshots and releases resources. | +| `sb.resume()` | `POST /v1/sandboxes/{id}/resume` | Restores from snapshot. | +| `sb.destroy()` | `DELETE /v1/sandboxes/{id}` | Tears down the sandbox. Called automatically by context manager. | +| `sb.metrics(range="10m")` | `GET /v1/sandboxes/{id}/metrics` | Returns CPU, memory, disk time-series. | +| `sb.run_code(code, language="python")` | Jupyter kernel via proxy WS | Stateful code execution in any language with a Jupyter kernel. Variables persist across calls. Returns `CodeResult` with `.text`, `.stdout`, `.stderr`, `.error`, `.data`. See `CODE_EXECUTION.md`. | + +### 4. Context Managers + +Sandboxes are ephemeral. The SDK must use context managers (`with` and `async with`) to guarantee cleanup: + +```python +with client.sandboxes.create("base-python") as sb: + sb.wait_ready(timeout=30) + result = sb.exec("python -c 'print(42)'") +# __exit__ calls sb.destroy() / DELETE /v1/sandboxes/{id} +``` + +### 5. Streaming Executions + +There are two distinct exec endpoints: + +**Synchronous exec** — `sb.exec(cmd, args=[], timeout_sec=30)` +- Calls `POST /v1/sandboxes/{id}/exec`. Blocks until the command completes. +- Returns an `ExecResult` with `stdout`, `stderr`, `exit_code`, `duration_ms`, `encoding`. + +**Streaming exec** — `sb.exec_stream(cmd, args=[])` +- Opens a WebSocket to `GET /v1/sandboxes/{id}/exec/stream`. +- Returns an `Iterator[StreamEvent]` (or `AsyncIterator[StreamEvent]` for async). +- The client sends `{"type": "start", "cmd": "...", "args": [...]}` as the first message. +- The server sends events: `StreamStartEvent(pid)`, `StreamStdoutEvent(data)`, `StreamStderrEvent(data)`, `StreamExitEvent(exit_code)`, `StreamErrorEvent(data)`. +- The connection closes after the process exits. The client can send `{"type": "stop"}` to terminate early. + +### 6. Error Handling + +Do not leak raw `httpx.HTTPStatusError` to the user. The server returns errors as: + +```json +{"error": {"code": "not_found", "message": "sandbox not found"}} +``` + +Map the `code` field (not just HTTP status) to typed exceptions: + +| Error code | HTTP status | Exception | +|-----------|-------------|-----------| +| `invalid_request` | 400 | `WrennValidationError` | +| `unauthorized` | 401 | `WrennAuthenticationError` | +| `forbidden` | 403 | `WrennForbiddenError` | +| `not_found` | 404 | `WrennNotFoundError` | +| `invalid_state` | 409 | `WrennConflictError` | +| `conflict` | 409 | `WrennConflictError` | +| `host_has_sandboxes` | 409 | `WrennHostHasSandboxesError` (includes `sandbox_ids`) | +| `host_unavailable` | 503 | `WrennHostUnavailableError` | +| `agent_error` | 502 | `WrennAgentError` | +| `internal_error` | 500 | `WrennInternalError` | + +All exceptions inherit from `WrennError` and expose `.code`, `.message`, and `.status_code`. + +### 7. Resource Coverage + +The full API surface exposed through resource namespaces: + +**`client.sandboxes`** — `create`, `list`, `get`, `destroy`, `get_stats` +**`client.snapshots`** — `create`, `list`, `delete` +**`client.api_keys`** — `create`, `list`, `delete` +**`client.hosts`** — `create`, `list`, `get`, `delete`, `delete_preview`, `regenerate_token`, `list_tags`, `add_tag`, `remove_tag` +**`client.teams`** — `list`, `create`, `get`, `rename`, `delete`, `list_members`, `add_member`, `update_member_role`, `remove_member`, `leave` +**`client.audit`** — `list` (paginated with `before`/`before_id` cursors) +**`client.builds`** — `create`, `list`, `get`, `cancel` (admin-only) +**`client.admin`** — `set_team_byoc`, `list_templates`, `delete_template` + +### 8. Sandbox Proxy / Port Forwarding + +Services running inside a sandbox are accessible via a reverse proxy. The control plane intercepts requests whose `Host` header matches `{port}-{sandbox_id}.{domain}` and forwards them to the host agent. + +The SDK exposes two helpers on the `Sandbox` object: + +**`sb.get_url(port) -> str`** +- Constructs the proxy URL from the client's `base_url`. +- Derivation: parse `base_url` host, build `http://{port}-{sandbox_id}.{host}`. +- Example: `base_url="https://api.wrenn.dev"`, `sb.id="cl-abc123"` → `"http://8888-cl-abc123.api.wrenn.dev"` +- Example: `base_url="http://localhost:8080"`, `sb.id="cl-abc123"` → `"http://8888-cl-abc123.localhost:8080"` + +**`sb.http_client -> httpx.Client`** +- A pre-configured `httpx.Client` with: + - `base_url` set to the proxy URL (root `/` maps to the proxied service) + - `X-API-Key` header set from the parent client's API key +- Allows direct HTTP interaction with services inside the sandbox without manual header management. +- Closed automatically when the sandbox context manager exits. + +**Auth:** Proxy requests require the `X-API-Key` header. JWT is not supported for proxy routes. If the client was constructed with a JWT token only, `sb.get_url()` and `sb.http_client` must raise `WrennAuthenticationError`. + +**Example: Jupyter inside a sandbox** + +```python +with client.sandboxes.create("python-jupyter") as sb: + sb.wait_ready(timeout=60) + + # High-level: stateful code execution (see CODE_EXECUTION.md) + result = sb.run_code("print('hello from persistent kernel')") + print(result.stdout) + + # Low-level: direct HTTP to Jupyter REST API + resp = sb.http_client.get("/api/kernels") + print(resp.json()) + + # Low-level: direct proxy URL for browser access + jupyter_url = sb.get_url(8888) +``` + +## Coding Conventions & Typing + +- **Python Target:** `3.13+`. Use modern syntax (`|` for Unions, standard library generics like `list[str]`). +- **Typing:** Everything must be strictly typed. Use `pyright` for validation. +- **Formatting:** `ruff` is the sole linter and formatter. Do not use `black`, `isort`, or `flake8`. +- **Docstrings:** Use Google-style docstrings. These surface to end-users via IDE hover. +- **No comments:** Do not add comments unless explicitly asked. diff --git a/LICENSE b/LICENSE index 583698c..6c40f1d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,18 +1,18 @@ MIT License -Copyright (c) 2026 wrenn +Copyright (c) 2026 M/S Omukk, Bangladesh -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile index 6d10f3c..e58a7af 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ # Makefile -.PHONY: generate +.PHONY: generate lint test check test-integration # Variables -SPEC_URL = "https://git.omukk.dev/wrenn/sandbox/raw/branch/main/internal/api/openapi.yaml" +SPEC_URL = "https://git.omukk.dev/wrenn/wrenn/raw/branch/main/internal/api/openapi.yaml" SPEC_PATH = "api/openapi.yaml" generate: @@ -22,3 +22,15 @@ generate: --target-python-version 3.13 \ --use-annotated \ --openapi-scopes schemas + +lint: + uv run ruff check src/ + uv run ruff format --check src/ + +test: + uv run pytest tests/test_client.py -v + +test-integration: + uv run pytest tests/ -v -m "integration or not integration" + +check: lint test diff --git a/pyproject.toml b/pyproject.toml index 0149f62..d7dbaff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,9 @@ authors = [ ] requires-python = ">=3.13" dependencies = [ + "email-validator>=2.3.0", "httpx>=0.28.1", + "httpx-ws>=0.9.0", "pydantic>=2.12.5", ] @@ -22,5 +24,11 @@ dev = [ "mypy>=1.20.0", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", + "respx>=0.23.1", "ruff>=0.15.10", ] + +[tool.pytest.ini_options] +markers = [ + "integration: integration tests (require live server)", +] diff --git a/src/wrenn/__init__.py b/src/wrenn/__init__.py index cc0f99e..1b90919 100644 --- a/src/wrenn/__init__.py +++ b/src/wrenn/__init__.py @@ -1,2 +1,51 @@ -def hello() -> str: - return "Hello from wrenn!" +from wrenn.client import AsyncWrennClient, WrennClient +from wrenn.exceptions import ( + WrennAgentError, + WrennAuthenticationError, + WrennConflictError, + WrennError, + WrennForbiddenError, + WrennHostHasSandboxesError, + WrennHostUnavailableError, + WrennInternalError, + WrennNotFoundError, + WrennValidationError, +) +from wrenn.sandbox import ( + CodeResult, + ExecResult, + Sandbox, + StreamErrorEvent, + StreamEvent, + StreamExitEvent, + StreamStartEvent, + StreamStderrEvent, + StreamStdoutEvent, +) + +__version__ = "0.1.0" + +__all__ = [ + "__version__", + "AsyncWrennClient", + "CodeResult", + "ExecResult", + "Sandbox", + "StreamErrorEvent", + "StreamEvent", + "StreamExitEvent", + "StreamStartEvent", + "StreamStderrEvent", + "StreamStdoutEvent", + "WrennAgentError", + "WrennAuthenticationError", + "WrennClient", + "WrennConflictError", + "WrennError", + "WrennForbiddenError", + "WrennHostHasSandboxesError", + "WrennHostUnavailableError", + "WrennInternalError", + "WrennNotFoundError", + "WrennValidationError", +] diff --git a/src/wrenn/client.py b/src/wrenn/client.py new file mode 100644 index 0000000..6ffa25c --- /dev/null +++ b/src/wrenn/client.py @@ -0,0 +1,534 @@ +from __future__ import annotations + +import builtins +from typing import cast + +import httpx + +from wrenn.exceptions import ( + WrennAgentError, + WrennAuthenticationError, + WrennConflictError, + WrennError, + WrennForbiddenError, + WrennHostHasSandboxesError, + WrennHostUnavailableError, + WrennInternalError, + WrennNotFoundError, + WrennValidationError, +) +from wrenn.models import ( + APIKeyResponse, + AuthResponse, + CreateHostResponse, + Host, + Sandbox as SandboxModel, + Template, +) +from wrenn.sandbox import Sandbox + +DEFAULT_BASE_URL = "https://api.wrenn.dev" + +_ERROR_MAP: dict[str, type[WrennError]] = { + "invalid_request": WrennValidationError, + "unauthorized": WrennAuthenticationError, + "forbidden": WrennForbiddenError, + "not_found": WrennNotFoundError, + "invalid_state": WrennConflictError, + "conflict": WrennConflictError, + "host_has_sandboxes": WrennHostHasSandboxesError, + "host_unavailable": WrennHostUnavailableError, + "agent_error": WrennAgentError, + "internal_error": WrennInternalError, +} + + +def _handle_response(resp: httpx.Response) -> dict | list: + if resp.status_code >= 400: + try: + body = resp.json() + except Exception: + resp.raise_for_status() + raise + + err = body.get("error", {}) + code = err.get("code", "internal_error") + message = err.get("message", resp.text) + + exc_cls = _ERROR_MAP.get(code, WrennError) + + if exc_cls is WrennHostHasSandboxesError: + raise WrennHostHasSandboxesError( + code=code, + message=message, + status_code=resp.status_code, + sandbox_ids=body.get("sandbox_ids", []), + ) + + raise exc_cls( + code=code, + message=message, + status_code=resp.status_code, + ) + + if resp.status_code == 204: + return {} + + return resp.json() + + +def _build_headers(api_key: str | None, token: str | None) -> dict[str, str]: + headers: dict[str, str] = {"Content-Type": "application/json"} + if api_key: + headers["X-API-Key"] = api_key + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +class AuthResource: + """Sync auth operations.""" + + def __init__(self, http: httpx.Client) -> None: + self._http = http + + def signup(self, email: str, password: str) -> AuthResponse: + resp = self._http.post( + "/v1/auth/signup", json={"email": email, "password": password} + ) + return AuthResponse.model_validate(_handle_response(resp)) + + def login(self, email: str, password: str) -> AuthResponse: + resp = self._http.post( + "/v1/auth/login", json={"email": email, "password": password} + ) + return AuthResponse.model_validate(_handle_response(resp)) + + +class AsyncAuthResource: + """Async auth operations.""" + + def __init__(self, http: httpx.AsyncClient) -> None: + self._http = http + + async def signup(self, email: str, password: str) -> AuthResponse: + resp = await self._http.post( + "/v1/auth/signup", json={"email": email, "password": password} + ) + return AuthResponse.model_validate(_handle_response(resp)) + + async def login(self, email: str, password: str) -> AuthResponse: + resp = await self._http.post( + "/v1/auth/login", json={"email": email, "password": password} + ) + return AuthResponse.model_validate(_handle_response(resp)) + + +class APIKeysResource: + """Sync API key operations.""" + + def __init__(self, http: httpx.Client) -> None: + self._http = http + + def create(self, name: str | None = None) -> APIKeyResponse: + payload: dict = {} + if name is not None: + payload["name"] = name + resp = self._http.post("/v1/api-keys", json=payload) + return APIKeyResponse.model_validate(_handle_response(resp)) + + def list(self) -> list[APIKeyResponse]: + resp = self._http.get("/v1/api-keys") + return [APIKeyResponse.model_validate(item) for item in _handle_response(resp)] + + def delete(self, id: str) -> None: + resp = self._http.delete(f"/v1/api-keys/{id}") + _handle_response(resp) + + +class AsyncAPIKeysResource: + """Async API key operations.""" + + def __init__(self, http: httpx.AsyncClient) -> None: + self._http = http + + async def create(self, name: str | None = None) -> APIKeyResponse: + payload: dict = {} + if name is not None: + payload["name"] = name + resp = await self._http.post("/v1/api-keys", json=payload) + return APIKeyResponse.model_validate(_handle_response(resp)) + + async def list(self) -> list[APIKeyResponse]: + resp = await self._http.get("/v1/api-keys") + return [APIKeyResponse.model_validate(item) for item in _handle_response(resp)] + + async def delete(self, id: str) -> None: + resp = await self._http.delete(f"/v1/api-keys/{id}") + _handle_response(resp) + + +class SandboxesResource: + """Sync sandbox control-plane operations.""" + + def __init__( + self, + http: httpx.Client, + base_url: str, + api_key: str | None = None, + token: str | None = None, + ) -> None: + self._http = http + self._base_url = base_url + self._api_key = api_key + self._token = token + + def create( + self, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout_sec: int | None = None, + ) -> Sandbox: + payload: dict = {} + if template is not None: + payload["template"] = template + if vcpus is not None: + payload["vcpus"] = vcpus + if memory_mb is not None: + payload["memory_mb"] = memory_mb + if timeout_sec is not None: + payload["timeout_sec"] = timeout_sec + resp = self._http.post("/v1/sandboxes", json=payload) + model = SandboxModel.model_validate(_handle_response(resp)) + sb = Sandbox.model_validate(model.model_dump()) + sb._bind(self._http, self._base_url, self._api_key, self._token) + return sb + + def list(self) -> list[SandboxModel]: + resp = self._http.get("/v1/sandboxes") + return [SandboxModel.model_validate(item) for item in _handle_response(resp)] + + def get(self, id: str) -> SandboxModel: + resp = self._http.get(f"/v1/sandboxes/{id}") + return SandboxModel.model_validate(_handle_response(resp)) + + def destroy(self, id: str) -> None: + resp = self._http.delete(f"/v1/sandboxes/{id}") + _handle_response(resp) + + +class AsyncSandboxesResource: + """Async sandbox control-plane operations.""" + + def __init__( + self, + http: httpx.AsyncClient, + base_url: str, + api_key: str | None = None, + token: str | None = None, + ) -> None: + self._http = http + self._base_url = base_url + self._api_key = api_key + self._token = token + + async def create( + self, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout_sec: int | None = None, + ) -> Sandbox: + payload: dict = {} + if template is not None: + payload["template"] = template + if vcpus is not None: + payload["vcpus"] = vcpus + if memory_mb is not None: + payload["memory_mb"] = memory_mb + if timeout_sec is not None: + payload["timeout_sec"] = timeout_sec + resp = await self._http.post("/v1/sandboxes", json=payload) + model = SandboxModel.model_validate(_handle_response(resp)) + sb = Sandbox.model_validate(model.model_dump()) + sb._bind(self._http, self._base_url, self._api_key, self._token) + return sb + + async def list(self) -> list[SandboxModel]: + resp = await self._http.get("/v1/sandboxes") + return [SandboxModel.model_validate(item) for item in _handle_response(resp)] + + async def get(self, id: str) -> SandboxModel: + resp = await self._http.get(f"/v1/sandboxes/{id}") + return SandboxModel.model_validate(_handle_response(resp)) + + async def destroy(self, id: str) -> None: + resp = await self._http.delete(f"/v1/sandboxes/{id}") + _handle_response(resp) + + +class SnapshotsResource: + """Sync snapshot operations.""" + + def __init__(self, http: httpx.Client) -> None: + self._http = http + + def create( + self, + sandbox_id: str, + name: str | None = None, + overwrite: bool = False, + ) -> Template: + payload: dict = {"sandbox_id": sandbox_id} + if name is not None: + payload["name"] = name + params: dict = {} + if overwrite: + params["overwrite"] = "true" + resp = self._http.post("/v1/snapshots", json=payload, params=params) + return Template.model_validate(_handle_response(resp)) + + def list(self, type: str | None = None) -> list[Template]: + params: dict = {} + if type is not None: + params["type"] = type + resp = self._http.get("/v1/snapshots", params=params) + return [Template.model_validate(item) for item in _handle_response(resp)] + + def delete(self, name: str) -> None: + resp = self._http.delete(f"/v1/snapshots/{name}") + _handle_response(resp) + + +class AsyncSnapshotsResource: + """Async snapshot operations.""" + + def __init__(self, http: httpx.AsyncClient) -> None: + self._http = http + + async def create( + self, + sandbox_id: str, + name: str | None = None, + overwrite: bool = False, + ) -> Template: + payload: dict = {"sandbox_id": sandbox_id} + if name is not None: + payload["name"] = name + params: dict = {} + if overwrite: + params["overwrite"] = "true" + resp = await self._http.post("/v1/snapshots", json=payload, params=params) + return Template.model_validate(_handle_response(resp)) + + async def list(self, type: str | None = None) -> list[Template]: + params: dict = {} + if type is not None: + params["type"] = type + resp = await self._http.get("/v1/snapshots", params=params) + return [Template.model_validate(item) for item in _handle_response(resp)] + + async def delete(self, name: str) -> None: + resp = await self._http.delete(f"/v1/snapshots/{name}") + _handle_response(resp) + + +class HostsResource: + """Sync host operations.""" + + def __init__(self, http: httpx.Client) -> None: + self._http = http + + def create( + self, + type: str, + team_id: str | None = None, + provider: str | None = None, + availability_zone: str | None = None, + ) -> CreateHostResponse: + payload: dict = {"type": type} + if team_id is not None: + payload["team_id"] = team_id + if provider is not None: + payload["provider"] = provider + if availability_zone is not None: + payload["availability_zone"] = availability_zone + resp = self._http.post("/v1/hosts", json=payload) + return CreateHostResponse.model_validate(_handle_response(resp)) + + def list(self) -> list[Host]: + resp = self._http.get("/v1/hosts") + return [Host.model_validate(item) for item in _handle_response(resp)] + + def get(self, id: str) -> Host: + resp = self._http.get(f"/v1/hosts/{id}") + return Host.model_validate(_handle_response(resp)) + + def delete(self, id: str) -> None: + resp = self._http.delete(f"/v1/hosts/{id}") + _handle_response(resp) + + def regenerate_token(self, id: str) -> CreateHostResponse: + resp = self._http.post(f"/v1/hosts/{id}/token") + return CreateHostResponse.model_validate(_handle_response(resp)) + + def list_tags(self, id: str) -> builtins.list[str]: + resp = self._http.get(f"/v1/hosts/{id}/tags") + return cast(builtins.list[str], _handle_response(resp)) + + def add_tag(self, id: str, tag: str) -> None: + resp = self._http.post(f"/v1/hosts/{id}/tags", json={"tag": tag}) + _handle_response(resp) + + def remove_tag(self, id: str, tag: str) -> None: + resp = self._http.delete(f"/v1/hosts/{id}/tags/{tag}") + _handle_response(resp) + + +class AsyncHostsResource: + """Async host operations.""" + + def __init__(self, http: httpx.AsyncClient) -> None: + self._http = http + + async def create( + self, + type: str, + team_id: str | None = None, + provider: str | None = None, + availability_zone: str | None = None, + ) -> CreateHostResponse: + payload: dict = {"type": type} + if team_id is not None: + payload["team_id"] = team_id + if provider is not None: + payload["provider"] = provider + if availability_zone is not None: + payload["availability_zone"] = availability_zone + resp = await self._http.post("/v1/hosts", json=payload) + return CreateHostResponse.model_validate(_handle_response(resp)) + + async def list(self) -> list[Host]: + resp = await self._http.get("/v1/hosts") + return [Host.model_validate(item) for item in _handle_response(resp)] + + async def get(self, id: str) -> Host: + resp = await self._http.get(f"/v1/hosts/{id}") + return Host.model_validate(_handle_response(resp)) + + async def delete(self, id: str) -> None: + resp = await self._http.delete(f"/v1/hosts/{id}") + _handle_response(resp) + + async def regenerate_token(self, id: str) -> CreateHostResponse: + resp = await self._http.post(f"/v1/hosts/{id}/token") + return CreateHostResponse.model_validate(_handle_response(resp)) + + async def list_tags(self, id: str) -> builtins.list[str]: + resp = await self._http.get(f"/v1/hosts/{id}/tags") + return cast(builtins.list[str], _handle_response(resp)) + + async def add_tag(self, id: str, tag: str) -> None: + resp = await self._http.post(f"/v1/hosts/{id}/tags", json={"tag": tag}) + _handle_response(resp) + + async def remove_tag(self, id: str, tag: str) -> None: + resp = await self._http.delete(f"/v1/hosts/{id}/tags/{tag}") + _handle_response(resp) + + +class WrennClient: + """Synchronous client for the Wrenn API. + + Authenticate with either an API key or a JWT token. + + Args: + api_key: API key (``wrn_...``). Sent as ``X-API-Key`` header. + token: JWT token. Sent as ``Authorization: Bearer`` header. + base_url: Wrenn Control Plane URL. + """ + + def __init__( + self, + api_key: str | None = None, + token: str | None = None, + base_url: str = DEFAULT_BASE_URL, + ) -> None: + if not api_key and not token: + raise ValueError("Either api_key or token must be provided") + + headers = _build_headers(api_key, token) + self._http = httpx.Client(base_url=base_url, headers=headers) + self._api_key = api_key + self._token = token + self._base_url = base_url + + self.auth = AuthResource(self._http) + self.api_keys = APIKeysResource(self._http) + self.sandboxes = SandboxesResource(self._http, base_url, api_key, token) + self.snapshots = SnapshotsResource(self._http) + self.hosts = HostsResource(self._http) + + def close(self) -> None: + """Close the underlying HTTP connection pool.""" + self._http.close() + + def __enter__(self) -> WrennClient: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object, + ) -> None: + self.close() + + +class AsyncWrennClient: + """Asynchronous client for the Wrenn API. + + Authenticate with either an API key or a JWT token. + + Args: + api_key: API key (``wrn_...``). Sent as ``X-API-Key`` header. + token: JWT token. Sent as ``Authorization: Bearer`` header. + base_url: Wrenn Control Plane URL. + """ + + def __init__( + self, + api_key: str | None = None, + token: str | None = None, + base_url: str = DEFAULT_BASE_URL, + ) -> None: + if not api_key and not token: + raise ValueError("Either api_key or token must be provided") + + headers = _build_headers(api_key, token) + self._http = httpx.AsyncClient(base_url=base_url, headers=headers) + self._api_key = api_key + self._token = token + self._base_url = base_url + + self.auth = AsyncAuthResource(self._http) + self.api_keys = AsyncAPIKeysResource(self._http) + self.sandboxes = AsyncSandboxesResource(self._http, base_url, api_key, token) + self.snapshots = AsyncSnapshotsResource(self._http) + self.hosts = AsyncHostsResource(self._http) + + async def aclose(self) -> None: + """Close the underlying async HTTP connection pool.""" + await self._http.aclose() + + async def __aenter__(self) -> AsyncWrennClient: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object, + ) -> None: + await self.aclose() diff --git a/src/wrenn/exceptions.py b/src/wrenn/exceptions.py new file mode 100644 index 0000000..0a6b644 --- /dev/null +++ b/src/wrenn/exceptions.py @@ -0,0 +1,53 @@ +from __future__ import annotations + + +class WrennError(Exception): + """Base exception for all Wrenn SDK errors.""" + + def __init__(self, code: str, message: str, status_code: int) -> None: + self.code = code + self.message = message + self.status_code = status_code + super().__init__(message) + + +class WrennValidationError(WrennError): + """400 — Invalid request parameters.""" + + +class WrennAuthenticationError(WrennError): + """401 — Invalid or missing authentication.""" + + +class WrennForbiddenError(WrennError): + """403 — Authenticated but not authorized.""" + + +class WrennNotFoundError(WrennError): + """404 — Resource not found.""" + + +class WrennConflictError(WrennError): + """409 — State conflict (e.g. invalid_state).""" + + +class WrennHostHasSandboxesError(WrennConflictError): + """409 — Host still has running sandboxes.""" + + def __init__( + self, code: str, message: str, status_code: int, sandbox_ids: list[str] + ) -> None: + self.sandbox_ids = sandbox_ids + super().__init__(code, message, status_code) + + +class WrennHostUnavailableError(WrennError): + """503 — No suitable host available.""" + + +class WrennAgentError(WrennError): + """502 — Host agent returned an error.""" + + +class WrennInternalError(WrennError): + """500 — Unexpected server error.""" diff --git a/src/wrenn/models/__init__.py b/src/wrenn/models/__init__.py new file mode 100644 index 0000000..bddfa94 --- /dev/null +++ b/src/wrenn/models/__init__.py @@ -0,0 +1,55 @@ +from wrenn.models._generated import ( + APIKeyResponse, + AuthResponse, + CreateAPIKeyRequest, + CreateHostRequest, + CreateHostResponse, + CreateSandboxRequest, + CreateSnapshotRequest, + Encoding, + Error, + Error1, + ExecRequest, + ExecResponse, + Host, + LoginRequest, + ReadFileRequest, + RegisterHostRequest, + RegisterHostResponse, + Sandbox, + SignupRequest, + Status, + Status1, + Template, + Type, + Type1, + Type2, +) + +__all__ = [ + "APIKeyResponse", + "AuthResponse", + "CreateAPIKeyRequest", + "CreateHostRequest", + "CreateHostResponse", + "CreateSandboxRequest", + "CreateSnapshotRequest", + "Encoding", + "Error", + "Error1", + "ExecRequest", + "ExecResponse", + "Host", + "LoginRequest", + "ReadFileRequest", + "RegisterHostRequest", + "RegisterHostResponse", + "Sandbox", + "SignupRequest", + "Status", + "Status1", + "Template", + "Type", + "Type1", + "Type2", +] diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py new file mode 100644 index 0000000..ec70bef --- /dev/null +++ b/src/wrenn/models/_generated.py @@ -0,0 +1,245 @@ +# generated by datamodel-codegen: +# filename: openapi.yaml +# timestamp: 2026-04-09T15:01:48+00:00 + +from __future__ import annotations + +from enum import StrEnum +from typing import Annotated + +from pydantic import AwareDatetime, BaseModel, EmailStr, Field + + +class SignupRequest(BaseModel): + email: EmailStr + password: Annotated[str, Field(min_length=8)] + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class AuthResponse(BaseModel): + token: Annotated[str | None, Field(description="JWT token (valid for 6 hours)")] = ( + None + ) + user_id: str | None = None + team_id: str | None = None + email: str | None = None + + +class CreateAPIKeyRequest(BaseModel): + name: str | None = "Unnamed API Key" + + +class APIKeyResponse(BaseModel): + id: str | None = None + team_id: str | None = None + name: str | None = None + key_prefix: Annotated[ + str | None, Field(description='Display prefix (e.g. "wrn_ab12cd34...")') + ] = None + created_at: AwareDatetime | None = None + last_used: AwareDatetime | None = None + key: Annotated[ + str | None, + Field( + description="Full plaintext key. Only returned on creation, never again." + ), + ] = None + + +class CreateSandboxRequest(BaseModel): + template: str | None = "minimal" + vcpus: int | None = 1 + memory_mb: int | None = 512 + timeout_sec: Annotated[ + int | None, + Field( + description="Auto-pause TTL in seconds. The sandbox is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause.\n" + ), + ] = 0 + + +class Status(StrEnum): + pending = "pending" + running = "running" + paused = "paused" + stopped = "stopped" + error = "error" + + +class Sandbox(BaseModel): + id: str | None = None + status: Status | None = None + template: str | None = None + vcpus: int | None = None + memory_mb: int | None = None + timeout_sec: int | None = None + guest_ip: str | None = None + host_ip: str | None = None + created_at: AwareDatetime | None = None + started_at: AwareDatetime | None = None + last_active_at: AwareDatetime | None = None + last_updated: AwareDatetime | None = None + + +class CreateSnapshotRequest(BaseModel): + sandbox_id: Annotated[ + str, Field(description="ID of the running sandbox to snapshot.") + ] + name: Annotated[ + str | None, + Field(description="Name for the snapshot template. Auto-generated if omitted."), + ] = None + + +class Type(StrEnum): + base = "base" + snapshot = "snapshot" + + +class Template(BaseModel): + name: str | None = None + type: Type | None = None + vcpus: int | None = None + memory_mb: int | None = None + size_bytes: int | None = None + created_at: AwareDatetime | None = None + + +class ExecRequest(BaseModel): + cmd: str + args: list[str] | None = None + timeout_sec: int | None = 30 + + +class Encoding(StrEnum): + """ + Output encoding. "base64" when stdout/stderr contain binary data. + """ + + utf_8 = "utf-8" + base64 = "base64" + + +class ExecResponse(BaseModel): + sandbox_id: str | None = None + cmd: str | None = None + stdout: str | None = None + stderr: str | None = None + exit_code: int | None = None + duration_ms: int | None = None + encoding: Annotated[ + Encoding | None, + Field( + description='Output encoding. "base64" when stdout/stderr contain binary data.' + ), + ] = None + + +class ReadFileRequest(BaseModel): + path: Annotated[str, Field(description="Absolute file path inside the sandbox")] + + +class Type1(StrEnum): + """ + Host type. Regular hosts are shared; BYOC hosts belong to a team. + """ + + regular = "regular" + byoc = "byoc" + + +class CreateHostRequest(BaseModel): + type: Annotated[ + Type1, + Field( + description="Host type. Regular hosts are shared; BYOC hosts belong to a team." + ), + ] + team_id: Annotated[str | None, Field(description="Required for BYOC hosts.")] = None + provider: Annotated[ + str | None, + Field(description="Cloud provider (e.g. aws, gcp, hetzner, bare-metal)."), + ] = None + availability_zone: Annotated[ + str | None, Field(description="Availability zone (e.g. us-east, eu-west).") + ] = None + + +class RegisterHostRequest(BaseModel): + token: Annotated[ + str, Field(description="One-time registration token from POST /v1/hosts.") + ] + arch: Annotated[ + str | None, Field(description="CPU architecture (e.g. x86_64, aarch64).") + ] = None + cpu_cores: int | None = None + memory_mb: int | None = None + disk_gb: int | None = None + address: Annotated[str, Field(description="Host agent address (ip:port).")] + + +class Type2(StrEnum): + regular = "regular" + byoc = "byoc" + + +class Status1(StrEnum): + pending = "pending" + online = "online" + offline = "offline" + draining = "draining" + + +class Host(BaseModel): + id: str | None = None + type: Type2 | None = None + team_id: str | None = None + provider: str | None = None + availability_zone: str | None = None + arch: str | None = None + cpu_cores: int | None = None + memory_mb: int | None = None + disk_gb: int | None = None + address: str | None = None + status: Status1 | None = None + last_heartbeat_at: AwareDatetime | None = None + created_by: str | None = None + created_at: AwareDatetime | None = None + updated_at: AwareDatetime | None = None + + +class AddTagRequest(BaseModel): + tag: str + + +class Error1(BaseModel): + code: str | None = None + message: str | None = None + + +class Error(BaseModel): + error: Error1 | None = None + + +class CreateHostResponse(BaseModel): + host: Host | None = None + registration_token: Annotated[ + str | None, + Field( + description="One-time registration token for the host agent. Expires in 1 hour." + ), + ] = None + + +class RegisterHostResponse(BaseModel): + host: Host | None = None + token: Annotated[ + str | None, + Field( + description="Long-lived host JWT for X-Host-Token header. Valid for 1 year." + ), + ] = None diff --git a/src/wrenn/sandbox.py b/src/wrenn/sandbox.py new file mode 100644 index 0000000..ac9b237 --- /dev/null +++ b/src/wrenn/sandbox.py @@ -0,0 +1,928 @@ +from __future__ import annotations + +import asyncio +import base64 +import json +import time +import uuid +from collections.abc import AsyncIterator, Iterator +from typing import Any + +import httpx +import httpx_ws + +from wrenn.exceptions import WrennAuthenticationError +from wrenn.models import ExecResponse, Status +from wrenn.models import Sandbox as SandboxModel + + +class ExecResult: + """Typed result from a synchronous exec call.""" + + __slots__ = ("stdout", "stderr", "exit_code", "duration_ms", "encoding") + + def __init__( + self, + stdout: str, + stderr: str, + exit_code: int, + duration_ms: int | None, + encoding: str | None, + ) -> None: + self.stdout = stdout + self.stderr = stderr + self.exit_code = exit_code + self.duration_ms = duration_ms + self.encoding = encoding + + +class CodeResult: + """Typed result from stateful code execution (``run_code``). + + Attributes: + text: text/plain representation of the result. + data: rich MIME bundle (e.g. ``{"image/png": "..."}``). + stdout: accumulated stdout output. + stderr: accumulated stderr output. + error: language-specific error/traceback string. + """ + + __slots__ = ("text", "data", "stdout", "stderr", "error") + + def __init__( + self, + text: str | None = None, + data: dict[str, str] | None = None, + stdout: str = "", + stderr: str = "", + error: str | None = None, + ) -> None: + self.text = text + self.data = data + self.stdout = stdout + self.stderr = stderr + self.error = error + + +class StreamEvent: + """Base class for streaming exec events.""" + + __slots__ = ("type",) + + def __init__(self, type: str) -> None: + self.type = type + + +class StreamStartEvent(StreamEvent): + """Process started.""" + + __slots__ = ("pid",) + + def __init__(self, pid: int) -> None: + super().__init__("start") + self.pid = pid + + +class StreamStdoutEvent(StreamEvent): + """Stdout data received.""" + + __slots__ = ("data",) + + def __init__(self, data: str) -> None: + super().__init__("stdout") + self.data = data + + +class StreamStderrEvent(StreamEvent): + """Stderr data received.""" + + __slots__ = ("data",) + + def __init__(self, data: str) -> None: + super().__init__("stderr") + self.data = data + + +class StreamExitEvent(StreamEvent): + """Process exited.""" + + __slots__ = ("exit_code",) + + def __init__(self, exit_code: int) -> None: + super().__init__("exit") + self.exit_code = exit_code + + +class StreamErrorEvent(StreamEvent): + """Error occurred.""" + + __slots__ = ("data",) + + def __init__(self, data: str) -> None: + super().__init__("error") + self.data = data + + +def _parse_stream_event(raw: dict) -> StreamEvent: + t = raw.get("type") + if t == "start": + return StreamStartEvent(pid=raw.get("pid", 0)) + if t == "stdout": + return StreamStdoutEvent(data=raw.get("data", "")) + if t == "stderr": + return StreamStderrEvent(data=raw.get("data", "")) + if t == "exit": + return StreamExitEvent(exit_code=raw.get("exit_code", -1)) + if t == "error": + return StreamErrorEvent(data=raw.get("data", "")) + return StreamEvent(type=t or "unknown") + + +def _build_proxy_url(base_url: str, sandbox_id: str | None, port: int) -> str: + parsed = httpx.URL(base_url) + host = parsed.host + if parsed.port: + host = f"{host}:{parsed.port}" + scheme = "ws" if parsed.scheme == "http" else "wss" + return f"{scheme}://{port}-{sandbox_id}.{host}" + + +class Sandbox(SandboxModel): + """Developer-facing sandbox interface wrapping the generated Sandbox model. + + Provides data-plane methods (exec, file I/O, lifecycle), sandbox proxy + helpers, and context-manager support for automatic cleanup. + """ + + _http: httpx.Client | None + _async_http: httpx.AsyncClient | None + _base_url: str + _api_key: str | None + _token: str | None + _proxy_client: httpx.Client | None + _async_proxy_client: httpx.AsyncClient | None + _kernel_id: str | None + _jupyter_ws: Any + _async_jupyter_ws: Any + + def _bind( + self, + http: httpx.Client | httpx.AsyncClient, + base_url: str, + api_key: str | None = None, + token: str | None = None, + ) -> None: + self._base_url = base_url + self._api_key = api_key + self._token = token + self._proxy_client = None + self._async_proxy_client = None + self._kernel_id = None + self._jupyter_ws = None + self._async_jupyter_ws = None + if isinstance(http, httpx.Client): + self._http = http + self._async_http = None + else: + self._http = None # type: ignore[assignment] + self._async_http = http + + def _require_api_key(self) -> str: + if not self._api_key: + raise WrennAuthenticationError( + code="unauthorized", + message="Proxy requires an API key. JWT-only clients cannot use proxy routes.", + status_code=401, + ) + return self._api_key + + def _clear_content_type(self) -> dict[str, str]: + assert self._http is not None + headers = dict(self._http.headers) + headers.pop("Content-Type", None) + return headers + + def _async_clear_content_type(self) -> dict[str, str]: + assert self._async_http is not None + headers = dict(self._async_http.headers) + headers.pop("Content-Type", None) + return headers + + def get_url(self, port: int) -> str: + """Construct the proxy URL for a port inside this sandbox. + + Args: + port: Port number of the service running inside the sandbox. + + Returns: + A URL string like ``http://8888-cl-abc123.api.wrenn.dev``. + + Raises: + WrennAuthenticationError: If the client was constructed with JWT only. + """ + self._require_api_key() + return _build_proxy_url(self._base_url, self.id, port) + + @property + def http_client(self) -> httpx.Client: + """A pre-configured ``httpx.Client`` targeting the sandbox proxy on port 8888. + + The client has the ``X-API-Key`` header set and ``base_url`` pointing to + the proxy URL for port 8888. Closed automatically when the sandbox exits. + + Raises: + WrennAuthenticationError: If the client was constructed with JWT only. + """ + self._require_api_key() + if self._proxy_client is None: + url = ( + _build_proxy_url(self._base_url, self.id, 8888) + .replace("ws://", "http://") + .replace("wss://", "https://") + ) + self._proxy_client = httpx.Client( + base_url=url, + headers={"X-API-Key": self._api_key}, # type: ignore[dict-item, arg-type] + ) + return self._proxy_client + + def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None: + """Block until the sandbox status is ``running``. + + Args: + timeout: Maximum seconds to wait. + interval: Seconds between polls. + + Raises: + TimeoutError: If the sandbox does not become ready in time. + """ + assert self._http is not None + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + resp = self._http.get(f"/v1/sandboxes/{self.id}") + data = resp.json() + status = data.get("status") + if status == Status.running: + self.status = Status.running + return + if status in (Status.error, Status.stopped): + raise RuntimeError(f"Sandbox entered {status} state while waiting") + time.sleep(interval) + raise TimeoutError(f"Sandbox {self.id} did not become ready within {timeout}s") + + async def async_wait_ready( + self, timeout: float = 30, interval: float = 0.5 + ) -> None: + """Async version of ``wait_ready``.""" + assert self._async_http is not None + import asyncio + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + resp = await self._async_http.get(f"/v1/sandboxes/{self.id}") + data = resp.json() + status = data.get("status") + if status == Status.running: + self.status = Status.running + return + if status in (Status.error, Status.stopped): + raise RuntimeError(f"Sandbox entered {status} state while waiting") + await asyncio.sleep(interval) + raise TimeoutError(f"Sandbox {self.id} did not become ready within {timeout}s") + + def exec( + self, + cmd: str, + args: list[str] | None = None, + timeout_sec: int | None = 30, + ) -> ExecResult: + """Execute a command synchronously inside the sandbox. + + Args: + cmd: Command to run. + args: Optional positional arguments. + timeout_sec: Execution timeout in seconds. + + Returns: + An ``ExecResult`` with ``stdout``, ``stderr``, ``exit_code``, ``duration_ms``. + """ + assert self._http is not None + payload: dict = {"cmd": cmd} + if args is not None: + payload["args"] = args + if timeout_sec is not None: + payload["timeout_sec"] = timeout_sec + resp = self._http.post(f"/v1/sandboxes/{self.id}/exec", json=payload) + resp.raise_for_status() + er = ExecResponse.model_validate(resp.json()) + stdout = er.stdout or "" + stderr = er.stderr or "" + if er.encoding == "base64": + stdout = base64.b64decode(stdout).decode("utf-8", errors="replace") + if stderr: + stderr = base64.b64decode(stderr).decode("utf-8", errors="replace") + return ExecResult( + stdout=stdout, + stderr=stderr, + exit_code=er.exit_code if er.exit_code is not None else -1, + duration_ms=er.duration_ms, + encoding=er.encoding, + ) + + async def async_exec( + self, + cmd: str, + args: list[str] | None = None, + timeout_sec: int | None = 30, + ) -> ExecResult: + """Async version of ``exec``.""" + assert self._async_http is not None + payload: dict = {"cmd": cmd} + if args is not None: + payload["args"] = args + if timeout_sec is not None: + payload["timeout_sec"] = timeout_sec + resp = await self._async_http.post( + f"/v1/sandboxes/{self.id}/exec", json=payload + ) + resp.raise_for_status() + er = ExecResponse.model_validate(resp.json()) + stdout = er.stdout or "" + stderr = er.stderr or "" + if er.encoding == "base64": + stdout = base64.b64decode(stdout).decode("utf-8", errors="replace") + if stderr: + stderr = base64.b64decode(stderr).decode("utf-8", errors="replace") + return ExecResult( + stdout=stdout, + stderr=stderr, + exit_code=er.exit_code if er.exit_code is not None else -1, + duration_ms=er.duration_ms, + encoding=er.encoding, + ) + + def exec_stream( + self, + cmd: str, + args: list[str] | None = None, + ) -> Iterator[StreamEvent]: + """Execute a command via WebSocket, yielding ``StreamEvent`` objects. + + Args: + cmd: Command to run. + args: Optional positional arguments. + + Yields: + ``StreamStartEvent``, ``StreamStdoutEvent``, ``StreamStderrEvent``, + ``StreamExitEvent``, or ``StreamErrorEvent``. + """ + assert self._http is not None + with httpx_ws.ws_connect( # type: ignore[attr-defined] + f"/v1/sandboxes/{self.id}/exec/stream", + self._http, + ) as ws: + start_msg: dict = {"type": "start", "cmd": cmd} + if args: + start_msg["args"] = args + ws.send(json.dumps(start_msg)) + for raw_msg in ws: + event = _parse_stream_event(json.loads(raw_msg)) + yield event + if event.type in ("exit", "error"): + break + + async def async_exec_stream( + self, cmd: str, args: list[str] | None = None + ) -> AsyncIterator[StreamEvent]: + """Async version of ``exec_stream``.""" + assert self._async_http is not None + async with httpx_ws.aconnect_ws( # type: ignore[attr-defined, var-annotated] + f"/v1/sandboxes/{self.id}/exec/stream", self._async_http + ) as ws: + start_msg: dict = {"type": "start", "cmd": cmd} + if args: + start_msg["args"] = args + await ws.send_text(json.dumps(start_msg)) + + try: + while True: + raw_data = await ws.receive_json() + event = _parse_stream_event(raw_data) + yield event + + if event.type in ("exit", "error"): + break + except httpx_ws.WebSocketDisconnect: + pass + + def upload(self, path: str, data: bytes) -> None: + """Upload a small file to the sandbox. + + Args: + path: Absolute destination path inside the sandbox. + data: File contents as bytes. + """ + assert self._http is not None + original_ct = self._http.headers.pop("Content-Type", None) + try: + resp = self._http.post( + f"/v1/sandboxes/{self.id}/files/write", + files={"file": ("upload", data)}, + data={"path": path}, + ) + finally: + if original_ct is not None: + self._http.headers["content-type"] = original_ct + + resp.raise_for_status() + + async def async_upload(self, path: str, data: bytes) -> None: + """Async version of ``upload``.""" + assert self._async_http is not None + original_ct = self._async_http.headers.pop("Content-Type", None) + try: + resp = await self._async_http.post( + f"/v1/sandboxes/{self.id}/files/write", + files={"file": ("upload", data)}, + data={"path": path}, + ) + finally: + if original_ct is not None: + self._async_http.headers["Content-Type"] = original_ct + + resp.raise_for_status() + + def download(self, path: str) -> bytes: + """Download a small file from the sandbox. + + Args: + path: Absolute file path inside the sandbox. + + Returns: + File contents as bytes. + """ + assert self._http is not None + resp = self._http.post( + f"/v1/sandboxes/{self.id}/files/read", + json={"path": path}, + ) + resp.raise_for_status() + return resp.content + + async def async_download(self, path: str) -> bytes: + """Async version of ``download``.""" + assert self._async_http is not None + resp = await self._async_http.post( + f"/v1/sandboxes/{self.id}/files/read", + json={"path": path}, + ) + resp.raise_for_status() + return resp.content + + def stream_upload(self, path: str, stream: Iterator[bytes]) -> None: + """Streaming upload for large files. + + Args: + path: Absolute destination path inside the sandbox. + stream: An iterator yielding byte chunks. + """ + assert self._http is not None + + def _gen() -> Iterator[bytes]: + yield from stream + + original_ct = self._http.headers.pop("Content-Type", None) + try: + resp = self._http.post( + f"/v1/sandboxes/{self.id}/files/stream/write", + files={"file": ("upload", _gen())}, # type: ignore[dict-item] + data={"path": path}, + ) + finally: + if original_ct is not None: + self._http.headers["Content-Type"] = original_ct + + resp.raise_for_status() + + async def async_stream_upload( + self, path: str, stream: AsyncIterator[bytes] + ) -> None: + """Async version of ``stream_upload``.""" + assert self._async_http is not None + + async def _gen() -> AsyncIterator[bytes]: + async for chunk in stream: + yield chunk + + original_ct = self._async_http.headers.pop("Content-Type", None) + try: + resp = await self._async_http.post( + f"/v1/sandboxes/{self.id}/files/stream/write", + files={"file": ("upload", _gen())}, # type: ignore[dict-item] + data={"path": path}, + ) + finally: + if original_ct is not None: + self._async_http.headers["Content-Type"] = original_ct + + resp.raise_for_status() + + def stream_download(self, path: str) -> Iterator[bytes]: + """Streaming download for large files. + + Args: + path: Absolute file path inside the sandbox. + + Yields: + Byte chunks. + """ + assert self._http is not None + with self._http.stream( + "POST", + f"/v1/sandboxes/{self.id}/files/stream/read", + json={"path": path}, + ) as resp: + resp.raise_for_status() + yield from resp.iter_bytes() + + async def async_stream_download(self, path: str) -> AsyncIterator[bytes]: + """Async version of ``stream_download``.""" + assert self._async_http is not None + async with self._async_http.stream( + "POST", + f"/v1/sandboxes/{self.id}/files/stream/read", + json={"path": path}, + ) as resp: + resp.raise_for_status() + async for chunk in resp.aiter_bytes(): + yield chunk + + def ping(self) -> None: + """Reset the sandbox inactivity timer.""" + assert self._http is not None + resp = self._http.post(f"/v1/sandboxes/{self.id}/ping") + resp.raise_for_status() + + async def async_ping(self) -> None: + """Async version of ``ping``.""" + assert self._async_http is not None + resp = await self._async_http.post(f"/v1/sandboxes/{self.id}/ping") + resp.raise_for_status() + + def pause(self) -> Sandbox: + """Pause the sandbox (snapshot and release resources). + + Returns: + Updated ``Sandbox`` with new status. + """ + assert self._http is not None + resp = self._http.post(f"/v1/sandboxes/{self.id}/pause") + resp.raise_for_status() + updated = Sandbox.model_validate(resp.json()) + self.status = updated.status + return self + + async def async_pause(self) -> Sandbox: + """Async version of ``pause``.""" + assert self._async_http is not None + resp = await self._async_http.post(f"/v1/sandboxes/{self.id}/pause") + resp.raise_for_status() + updated = Sandbox.model_validate(resp.json()) + self.status = updated.status + return self + + def resume(self) -> Sandbox: + """Resume a paused sandbox from its snapshot. + + Returns: + Updated ``Sandbox`` with new status. + """ + assert self._http is not None + resp = self._http.post(f"/v1/sandboxes/{self.id}/resume") + resp.raise_for_status() + updated = Sandbox.model_validate(resp.json()) + self.status = updated.status + return self + + async def async_resume(self) -> Sandbox: + """Async version of ``resume``.""" + assert self._async_http is not None + resp = await self._async_http.post(f"/v1/sandboxes/{self.id}/resume") + resp.raise_for_status() + updated = Sandbox.model_validate(resp.json()) + self.status = updated.status + return self + + def destroy(self) -> None: + """Tear down the sandbox.""" + assert self._http is not None + resp = self._http.delete(f"/v1/sandboxes/{self.id}") + resp.raise_for_status() + + async def async_destroy(self) -> None: + """Async version of ``destroy``.""" + assert self._async_http is not None + resp = await self._async_http.delete(f"/v1/sandboxes/{self.id}") + resp.raise_for_status() + + def _ensure_kernel(self, jupyter_timeout: float = 30) -> str: + """Ensure a Jupyter kernel is running, creating one if needed. + + Polls the Jupyter server until it responds, then creates a kernel. + + Args: + jupyter_timeout: Maximum seconds to wait for Jupyter to become available. + + Returns: + The kernel ID. + + Raises: + TimeoutError: If Jupyter doesn't respond within the timeout. + """ + current_kernel = self._kernel_id + if current_kernel is not None: + return current_kernel + deadline = time.monotonic() + jupyter_timeout + last_exc: Exception | None = None + while time.monotonic() < deadline: + try: + resp = self.http_client.post("/api/kernels") + if resp.status_code < 500: + resp.raise_for_status() + data = resp.json() + self._kernel_id = data["id"] + return str(self._kernel_id) + last_exc = httpx.HTTPStatusError( + f"Jupyter returned {resp.status_code}", + request=resp.request, + response=resp, + ) + except (httpx.HTTPStatusError, WrennAuthenticationError): + raise + except Exception as exc: + last_exc = exc + time.sleep(0.5) + raise TimeoutError( + f"Jupyter not available within {jupyter_timeout}s: {last_exc}" + ) + + async def _async_ensure_kernel(self, jupyter_timeout: float = 30) -> str: + """Async version of ``_ensure_kernel``.""" + import asyncio + + current_kernel = self._kernel_id + if current_kernel is not None: + return current_kernel + + self._require_api_key() + if self._async_proxy_client is None: + url = ( + _build_proxy_url(self._base_url, self.id, 8888) + .replace("ws://", "http://") + .replace("wss://", "https://") + ) + self._async_proxy_client = httpx.AsyncClient( + base_url=url, + headers={"X-API-Key": self._api_key}, # type: ignore[dict-item, arg-type] + ) + + deadline = time.monotonic() + jupyter_timeout + last_exc: Exception | None = None + while time.monotonic() < deadline: + try: + resp = await self._async_proxy_client.post("/api/kernels") + if resp.status_code < 500: + resp.raise_for_status() + data = resp.json() + self._kernel_id = data["id"] + return str(self._kernel_id) + last_exc = httpx.HTTPStatusError( + f"Jupyter returned {resp.status_code}", + request=resp.request, + response=resp, + ) + except httpx.HTTPStatusError: + raise + except Exception as exc: + last_exc = exc + await asyncio.sleep(0.5) + raise TimeoutError( + f"Jupyter not available within {jupyter_timeout}s: {last_exc}" + ) + + def _jupyter_ws_url(self, kernel_id: str) -> str: + proxy = _build_proxy_url(self._base_url, self.id, 8888) + return f"{proxy}/api/kernels/{kernel_id}/channels" + + def _jupyter_execute_request(self, code: str) -> dict: + msg_id = str(uuid.uuid4()) + return { + "header": { + "msg_id": msg_id, + "msg_type": "execute_request", + "username": "wrenn-sdk", + "session": str(uuid.uuid4()), + "date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), + "version": "5.3", + }, + "parent_header": {}, + "metadata": {}, + "content": { + "code": code, + "silent": False, + "store_history": True, + "user_expressions": {}, + "allow_stdin": False, + "stop_on_error": True, + }, + "buffers": [], + "channel": "shell", + "msg_id": msg_id, + "msg_type": "execute_request", + } + + def run_code( + self, + code: str, + language: str = "python", + timeout: float = 30, + jupyter_timeout: float = 30, + ) -> CodeResult: + """Execute code in a persistent kernel inside the sandbox. + + Variables, imports, and function definitions survive across calls. + + Args: + code: Code string to execute. + language: Execution backend language. Currently only ``"python"``. + timeout: Maximum seconds to wait for execution to complete. + jupyter_timeout: Maximum seconds to wait for Jupyter to become available. + + Returns: + A ``CodeResult`` with ``.text``, ``.data``, ``.stdout``, ``.stderr``, ``.error``. + + Raises: + WrennAuthenticationError: If the client was constructed with JWT only. + """ + assert self._http is not None + kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout) + ws_url = self._jupyter_ws_url(kernel_id) + api_key = self._require_api_key() + + msg = self._jupyter_execute_request(code) + msg_id = msg["msg_id"] + + result = CodeResult() + deadline = time.monotonic() + timeout + + headers = {"X-API-Key": api_key} + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + + with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: ignore[attr-defined, var-annotated] + ws.send_text(json.dumps(msg)) + while time.monotonic() < deadline: + time_left = deadline - time.monotonic() + if time_left <= 0: + break + try: + data = ws.receive_json(timeout=time_left) + except (TimeoutError, Exception): + break + if not data: + break + parent = data.get("parent_header", {}).get("msg_id") + if parent != msg_id: + continue + msg_type = data.get("msg_type") or data.get("header", {}).get( + "msg_type" + ) + content = data.get("content", {}) + + if msg_type == "stream": + name = content.get("name", "stdout") + if name == "stderr": + result.stderr += content.get("text", "") + else: + result.stdout += content.get("text", "") + elif msg_type == "execute_result": + bundle = content.get("data", {}) + result.text = bundle.get("text/plain") + result.data = bundle + elif msg_type == "error": + traceback = content.get("traceback", []) + result.error = "\n".join(traceback) + elif msg_type == "status" and content.get("execution_state") == "idle": + break + + return result + + async def async_run_code( + self, + code: str, + language: str = "python", + timeout: float = 30, + jupyter_timeout: float = 30, + ) -> CodeResult: + """Async version of ``run_code``.""" + assert self._async_http is not None + kernel_id = await self._async_ensure_kernel(jupyter_timeout=jupyter_timeout) + ws_url = self._jupyter_ws_url(kernel_id) + api_key = self._require_api_key() + + msg = self._jupyter_execute_request(code) + msg_id = msg["msg_id"] + + result = CodeResult() + deadline = time.monotonic() + timeout + + headers = {"X-API-Key": api_key} + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + + async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: ignore[attr-defined, var-annotated] + await ws.send_text(json.dumps(msg)) + while time.monotonic() < deadline: + time_left = deadline - time.monotonic() + if time_left <= 0: + break + + try: + data = await asyncio.wait_for(ws.receive_json(), timeout=time_left) # type: ignore[misc] + except (asyncio.TimeoutError, Exception): + break + + if not data: + break + + parent = data.get("parent_header", {}).get("msg_id") + if parent != msg_id: + continue + msg_type = data.get("msg_type") or data.get("header", {}).get( + "msg_type" + ) + content = data.get("content", {}) + + if msg_type == "stream": + name = content.get("name", "stdout") + if name == "stderr": + result.stderr += content.get("text", "") + else: + result.stdout += content.get("text", "") + elif msg_type == "execute_result": + bundle = content.get("data", {}) + result.text = bundle.get("text/plain") + result.data = bundle + elif msg_type == "error": + traceback = content.get("traceback", []) + result.error = "\n".join(traceback) + elif msg_type == "status" and content.get("execution_state") == "idle": + break + + return result + + def _cleanup(self) -> None: + if self._proxy_client is not None: + try: + self._proxy_client.close() + except Exception: + pass + self._proxy_client = None + + async def _async_cleanup(self) -> None: + if self._async_proxy_client is not None: + try: + await self._async_proxy_client.aclose() + except Exception: + pass + self._async_proxy_client = None + + def __enter__(self) -> Sandbox: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object, + ) -> None: + try: + self.destroy() + except Exception: + pass + self._cleanup() + + async def __aenter__(self) -> Sandbox: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object, + ) -> None: + try: + await self.async_destroy() + except Exception: + pass + await self._async_cleanup() diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..b9adb02 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,417 @@ +from __future__ import annotations + +import pytest +import respx + +from wrenn.client import AsyncWrennClient, WrennClient +from wrenn.exceptions import ( + WrennAgentError, + WrennAuthenticationError, + WrennConflictError, + WrennForbiddenError, + WrennHostHasSandboxesError, + WrennInternalError, + WrennNotFoundError, + WrennValidationError, +) +from wrenn.models import ( + APIKeyResponse, + AuthResponse, + CreateHostResponse, + Host, + Sandbox, + Status, + Template, +) + + +@pytest.fixture +def client(): + with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: + yield c + + +@pytest.fixture +def async_client(): + return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678") + + +class TestAuth: + @respx.mock + def test_signup(self, client): + respx.post("https://api.wrenn.dev/v1/auth/signup").respond( + 201, + json={ + "token": "jwt-token", + "user_id": "u-1", + "team_id": "t-1", + "email": "a@b.com", + }, + ) + resp = client.auth.signup("a@b.com", "password123") + assert isinstance(resp, AuthResponse) + assert resp.token == "jwt-token" + assert resp.user_id == "u-1" + + @respx.mock + def test_login(self, client): + respx.post("https://api.wrenn.dev/v1/auth/login").respond( + 200, + json={"token": "jwt-token", "email": "a@b.com"}, + ) + resp = client.auth.login("a@b.com", "password123") + assert resp.token == "jwt-token" + + +class TestAPIKeys: + @respx.mock + def test_create(self, client): + respx.post("https://api.wrenn.dev/v1/api-keys").respond( + 201, + json={ + "id": "key-1", + "name": "my-key", + "key_prefix": "wrn_ab12cd34", + "key": "wrn_ab12cd34fullkey", + }, + ) + resp = client.api_keys.create(name="my-key") + assert isinstance(resp, APIKeyResponse) + assert resp.name == "my-key" + assert resp.key == "wrn_ab12cd34fullkey" + + @respx.mock + def test_list(self, client): + respx.get("https://api.wrenn.dev/v1/api-keys").respond( + 200, + json=[{"id": "key-1", "name": "k1"}, {"id": "key-2", "name": "k2"}], + ) + keys = client.api_keys.list() + assert len(keys) == 2 + assert keys[0].id == "key-1" + + @respx.mock + def test_delete(self, client): + route = respx.delete("https://api.wrenn.dev/v1/api-keys/key-1").respond(204) + client.api_keys.delete("key-1") + assert route.called + + +class TestSandboxes: + @respx.mock + def test_create(self, client): + respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + 201, + json={ + "id": "sb-1", + "status": "pending", + "template": "base-python", + "vcpus": 2, + "memory_mb": 1024, + }, + ) + resp = client.sandboxes.create(template="base-python", vcpus=2, memory_mb=1024) + assert isinstance(resp, Sandbox) + assert resp.id == "sb-1" + assert resp.status == Status.pending + + @respx.mock + def test_create_defaults(self, client): + respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + 201, json={"id": "sb-2", "status": "pending"} + ) + resp = client.sandboxes.create() + assert resp.id == "sb-2" + + @respx.mock + def test_list(self, client): + respx.get("https://api.wrenn.dev/v1/sandboxes").respond( + 200, json=[{"id": "sb-1", "status": "running"}] + ) + boxes = client.sandboxes.list() + assert len(boxes) == 1 + assert boxes[0].status == Status.running + + @respx.mock + def test_get(self, client): + respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond( + 200, json={"id": "sb-1", "status": "running"} + ) + resp = client.sandboxes.get("sb-1") + assert resp.id == "sb-1" + + @respx.mock + def test_destroy(self, client): + route = respx.delete("https://api.wrenn.dev/v1/sandboxes/sb-1").respond(204) + client.sandboxes.destroy("sb-1") + assert route.called + + +class TestSnapshots: + @respx.mock + def test_create(self, client): + respx.post("https://api.wrenn.dev/v1/snapshots").respond( + 201, + json={"name": "snap-1", "type": "snapshot", "vcpus": 1}, + ) + resp = client.snapshots.create(sandbox_id="sb-1", name="snap-1") + assert isinstance(resp, Template) + assert resp.name == "snap-1" + + @respx.mock + def test_create_with_overwrite(self, client): + route = respx.post("https://api.wrenn.dev/v1/snapshots").respond( + 201, json={"name": "snap-1", "type": "snapshot"} + ) + client.snapshots.create(sandbox_id="sb-1", overwrite=True) + req = route.calls[0].request + assert "overwrite=true" in str(req.url) + + @respx.mock + def test_list(self, client): + respx.get("https://api.wrenn.dev/v1/snapshots").respond( + 200, json=[{"name": "base-python", "type": "base"}] + ) + snaps = client.snapshots.list() + assert len(snaps) == 1 + + @respx.mock + def test_list_with_filter(self, client): + route = respx.get("https://api.wrenn.dev/v1/snapshots").respond(200, json=[]) + client.snapshots.list(type="snapshot") + req = route.calls[0].request + assert "type=snapshot" in str(req.url) + + @respx.mock + def test_delete(self, client): + route = respx.delete("https://api.wrenn.dev/v1/snapshots/snap-1").respond(204) + client.snapshots.delete("snap-1") + assert route.called + + +class TestHosts: + @respx.mock + def test_create(self, client): + respx.post("https://api.wrenn.dev/v1/hosts").respond( + 201, + json={ + "host": {"id": "h-1", "type": "regular", "status": "pending"}, + "registration_token": "reg-tok-123", + }, + ) + resp = client.hosts.create(type="regular") + assert isinstance(resp, CreateHostResponse) + assert resp.registration_token == "reg-tok-123" + + @respx.mock + def test_list(self, client): + respx.get("https://api.wrenn.dev/v1/hosts").respond( + 200, json=[{"id": "h-1", "status": "online"}] + ) + hosts = client.hosts.list() + assert len(hosts) == 1 + assert isinstance(hosts[0], Host) + + @respx.mock + def test_get(self, client): + respx.get("https://api.wrenn.dev/v1/hosts/h-1").respond( + 200, json={"id": "h-1", "status": "online"} + ) + resp = client.hosts.get("h-1") + assert resp.id == "h-1" + + @respx.mock + def test_delete(self, client): + route = respx.delete("https://api.wrenn.dev/v1/hosts/h-1").respond(204) + client.hosts.delete("h-1") + assert route.called + + @respx.mock + def test_regenerate_token(self, client): + respx.post("https://api.wrenn.dev/v1/hosts/h-1/token").respond( + 201, + json={ + "host": {"id": "h-1"}, + "registration_token": "new-tok", + }, + ) + resp = client.hosts.regenerate_token("h-1") + assert resp.registration_token == "new-tok" + + @respx.mock + def test_list_tags(self, client): + respx.get("https://api.wrenn.dev/v1/hosts/h-1/tags").respond( + 200, json=["gpu", "high-mem"] + ) + tags = client.hosts.list_tags("h-1") + assert tags == ["gpu", "high-mem"] + + @respx.mock + def test_add_tag(self, client): + route = respx.post("https://api.wrenn.dev/v1/hosts/h-1/tags").respond(204) + client.hosts.add_tag("h-1", "gpu") + assert route.called + + @respx.mock + def test_remove_tag(self, client): + route = respx.delete("https://api.wrenn.dev/v1/hosts/h-1/tags/gpu").respond(204) + client.hosts.remove_tag("h-1", "gpu") + assert route.called + + +class TestErrorHandling: + @respx.mock + def test_validation_error(self, client): + respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + 400, + json={"error": {"code": "invalid_request", "message": "bad input"}}, + ) + with pytest.raises(WrennValidationError) as exc_info: + client.sandboxes.create() + assert exc_info.value.code == "invalid_request" + assert exc_info.value.status_code == 400 + + @respx.mock + def test_auth_error(self, client): + respx.get("https://api.wrenn.dev/v1/sandboxes").respond( + 401, + json={"error": {"code": "unauthorized", "message": "bad key"}}, + ) + with pytest.raises(WrennAuthenticationError): + client.sandboxes.list() + + @respx.mock + def test_forbidden_error(self, client): + respx.post("https://api.wrenn.dev/v1/hosts").respond( + 403, + json={"error": {"code": "forbidden", "message": "nope"}}, + ) + with pytest.raises(WrennForbiddenError): + client.hosts.create(type="regular") + + @respx.mock + def test_not_found_error(self, client): + respx.get("https://api.wrenn.dev/v1/sandboxes/nope").respond( + 404, + json={"error": {"code": "not_found", "message": "sandbox not found"}}, + ) + with pytest.raises(WrennNotFoundError): + client.sandboxes.get("nope") + + @respx.mock + def test_conflict_error(self, client): + respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond( + 409, + json={"error": {"code": "invalid_state", "message": "not running"}}, + ) + with pytest.raises(WrennConflictError): + client.sandboxes.get("sb-1") + + @respx.mock + def test_host_has_sandboxes_error(self, client): + respx.delete("https://api.wrenn.dev/v1/hosts/h-1").respond( + 409, + json={ + "error": { + "code": "host_has_sandboxes", + "message": "host has running sandboxes", + }, + "sandbox_ids": ["sb-1", "sb-2"], + }, + ) + with pytest.raises(WrennHostHasSandboxesError) as exc_info: + client.hosts.delete("h-1") + assert exc_info.value.sandbox_ids == ["sb-1", "sb-2"] + + @respx.mock + def test_agent_error(self, client): + respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + 502, + json={"error": {"code": "agent_error", "message": "host agent failed"}}, + ) + with pytest.raises(WrennAgentError): + client.sandboxes.create() + + @respx.mock + def test_internal_error(self, client): + respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond( + 500, + json={"error": {"code": "internal_error", "message": "oops"}}, + ) + with pytest.raises(WrennInternalError): + client.sandboxes.get("sb-1") + + @respx.mock + def test_unknown_error_code_falls_back(self, client): + respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond( + 418, + json={"error": {"code": "teapot", "message": "I'm a teapot"}}, + ) + from wrenn.exceptions import WrennError + + with pytest.raises(WrennError) as exc_info: + client.sandboxes.get("sb-1") + assert exc_info.value.code == "teapot" + + +class TestAuthModes: + def test_api_key_header(self): + with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: + assert c._http.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678" + + def test_token_header(self): + with WrennClient(token="jwt-token-abc") as c: + assert c._http.headers["Authorization"] == "Bearer jwt-token-abc" + + def test_no_auth_raises(self): + with pytest.raises(ValueError, match="Either api_key or token"): + WrennClient() + + @respx.mock + def test_jwt_auth_on_api_keys(self): + route = respx.get("https://api.wrenn.dev/v1/api-keys").respond(200, json=[]) + with WrennClient(token="jwt-abc") as c: + c.api_keys.list() + req = route.calls[0].request + assert req.headers["Authorization"] == "Bearer jwt-abc" + + +class TestAsyncClient: + @pytest.mark.asyncio + @respx.mock + async def test_async_sandboxes_create(self, async_client): + async with async_client: + respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + 201, json={"id": "sb-1", "status": "pending"} + ) + resp = await async_client.sandboxes.create(template="base-python") + assert resp.id == "sb-1" + + @pytest.mark.asyncio + @respx.mock + async def test_async_sandboxes_list(self, async_client): + async with async_client: + respx.get("https://api.wrenn.dev/v1/sandboxes").respond( + 200, json=[{"id": "sb-1"}] + ) + boxes = await async_client.sandboxes.list() + assert len(boxes) == 1 + + @pytest.mark.asyncio + @respx.mock + async def test_async_hosts_list(self, async_client): + async with async_client: + respx.get("https://api.wrenn.dev/v1/hosts").respond(200, json=[]) + hosts = await async_client.hosts.list() + assert hosts == [] + + @pytest.mark.asyncio + @respx.mock + async def test_async_error_handling(self, async_client): + async with async_client: + respx.get("https://api.wrenn.dev/v1/sandboxes/nope").respond( + 404, + json={"error": {"code": "not_found", "message": "not found"}}, + ) + with pytest.raises(WrennNotFoundError): + await async_client.sandboxes.get("nope") diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..e4f51ea --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +import os +from typing import Generator + +import pytest + +from wrenn.client import AsyncWrennClient, WrennClient +from wrenn.exceptions import WrennNotFoundError, WrennValidationError + +WRENN_API_KEY = os.environ.get("WRENN_API_KEY") +WRENN_TOKEN = os.environ.get("WRENN_TOKEN") +WRENN_BASE_URL = os.environ.get("WRENN_BASE_URL", "http://localhost:8080") +WRENN_TEST_EMAIL = os.environ.get("WRENN_TEST_EMAIL") +WRENN_TEST_PASSWORD = os.environ.get("WRENN_TEST_PASSWORD") + + +def _has_auth() -> bool: + return bool(WRENN_API_KEY or WRENN_TOKEN) + + +requires_auth = pytest.mark.skipif( + not _has_auth(), + reason="Set WRENN_API_KEY or WRENN_TOKEN to run integration tests", +) + + +@pytest.fixture +def client() -> Generator[WrennClient, None, None]: + with WrennClient( + api_key=WRENN_API_KEY, + token=WRENN_TOKEN, + base_url=WRENN_BASE_URL, + ) as c: + yield c + + +@pytest.fixture +def async_client() -> AsyncWrennClient: + return AsyncWrennClient( + api_key=WRENN_API_KEY, + token=WRENN_TOKEN, + base_url=WRENN_BASE_URL, + ) + + +@pytest.fixture +def bearer_client() -> Generator[WrennClient, None, None]: + if WRENN_TOKEN: + with WrennClient(token=WRENN_TOKEN, base_url=WRENN_BASE_URL) as c: + yield c + elif WRENN_TEST_EMAIL and WRENN_TEST_PASSWORD: + with WrennClient( + api_key=WRENN_API_KEY, token=WRENN_TOKEN, base_url=WRENN_BASE_URL + ) as c: + resp = c.auth.login(WRENN_TEST_EMAIL, WRENN_TEST_PASSWORD) + with WrennClient(token=resp.token, base_url=WRENN_BASE_URL) as c: + yield c + else: + pytest.skip( + "Set WRENN_TOKEN or WRENN_TEST_EMAIL+WRENN_TEST_PASSWORD for bearer-auth tests" + ) + + +@requires_auth +class TestSandboxLifecycle: + def test_create_exec_destroy(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + result = sb.exec("echo", args=["hello"]) + assert result.exit_code == 0 + assert "hello" in result.stdout + + def test_exec_with_args(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + result = sb.exec("echo", args=["hello", "world"]) + assert result.exit_code == 0 + assert "hello world" in result.stdout + + def test_exec_nonzero_exit(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + result = sb.exec("sh", args=["-c", "exit 42"]) + assert result.exit_code == 42 + + def test_exec_stderr(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + result = sb.exec("sh", args=["-c", "echo err>&2"]) + assert result.exit_code == 0 + assert "err" in result.stderr + + def test_context_manager_cleanup(self, client): + sb = client.sandboxes.create(template="minimal", timeout_sec=120) + sb_id = sb.id + + with sb: + sb.wait_ready(timeout=60, interval=1) + + fetched = client.sandboxes.get(sb_id) + assert fetched.status in ("stopped", "destroyed") + + +@requires_auth +class TestFileIO: + def test_upload_and_download(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + content = b"Hello from integration test!" + sb.upload("/tmp/test_file.txt", content) + downloaded = sb.download("/tmp/test_file.txt") + assert downloaded == content + + def test_download_nonexistent_file(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + with pytest.raises(Exception): + sb.download("/tmp/no_such_file_12345") + + +@requires_auth +class TestPauseResume: + def test_pause_and_resume(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + sb.pause() + assert sb.status == "paused" + + sb.resume() + sb.wait_ready(timeout=60, interval=1) + + result = sb.exec("echo", args=["resumed"]) + assert result.exit_code == 0 + assert "resumed" in result.stdout + + +@requires_auth +class TestPing: + def test_ping_resets_timer(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + sb.ping() + result = sb.exec("echo", args=["still_alive"]) + assert result.exit_code == 0 + assert "still_alive" in result.stdout + + +@requires_auth +class TestProxy: + def test_get_url(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + url = sb.get_url(8888) + assert sb.id in url + assert "8888" in url + + +@requires_auth +class TestListAndGet: + def test_list_sandboxes(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + boxes = client.sandboxes.list() + ids = [b.id for b in boxes] + assert sb.id in ids + + def test_get_existing_sandbox(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + fetched = client.sandboxes.get(sb.id) + assert fetched.id == sb.id + assert fetched.status == "running" + + def test_get_nonexistent_sandbox(self, client): + with pytest.raises((WrennNotFoundError, WrennValidationError)): + client.sandboxes.get("cl-nonexistent00000000000000000") + + +@requires_auth +class TestSnapshots: + def test_list_templates(self, client): + templates = client.snapshots.list() + assert isinstance(templates, list) + + +@requires_auth +class TestAPIKeys: + def test_create_list_delete(self, bearer_client): + key_resp = bearer_client.api_keys.create(name="integration-test-key") + assert key_resp.name == "integration-test-key" + assert key_resp.key is not None + assert key_resp.id is not None + + try: + keys = bearer_client.api_keys.list() + ids = [k.id for k in keys] + assert key_resp.id in ids + finally: + bearer_client.api_keys.delete(key_resp.id) + + +@requires_auth +class TestRunCode: + def test_basic_execution(self, client): + with client.sandboxes.create( + template="python-interpreter-v0-beta", timeout_sec=120 + ) as sb: + sb.wait_ready(timeout=60, interval=1) + + r = sb.run_code("x = 42") + assert r.error is None + + r = sb.run_code("x * 2") + assert r.text == "84" + + def test_state_persists(self, client): + with client.sandboxes.create( + template="python-interpreter-v0-beta", timeout_sec=120 + ) as sb: + sb.wait_ready(timeout=60, interval=1) + + sb.run_code("def greet(name): return f'hello {name}'") + r = sb.run_code("greet('sandbox')") + assert "hello sandbox" in (r.text or "") + + def test_error_traceback(self, client): + with client.sandboxes.create( + template="python-interpreter-v0-beta", timeout_sec=120 + ) as sb: + sb.wait_ready(timeout=60, interval=1) + + r = sb.run_code("1/0") + assert r.error is not None + assert "ZeroDivisionError" in r.error + + def test_stdout_capture(self, client): + with client.sandboxes.create( + template="python-interpreter-v0-beta", timeout_sec=120 + ) as sb: + sb.wait_ready(timeout=60, interval=1) + + r = sb.run_code("print('hello from kernel')") + assert "hello from kernel" in r.stdout + + +@requires_auth +class TestAsyncSandboxLifecycle: + @pytest.mark.asyncio + async def test_async_create_exec_destroy(self, async_client): + async with async_client: + sb = await async_client.sandboxes.create( + template="minimal", timeout_sec=120 + ) + try: + await sb.async_wait_ready(timeout=60, interval=1) + result = await sb.async_exec("echo", args=["async_hello"]) + assert result.exit_code == 0 + assert "async_hello" in result.stdout + finally: + await sb.async_destroy() + + @pytest.mark.asyncio + async def test_async_upload_download(self, async_client): + async with async_client: + sb = await async_client.sandboxes.create( + template="minimal", timeout_sec=120 + ) + try: + await sb.async_wait_ready(timeout=60, interval=1) + content = b"Async upload test" + await sb.async_upload("/tmp/async_test.txt", content) + downloaded = await sb.async_download("/tmp/async_test.txt") + assert downloaded == content + finally: + await sb.async_destroy() + + @pytest.mark.asyncio + async def test_async_run_code(self, async_client): + async with async_client: + sb = await async_client.sandboxes.create( + template="python-interpreter-v0-beta", timeout_sec=120 + ) + try: + await sb.async_wait_ready(timeout=60, interval=1) + r = await sb.async_run_code("42 * 2") + assert r.text == "84" + finally: + await sb.async_destroy() diff --git a/tests/test_sandbox_features.py b/tests/test_sandbox_features.py new file mode 100644 index 0000000..d5538ef --- /dev/null +++ b/tests/test_sandbox_features.py @@ -0,0 +1,175 @@ +from __future__ import annotations + + +import pytest +import respx + +from wrenn.client import WrennClient +from wrenn.exceptions import WrennAuthenticationError +from wrenn.sandbox import CodeResult, Sandbox, _build_proxy_url + + +@pytest.fixture +def client(): + with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: + yield c + + +class TestBuildProxyUrl: + def test_https_production(self): + url = _build_proxy_url("https://api.wrenn.dev", "cl-abc123", 8888) + assert url == "wss://8888-cl-abc123.api.wrenn.dev" + + def test_http_localhost(self): + url = _build_proxy_url("http://localhost:8080", "cl-abc123", 3000) + assert url == "ws://3000-cl-abc123.localhost:8080" + + def test_https_custom_port(self): + url = _build_proxy_url("https://api.example.com:9443", "sb-1", 8080) + assert url == "wss://8080-sb-1.api.example.com:9443" + + def test_http_no_port(self): + url = _build_proxy_url("http://192.168.1.1", "sb-2", 5000) + assert url == "ws://5000-sb-2.192.168.1.1" + + +class TestSandboxGetUrl: + @respx.mock + def test_get_url_returns_proxy_url(self, client): + respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + 201, json={"id": "cl-abc", "status": "pending"} + ) + sb = client.sandboxes.create(template="minimal") + url = sb.get_url(8888) + assert url == "wss://8888-cl-abc.api.wrenn.dev" + + @respx.mock + def test_get_url_localhost(self): + with WrennClient( + api_key="wrn_test1234567890abcdef12345678", + base_url="http://localhost:8080", + ) as c: + respx.post("http://localhost:8080/v1/sandboxes").respond( + 201, json={"id": "cl-xyz", "status": "pending"} + ) + sb = c.sandboxes.create() + url = sb.get_url(3000) + assert url == "ws://3000-cl-xyz.localhost:8080" + + +class TestProxyAuthGuard: + def test_jwt_only_get_url_raises(self): + with WrennClient(token="jwt-abc") as c: + sb = Sandbox(id="cl-abc") + sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") + with pytest.raises(WrennAuthenticationError): + sb.get_url(8888) + + def test_jwt_only_http_client_raises(self): + with WrennClient(token="jwt-abc") as c: + sb = Sandbox(id="cl-abc") + sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") + with pytest.raises(WrennAuthenticationError): + _ = sb.http_client + + +class TestSandboxHttpClient: + @respx.mock + def test_http_client_has_api_key_header(self, client): + respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + 201, json={"id": "cl-abc", "status": "pending"} + ) + sb = client.sandboxes.create() + hc = sb.http_client + assert hc.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678" + + @respx.mock + def test_http_client_sends_to_proxy(self, client): + route = respx.get("https://8888-cl-abc.api.wrenn.dev/api/kernels").respond( + 200, json=[] + ) + respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + 201, json={"id": "cl-abc", "status": "pending"} + ) + sb = client.sandboxes.create() + resp = sb.http_client.get("/api/kernels") + assert resp.status_code == 200 + assert route.called + + +class TestCreateReturnsBoundSandbox: + @respx.mock + def test_create_returns_sandbox_subclass(self, client): + respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + 201, json={"id": "cl-1", "status": "pending", "template": "minimal"} + ) + sb = client.sandboxes.create(template="minimal") + assert isinstance(sb, Sandbox) + assert sb.id == "cl-1" + assert hasattr(sb, "exec") + assert hasattr(sb, "run_code") + assert hasattr(sb, "get_url") + + @respx.mock + def test_create_context_manager(self, client): + route = respx.delete("https://api.wrenn.dev/v1/sandboxes/cl-1").respond(204) + respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + 201, json={"id": "cl-1", "status": "pending"} + ) + sb = client.sandboxes.create() + with sb: + assert sb.id == "cl-1" + assert route.called + + +class TestCodeResult: + def test_defaults(self): + r = CodeResult() + assert r.text is None + assert r.data is None + assert r.stdout == "" + assert r.stderr == "" + assert r.error is None + + def test_with_values(self): + r = CodeResult( + text="84", + data={"text/plain": "84"}, + stdout="", + stderr="", + error=None, + ) + assert r.text == "84" + assert r.data["text/plain"] == "84" + + def test_error_result(self): + r = CodeResult(error="ZeroDivisionError: division by zero\n...") + assert r.error is not None + assert "ZeroDivisionError" in r.error + + +class TestRunCodeAuthGuard: + def test_jwt_only_run_code_raises(self): + with WrennClient(token="jwt-abc") as c: + sb = Sandbox(id="cl-abc") + sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") + with pytest.raises(WrennAuthenticationError): + sb.run_code("print(1)") + + +class TestJupyterMessageFormat: + def test_execute_request_structure(self): + sb = Sandbox(id="test") + msg = sb._jupyter_execute_request("x = 42") + assert msg["msg_type"] == "execute_request" + assert msg["content"]["code"] == "x = 42" + assert msg["content"]["silent"] is False + assert "msg_id" in msg + assert "header" in msg + assert msg["header"]["msg_type"] == "execute_request" + + def test_execute_request_unique_ids(self): + sb = Sandbox(id="test") + m1 = sb._jupyter_execute_request("a") + m2 = sb._jupyter_execute_request("b") + assert m1["msg_id"] != m2["msg_id"] diff --git a/uv.lock b/uv.lock index 852c192..22123d3 100644 --- a/uv.lock +++ b/uv.lock @@ -112,6 +112,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/3a/7f169ffc7a2d69a4f9158b1ac083f685b7f4a1a8a1db5d1e4abbb4e741b7/datamodel_code_generator-0.56.0-py3-none-any.whl", hash = "sha256:a0559683fbe90cdf2ce9b6637e3adae3e3a8056a8d0516df581d486e2834ead2", size = 256545, upload-time = "2026-04-04T09:46:17.582Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "genson" version = "1.3.0" @@ -158,6 +180,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-ws" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/cd/ca91a07ae446451f7476bf3fcc909e98cb942ff032ebfda0e3fe449aca7b/httpx_ws-0.9.0.tar.gz", hash = "sha256:797373326f70eec1ae96f6e43ae9f12002fd7d73aee139a4985eaab964338a08", size = 107105, upload-time = "2026-03-28T14:11:10.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/f8/a6bc80313a9e93c888fa10534dfce2ad76ff86911b6f485777ce6de6a073/httpx_ws-0.9.0-py3-none-any.whl", hash = "sha256:71640d2fb1bf9a225775015b33cd755cfd4c5f7e21c885192fe3adc4c387b248", size = 15759, upload-time = "2026-03-28T14:11:11.887Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -564,6 +601,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "respx" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, +] + [[package]] name = "ruff" version = "0.15.10" @@ -627,7 +676,9 @@ name = "wrenn" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "email-validator" }, { name = "httpx" }, + { name = "httpx-ws" }, { name = "pydantic" }, ] @@ -637,12 +688,15 @@ dev = [ { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "respx" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ + { name = "email-validator", specifier = ">=2.3.0" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "httpx-ws", specifier = ">=0.9.0" }, { name = "pydantic", specifier = ">=2.12.5" }, ] @@ -652,5 +706,18 @@ dev = [ { name = "mypy", specifier = ">=1.20.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "respx", specifier = ">=0.23.1" }, { name = "ruff", specifier = ">=0.15.10" }, ] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] -- 2.49.0 From a5bf66c199287fc404878275c06574fc0c8b59ee Mon Sep 17 00:00:00 2001 From: Tasnim Kabir Sadik Date: Sun, 12 Apr 2026 02:35:20 +0600 Subject: [PATCH 02/44] 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. --- AGENTS.md | 272 ++----- Makefile | 2 +- api/openapi.yaml | 1285 +++++++++++++++++++++++++++++++- src/wrenn/__init__.py | 7 + src/wrenn/client.py | 146 ++-- src/wrenn/exceptions.py | 50 ++ src/wrenn/models/__init__.py | 12 + src/wrenn/models/_generated.py | 307 +++++++- src/wrenn/pty.py | 306 ++++++++ src/wrenn/sandbox.py | 413 ++++++++-- tests/test_filesystem_pty.py | 506 +++++++++++++ tests/test_integration.py | 279 +++++++ tests/test_sandbox_features.py | 40 +- 13 files changed, 3180 insertions(+), 445 deletions(-) create mode 100644 src/wrenn/pty.py create mode 100644 tests/test_filesystem_pty.py diff --git a/AGENTS.md b/AGENTS.md index 405766b..030df8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,252 +1,80 @@ # AGENTS.md -This file provides strict guidance to AI coding agents and assistants when modifying code in the `wrenn-python-sdk` repository. Read this entirely before writing or refactoring any code. +## What this repo is -## Project Overview +Python SDK for **Wrenn** (microVM code execution platform). Communicates with the Control Plane via REST + WebSockets only — no gRPC. The `envd` and `HostAgentService` are internal to the Go backend and never reachable from this SDK. -This is the official Python SDK for **Wrenn**, a microVM-based code execution platform. The SDK provides developers and AI agents with a clean, typed interface to interact with the Wrenn Control Plane over REST and WebSockets. +## Build & dev commands -**Important:** The SDK communicates exclusively with the Control Plane over HTTP/HTTPS and WebSockets. It does **not** generate or use gRPC stubs. The `envd` guest agent and `HostAgentService` are internal RPCs between the control plane and host agents — they are never reachable from the SDK. All data-plane operations (exec, file I/O) are proxied through the control plane's REST/WS endpoints. - -## Repository Architecture & Structure - -This is a modern Python package managed entirely by `uv`. It uses a flattened `src/` layout. - -```text -. -├── LICENSE -├── Makefile # Central command runner -├── pyproject.toml # uv dependency and build config -├── uv.lock # Exact dependency resolution -├── internal/ -│ └── api/ -│ └── openapi.yaml # Cached OpenAPI spec from the Go backend -├── src/ -│ └── wrenn/ # The actual importable Python package -│ ├── __init__.py # Version + top-level re-exports -│ ├── client.py # WrennClient & AsyncWrennClient (httpx transport) -│ ├── sandbox.py # Sandbox class (exec, files, context manager) -│ ├── exceptions.py # Typed exception hierarchy -│ ├── py.typed # PEP 561 marker -│ └── models/ -│ ├── __init__.py # Public re-exports via __all__ -│ └── _generated.py # DO NOT EDIT — generated by datamodel-codegen -└── tests/ # Pytest suite -``` - -## Build & Development Commands - -Never use raw `pip`, `venv`, or `python -m venv`. **All dependency management and script execution goes through `uv` and the `Makefile`.** +All commands go through `uv` and the `Makefile`. Never use raw `pip`, `venv`, or `python -m venv`. ```bash -make generate # Fetches openapi.yaml and runs datamodel-codegen → models/_generated.py -make lint # Runs ruff check and ruff format -make test # Runs pytest -make check # Runs lint + test +make generate # Fetch openapi.yaml → src/wrenn/models/_generated.py +make lint # ruff check + ruff format --check on src/ +make test # runs ONLY tests/test_client.py +make test-integration # runs ALL tests (unit + integration, needs live server) +make check # lint + test (test_client.py only) ``` -There is no `make proto`. The SDK does not generate gRPC stubs — the `envd` and `HostAgentService` protos are internal to the Go backend. +To run all unit tests (not just test_client.py): -## Dependency Management (`uv`) - -- **Adding a runtime dependency:** `uv add ` (e.g., `uv add httpx pydantic`) -- **Adding a dev dependency:** `uv add --dev ` (e.g., `uv add --dev pytest ruff`) -- **Running isolated scripts:** Use `uv run `. `uv` implicitly manages the `.venv`; do not try to manually activate it in automation scripts. - -## Code Generation Invariants (CRITICAL) - -The data models for this SDK are generated directly from the Go backend's OpenAPI contract (`internal/api/openapi.yaml`). - -1. **Never manually edit `src/wrenn/models/_generated.py`.** Any custom logic placed here will be destroyed on the next `make generate`. -2. If the Go API contract changes, run `make generate`. -3. **Export routing:** The `_generated.py` file is large. Users must never import from it directly. All user-facing models must be explicitly re-exported in `src/wrenn/models/__init__.py` using the `__all__` dunder list. -4. **Extending models:** If a generated Pydantic model needs custom Python methods, subclass it in a new file (e.g., `src/wrenn/sandbox.py` extends the generated `Sandbox` model) and export the subclass. - -## Authentication - -The SDK supports two authentication mechanisms, set via the `WrennClient` constructor: - -1. **API Key (primary):** Pass `api_key="wrn_..."` to the constructor. Sent as `X-API-Key` header. Format: `wrn_` + 32 hex chars. Used for programmatic/agent access. -2. **JWT (secondary):** Pass `token=""` to the constructor. Sent as `Authorization: Bearer ` header. Used for user-facing tooling. Tokens expire after 6 hours. - -Host tokens (`X-Host-Token`) are for the host agent binary only and are **not** exposed in the SDK. - -```python -client = WrennClient(api_key="wrn_ab12cd34...") # typical usage -client = WrennClient(token="eyJhbGci...") # alternative +```bash +uv run pytest tests/test_client.py tests/test_sandbox_features.py tests/test_filesystem_pty.py -v ``` -## Core SDK Design Patterns +To run a single test: -### 1. Sync and Async Parity - -The SDK must natively support both synchronous and asynchronous workflows. -- Core logic lives in `WrennClient` and `AsyncWrennClient` inside `client.py`. -- Under the hood, rely on `httpx.Client` and `httpx.AsyncClient`. -- Resource namespaces are injected via constructor. - -### 2. Resource Namespaces - -The client exposes resources as plural namespaces matching the API path convention: - -```python -client = WrennClient(api_key="wrn_...") -client.sandboxes.create(template="base-python") -client.sandboxes.list() -client.snapshots.create(sandbox_id="cl-...") -client.api_keys.create(name="my-key") -client.hosts.list() -client.teams.list() -client.audit.list(limit=50) -client.builds.list() # admin-only +```bash +uv run pytest tests/test_client.py::TestAuth::test_signup -v ``` -### 3. The Sandbox Class +## Code generation (CRITICAL) -The `Sandbox` object is the primary developer-facing interface. It wraps the generated `Sandbox` model with lifecycle and data-plane methods: +Models in `src/wrenn/models/_generated.py` are generated by `datamodel-codegen` from `api/openapi.yaml`. -```python -with client.sandboxes.create("base-python") as sb: - sb.wait_ready(timeout=30) +1. **Never edit `_generated.py`** — overwritten on next `make generate`. +2. All user-facing models must be re-exported in `src/wrenn/models/__init__.py` via `__all__`. +3. To extend a generated model with custom methods, subclass it (e.g. `Sandbox` in `sandbox.py` subclasses the generated `SandboxModel`). - result = sb.exec("echo hello") - print(result.stdout) # "hello\n" - print(result.exit_code) # 0 +## Dependency management - sb.upload("/app/main.py", b"print('hello')") - data = sb.download("/app/main.py") - - sb.ping() - sb.pause() - sb.resume() -# Exiting the block automatically calls sb.destroy() +```bash +uv add # runtime dep +uv add --dev # dev dep +uv run # run in managed .venv ``` -**Key methods:** +## Implemented resource namespaces -| Method | Endpoint | Description | -|--------|----------|-------------| -| `sb.exec(cmd)` | `POST /v1/sandboxes/{id}/exec` | Synchronous exec. Returns `ExecResult` with `stdout`, `stderr`, `exit_code`, `duration_ms`. | -| `sb.exec_stream(cmd)` | `WS GET /v1/sandboxes/{id}/exec/stream` | Streaming exec via WebSocket. Returns an `Iterator[StreamEvent]` yielding `start`, `stdout`, `stderr`, `exit`, `error` events. | -| `sb.upload(path, data)` | `POST /v1/sandboxes/{id}/files/write` | Upload a small file (multipart form-data). | -| `sb.download(path)` | `POST /v1/sandboxes/{id}/files/read` | Download a small file. Returns bytes. | -| `sb.stream_upload(path, stream)` | `POST /v1/sandboxes/{id}/files/stream/write` | Streaming multipart upload for large files. No in-memory buffering. | -| `sb.stream_download(path)` | `POST /v1/sandboxes/{id}/files/stream/read` | Streaming chunked download for large files. Returns `Iterator[bytes]`. | -| `sb.wait_ready(timeout=30)` | Polls `GET /v1/sandboxes/{id}` | Blocks until status is `running`. Raises `TimeoutError` on expiry. | -| `sb.ping()` | `POST /v1/sandboxes/{id}/ping` | Resets inactivity timer. | -| `sb.pause()` | `POST /v1/sandboxes/{id}/pause` | Snapshots and releases resources. | -| `sb.resume()` | `POST /v1/sandboxes/{id}/resume` | Restores from snapshot. | -| `sb.destroy()` | `DELETE /v1/sandboxes/{id}` | Tears down the sandbox. Called automatically by context manager. | -| `sb.metrics(range="10m")` | `GET /v1/sandboxes/{id}/metrics` | Returns CPU, memory, disk time-series. | -| `sb.run_code(code, language="python")` | Jupyter kernel via proxy WS | Stateful code execution in any language with a Jupyter kernel. Variables persist across calls. Returns `CodeResult` with `.text`, `.stdout`, `.stderr`, `.error`, `.data`. See `CODE_EXECUTION.md`. | +Only these are currently implemented in `client.py`: -### 4. Context Managers +- **`client.auth`** — `signup`, `login` +- **`client.api_keys`** — `create`, `list`, `delete` +- **`client.sandboxes`** — `create`, `list`, `get`, `destroy` +- **`client.snapshots`** — `create`, `list`, `delete` +- **`client.hosts`** — `create`, `list`, `get`, `delete`, `regenerate_token`, `list_tags`, `add_tag`, `remove_tag` -Sandboxes are ephemeral. The SDK must use context managers (`with` and `async with`) to guarantee cleanup: +Both sync and async variants exist for every resource. -```python -with client.sandboxes.create("base-python") as sb: - sb.wait_ready(timeout=30) - result = sb.exec("python -c 'print(42)'") -# __exit__ calls sb.destroy() / DELETE /v1/sandboxes/{id} -``` +## Architecture notes -### 5. Streaming Executions +- **Sync/async parity**: `WrennClient` + `AsyncWrennClient` in `client.py`, using `httpx.Client`/`httpx.AsyncClient`. Async methods on `Sandbox` are prefixed `async_` (e.g. `async_exec`, `async_upload`). +- **WebSocket library**: `httpx-ws` (not `websockets`). Used for `exec_stream`, `pty`, and `run_code`. +- **Sandbox proxy URL**: `get_url(port)` returns `ws://` or `wss://` scheme. The `http_client` property converts to `http://`/`https://` automatically. +- **`Sandbox`** (in `sandbox.py`) is the main developer-facing class — subclasses generated model, adds lifecycle methods (`exec`, `upload`, `download`, `list_dir`, `mkdir`, `remove`, `pty`, `run_code`, `wait_ready`, `pause`, `resume`, `destroy`, `ping`, `metrics`), context manager support, and proxy helpers. +- **Error handling**: `handle_response()` in `exceptions.py` maps server error `code` field to typed exceptions (not just HTTP status). All inherit from `WrennError` with `.code`, `.message`, `.status_code`. -There are two distinct exec endpoints: +## Testing -**Synchronous exec** — `sb.exec(cmd, args=[], timeout_sec=30)` -- Calls `POST /v1/sandboxes/{id}/exec`. Blocks until the command completes. -- Returns an `ExecResult` with `stdout`, `stderr`, `exit_code`, `duration_ms`, `encoding`. +- **HTTP mocking**: `respx` library (not `responses` or `pytest-httpx`). Mock routes with `@respx.mock` decorator or `respx.mock` context manager. +- **Async tests**: use `@pytest.mark.asyncio` (backed by `pytest-asyncio`). +- **Integration tests**: in `test_integration.py`, require env vars `WRENN_API_KEY` or `WRENN_TOKEN` (plus optional `WRENN_BASE_URL`, `WRENN_TEST_EMAIL`, `WRENN_TEST_PASSWORD`). They are skipped via `@requires_auth` if credentials are absent. +- **Fixtures**: test fixtures create `WrennClient(api_key="wrn_test1234567890abcdef12345678")` with context manager cleanup. -**Streaming exec** — `sb.exec_stream(cmd, args=[])` -- Opens a WebSocket to `GET /v1/sandboxes/{id}/exec/stream`. -- Returns an `Iterator[StreamEvent]` (or `AsyncIterator[StreamEvent]` for async). -- The client sends `{"type": "start", "cmd": "...", "args": [...]}` as the first message. -- The server sends events: `StreamStartEvent(pid)`, `StreamStdoutEvent(data)`, `StreamStderrEvent(data)`, `StreamExitEvent(exit_code)`, `StreamErrorEvent(data)`. -- The connection closes after the process exits. The client can send `{"type": "stop"}` to terminate early. +## Coding conventions -### 6. Error Handling - -Do not leak raw `httpx.HTTPStatusError` to the user. The server returns errors as: - -```json -{"error": {"code": "not_found", "message": "sandbox not found"}} -``` - -Map the `code` field (not just HTTP status) to typed exceptions: - -| Error code | HTTP status | Exception | -|-----------|-------------|-----------| -| `invalid_request` | 400 | `WrennValidationError` | -| `unauthorized` | 401 | `WrennAuthenticationError` | -| `forbidden` | 403 | `WrennForbiddenError` | -| `not_found` | 404 | `WrennNotFoundError` | -| `invalid_state` | 409 | `WrennConflictError` | -| `conflict` | 409 | `WrennConflictError` | -| `host_has_sandboxes` | 409 | `WrennHostHasSandboxesError` (includes `sandbox_ids`) | -| `host_unavailable` | 503 | `WrennHostUnavailableError` | -| `agent_error` | 502 | `WrennAgentError` | -| `internal_error` | 500 | `WrennInternalError` | - -All exceptions inherit from `WrennError` and expose `.code`, `.message`, and `.status_code`. - -### 7. Resource Coverage - -The full API surface exposed through resource namespaces: - -**`client.sandboxes`** — `create`, `list`, `get`, `destroy`, `get_stats` -**`client.snapshots`** — `create`, `list`, `delete` -**`client.api_keys`** — `create`, `list`, `delete` -**`client.hosts`** — `create`, `list`, `get`, `delete`, `delete_preview`, `regenerate_token`, `list_tags`, `add_tag`, `remove_tag` -**`client.teams`** — `list`, `create`, `get`, `rename`, `delete`, `list_members`, `add_member`, `update_member_role`, `remove_member`, `leave` -**`client.audit`** — `list` (paginated with `before`/`before_id` cursors) -**`client.builds`** — `create`, `list`, `get`, `cancel` (admin-only) -**`client.admin`** — `set_team_byoc`, `list_templates`, `delete_template` - -### 8. Sandbox Proxy / Port Forwarding - -Services running inside a sandbox are accessible via a reverse proxy. The control plane intercepts requests whose `Host` header matches `{port}-{sandbox_id}.{domain}` and forwards them to the host agent. - -The SDK exposes two helpers on the `Sandbox` object: - -**`sb.get_url(port) -> str`** -- Constructs the proxy URL from the client's `base_url`. -- Derivation: parse `base_url` host, build `http://{port}-{sandbox_id}.{host}`. -- Example: `base_url="https://api.wrenn.dev"`, `sb.id="cl-abc123"` → `"http://8888-cl-abc123.api.wrenn.dev"` -- Example: `base_url="http://localhost:8080"`, `sb.id="cl-abc123"` → `"http://8888-cl-abc123.localhost:8080"` - -**`sb.http_client -> httpx.Client`** -- A pre-configured `httpx.Client` with: - - `base_url` set to the proxy URL (root `/` maps to the proxied service) - - `X-API-Key` header set from the parent client's API key -- Allows direct HTTP interaction with services inside the sandbox without manual header management. -- Closed automatically when the sandbox context manager exits. - -**Auth:** Proxy requests require the `X-API-Key` header. JWT is not supported for proxy routes. If the client was constructed with a JWT token only, `sb.get_url()` and `sb.http_client` must raise `WrennAuthenticationError`. - -**Example: Jupyter inside a sandbox** - -```python -with client.sandboxes.create("python-jupyter") as sb: - sb.wait_ready(timeout=60) - - # High-level: stateful code execution (see CODE_EXECUTION.md) - result = sb.run_code("print('hello from persistent kernel')") - print(result.stdout) - - # Low-level: direct HTTP to Jupyter REST API - resp = sb.http_client.get("/api/kernels") - print(resp.json()) - - # Low-level: direct proxy URL for browser access - jupyter_url = sb.get_url(8888) -``` - -## Coding Conventions & Typing - -- **Python Target:** `3.13+`. Use modern syntax (`|` for Unions, standard library generics like `list[str]`). -- **Typing:** Everything must be strictly typed. Use `pyright` for validation. -- **Formatting:** `ruff` is the sole linter and formatter. Do not use `black`, `isort`, or `flake8`. -- **Docstrings:** Use Google-style docstrings. These surface to end-users via IDE hover. -- **No comments:** Do not add comments unless explicitly asked. +- **Python 3.13+** with modern syntax (`|` unions, `list[str]` generics). +- **Strict typing** throughout. `pyright`/`mypy` available but not in CI. +- **`ruff`** is the sole linter and formatter. Do not use `black`, `isort`, or `flake8`. +- **Google-style docstrings** on all public APIs. +- **No comments** unless explicitly asked. diff --git a/Makefile b/Makefile index e58a7af..a4a57ba 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ .PHONY: generate lint test check test-integration # Variables -SPEC_URL = "https://git.omukk.dev/wrenn/wrenn/raw/branch/main/internal/api/openapi.yaml" +SPEC_URL = "https://git.omukk.dev/wrenn/wrenn/raw/branch/dev/internal/api/openapi.yaml" SPEC_PATH = "api/openapi.yaml" generate: diff --git a/api/openapi.yaml b/api/openapi.yaml index f4c8f66..0b56fe5 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -42,6 +42,47 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/auth/switch-team: + post: + summary: Switch active team + operationId: switchTeam + tags: [auth] + security: + - bearerAuth: [] + description: | + Re-issues a JWT scoped to a different team. The user must be a member of + the target team (verified from DB). Use the returned token for subsequent + requests to that team's resources. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [team_id] + properties: + team_id: + type: string + responses: + "200": + description: New JWT issued for the target team + content: + application/json: + schema: + $ref: "#/components/schemas/AuthResponse" + "403": + description: Not a member of this team + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Team not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/auth/login: post: summary: Log in with email and password @@ -195,6 +236,340 @@ paths: "204": description: API key deleted + /v1/users/search: + get: + summary: Search users by email prefix + operationId: searchUsers + tags: [users] + security: + - bearerAuth: [] + description: | + Returns up to 10 users whose email starts with the given prefix. + The prefix must contain "@". Intended for the add-member UI autocomplete. + parameters: + - name: email + in: query + required: true + schema: + type: string + description: Email prefix (must contain "@", e.g. "alice@") + responses: + "200": + description: Matching users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/UserSearchResult" + "400": + description: Prefix does not contain "@" + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/teams: + get: + summary: List teams for the authenticated user + operationId: listTeams + tags: [teams] + security: + - bearerAuth: [] + responses: + "200": + description: Teams the user belongs to, each with their role + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TeamWithRole" + + post: + summary: Create a new team + operationId: createTeam + tags: [teams] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + description: 1-128 chars; A-Z a-z 0-9 space _ + responses: + "201": + description: Team created (caller is owner) + content: + application/json: + schema: + $ref: "#/components/schemas/TeamWithRole" + "400": + description: Invalid team name + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/teams/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Team ID (must match the JWT's team_id) + + get: + summary: Get team info and member list + operationId: getTeam + tags: [teams] + security: + - bearerAuth: [] + responses: + "200": + description: Team details with members + content: + application/json: + schema: + $ref: "#/components/schemas/TeamDetail" + "403": + description: JWT team does not match requested team + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Team not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + patch: + summary: Rename the team + operationId: renameTeam + tags: [teams] + security: + - bearerAuth: [] + description: Admin or owner role required (verified from DB). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + responses: + "204": + description: Renamed + "400": + description: Invalid team name + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "403": + description: Insufficient role + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + delete: + summary: Delete the team + operationId: deleteTeam + tags: [teams] + security: + - bearerAuth: [] + description: | + Owner only. Soft-deletes the team and destroys all running/paused/starting + sandboxes. All DB records are preserved. The team slug is permanently reserved. + responses: + "204": + description: Team deleted + "403": + description: Caller is not the owner + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/teams/{id}/members: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: List team members + operationId: listTeamMembers + tags: [teams] + security: + - bearerAuth: [] + responses: + "200": + description: Members with roles + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TeamMember" + + post: + summary: Add a member by email + operationId: addTeamMember + tags: [teams] + security: + - bearerAuth: [] + description: Admin or owner role required. User is added instantly as a member. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: + type: string + format: email + responses: + "201": + description: Member added + content: + application/json: + schema: + $ref: "#/components/schemas/TeamMember" + "403": + description: Insufficient role + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: No account with that email + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "400": + description: User is already a member + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/teams/{id}/members/{uid}: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: uid + in: path + required: true + schema: + type: string + description: Target user ID + + patch: + summary: Update member role + operationId: updateMemberRole + tags: [teams] + security: + - bearerAuth: [] + description: | + Admin or owner required. Valid target roles: admin, member. + The owner's role cannot be changed. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [role] + properties: + role: + type: string + enum: [admin, member] + responses: + "204": + description: Role updated + "403": + description: Insufficient role or attempt to modify owner + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: User is not a member + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + delete: + summary: Remove a member + operationId: removeTeamMember + tags: [teams] + security: + - bearerAuth: [] + description: Admin or owner required. Owner cannot be removed. + responses: + "204": + description: Member removed + "403": + description: Insufficient role or attempt to remove owner + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: User is not a member + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/teams/{id}/leave: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Leave the team + operationId: leaveTeam + tags: [teams] + security: + - bearerAuth: [] + description: The owner cannot leave; they must delete the team instead. + responses: + "204": + description: Left the team + "403": + description: Owner cannot leave + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/sandboxes: post: summary: Create a sandbox @@ -238,6 +613,32 @@ paths: items: $ref: "#/components/schemas/Sandbox" + /v1/sandboxes/stats: + get: + summary: Get sandbox usage stats for your team + operationId: getSandboxStats + tags: [sandboxes] + security: + - apiKeyAuth: [] + parameters: + - name: range + in: query + required: false + schema: + type: string + enum: [5m, 1h, 6h, 24h, 30d] + default: 1h + description: Time window for the time-series data. + responses: + "200": + description: Sandbox stats for the team + content: + application/json: + schema: + $ref: "#/components/schemas/SandboxStats" + "400": + $ref: "#/components/responses/BadRequest" + /v1/sandboxes/{id}: parameters: - name: id @@ -350,6 +751,60 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/sandboxes/{id}/metrics: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: Get per-sandbox resource metrics + operationId: getSandboxMetrics + tags: [sandboxes] + security: + - apiKeyAuth: [] + - bearerAuth: [] + description: | + Returns time-series CPU, memory, and disk metrics for a sandbox. + Three tiers are available with different granularity and retention: + - `10m`: 500ms samples, last 10 minutes + - `2h`: 30-second averages, last 2 hours + - `24h`: 5-minute averages, last 24 hours + + For running sandboxes, data comes from the host agent's in-memory + ring buffer. For paused sandboxes, data is read from persisted + snapshots in the database. Stopped/destroyed sandboxes return 404. + parameters: + - name: range + in: query + required: false + schema: + type: string + enum: ["5m", "10m", "1h", "2h", "6h", "12h", "24h"] + default: "10m" + description: Time range filter to query + responses: + "200": + description: Metrics retrieved + content: + application/json: + schema: + $ref: "#/components/schemas/SandboxMetrics" + "400": + description: Invalid range parameter + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Sandbox not found or metrics not available + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/sandboxes/{id}/pause: parameters: - name: id @@ -582,6 +1037,122 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/sandboxes/{id}/files/list: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: List directory contents + operationId: listDir + tags: [sandboxes] + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListDirRequest" + responses: + "200": + description: Directory listing + content: + application/json: + schema: + $ref: "#/components/schemas/ListDirResponse" + "404": + description: Sandbox not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Sandbox not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/sandboxes/{id}/files/mkdir: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Create a directory + operationId: makeDir + tags: [sandboxes] + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MakeDirRequest" + responses: + "200": + description: Directory created + content: + application/json: + schema: + $ref: "#/components/schemas/MakeDirResponse" + "404": + description: Sandbox not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Sandbox not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/sandboxes/{id}/files/remove: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Remove a file or directory + operationId: removePath + tags: [sandboxes] + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RemoveRequest" + responses: + "204": + description: File or directory removed + "404": + description: Sandbox not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Sandbox not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/sandboxes/{id}/exec/stream: parameters: - name: id @@ -635,6 +1206,84 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/sandboxes/{id}/pty: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: Interactive PTY session via WebSocket + operationId: ptySession + tags: [sandboxes] + security: + - apiKeyAuth: [] + description: | + Opens a WebSocket connection for an interactive PTY (terminal) session. + Supports creating new sessions, sending input, resizing, killing, and + reconnecting to existing sessions. + + **Client sends** (first message — start a new PTY): + ```json + { + "type": "start", + "cmd": "/bin/bash", + "args": [], + "cols": 80, + "rows": 24, + "envs": {"TERM": "xterm-256color"}, + "cwd": "/home/user", + "user": "user" + } + ``` + All fields except `type` are optional. Defaults: cmd="/bin/bash", cols=80, rows=24. + + **Client sends** (first message — reconnect to existing PTY): + ```json + {"type": "connect", "tag": "pty-abc123de"} + ``` + + **Client sends** (after session is established): + ```json + {"type": "input", "data": ""} + {"type": "resize", "cols": 120, "rows": 40} + {"type": "kill"} + ``` + + **Server sends**: + ```json + {"type": "started", "tag": "pty-abc123de", "pid": 42} + {"type": "output", "data": ""} + {"type": "exit", "exit_code": 0} + {"type": "error", "data": "description", "fatal": true} + {"type": "ping"} + ``` + + PTY data (input and output) is base64-encoded because it contains raw + terminal bytes (escape sequences, control codes) that are not valid UTF-8. + + Sessions have a 120-second inactivity timeout (reset on input/resize). + Sessions persist across WebSocket disconnections — the process keeps + running in the sandbox. Use the `tag` from the "started" response to + reconnect later. + responses: + "101": + description: WebSocket upgrade + "404": + description: Sandbox not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Sandbox not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/sandboxes/{id}/files/stream/write: parameters: - name: id @@ -818,8 +1467,16 @@ paths: security: - bearerAuth: [] description: | - Admins can delete any host. Team owners can delete BYOC hosts - belonging to their team. + Admins can delete any host. Team owners and admins can delete BYOC hosts + belonging to their team. Without `?force=true`, returns 409 if the host + has active sandboxes. With `?force=true`, destroys all sandboxes first. + parameters: + - name: force + in: query + required: false + schema: + type: boolean + description: If true, destroy all sandboxes on the host before deleting. responses: "204": description: Host deleted @@ -829,6 +1486,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + "409": + description: Host has active sandboxes (only when force is not set) + content: + application/json: + schema: + $ref: "#/components/schemas/HostHasSandboxesError" /v1/hosts/{id}/token: parameters: @@ -937,6 +1600,72 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/hosts/auth/refresh: + post: + summary: Refresh host JWT + operationId: refreshHostToken + tags: [hosts] + description: | + Exchanges a refresh token for a new JWT and rotated refresh token. + The old refresh token is immediately revoked. No authentication required — + the refresh token itself is the credential. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RefreshHostTokenRequest" + responses: + "200": + description: New JWT and rotated refresh token + content: + application/json: + schema: + $ref: "#/components/schemas/RefreshHostTokenResponse" + "401": + description: Invalid, expired, or revoked refresh token + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/hosts/{id}/delete-preview: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: Preview host deletion + operationId: getHostDeletePreview + tags: [hosts] + security: + - bearerAuth: [] + description: | + Returns the list of sandbox IDs that would be destroyed if the host + were deleted with `?force=true`. No state is modified. + responses: + "200": + description: Deletion preview + content: + application/json: + schema: + $ref: "#/components/schemas/HostDeletePreview" + "403": + description: Insufficient permissions + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Host not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/hosts/{id}/tags: parameters: - name: id @@ -1012,6 +1741,176 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/channels: + post: + summary: Create a notification channel + operationId: createChannel + tags: [channels] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateChannelRequest" + responses: + "201": + description: Channel created + content: + application/json: + schema: + $ref: "#/components/schemas/ChannelResponse" + "400": + $ref: "#/components/responses/BadRequest" + get: + summary: List notification channels + operationId: listChannels + tags: [channels] + security: + - bearerAuth: [] + responses: + "200": + description: Channels list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ChannelResponse" + + /v1/channels/test: + post: + summary: Test a channel configuration + description: > + Sends a test notification using the provided provider and config without + saving anything. Use this to verify credentials before creating a channel. + operationId: testChannel + tags: [channels] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TestChannelRequest" + responses: + "200": + description: Test notification sent successfully + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + "400": + $ref: "#/components/responses/BadRequest" + + /v1/channels/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + get: + summary: Get a notification channel + operationId: getChannel + tags: [channels] + security: + - bearerAuth: [] + responses: + "200": + description: Channel details + content: + application/json: + schema: + $ref: "#/components/schemas/ChannelResponse" + "404": + description: Channel not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + patch: + summary: Update a notification channel + operationId: updateChannel + tags: [channels] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateChannelRequest" + responses: + "200": + description: Channel updated + content: + application/json: + schema: + $ref: "#/components/schemas/ChannelResponse" + "400": + $ref: "#/components/responses/BadRequest" + "404": + description: Channel not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + delete: + summary: Delete a notification channel + operationId: deleteChannel + tags: [channels] + security: + - bearerAuth: [] + responses: + "204": + description: Channel deleted + + /v1/channels/{id}/config: + parameters: + - name: id + in: path + required: true + schema: + type: string + put: + summary: Rotate channel secrets + description: > + Replaces the channel's provider configuration entirely with new secrets. + The previous config is discarded. Config fields must match the provider's + required fields. + operationId: rotateChannelConfig + tags: [channels] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RotateConfigRequest" + responses: + "200": + description: Config rotated + content: + application/json: + schema: + $ref: "#/components/schemas/ChannelResponse" + "400": + $ref: "#/components/responses/BadRequest" + "404": + description: Channel not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + components: securitySchemes: apiKeyAuth: @@ -1030,12 +1929,12 @@ components: type: apiKey in: header name: X-Host-Token - description: Long-lived host JWT returned from POST /v1/hosts/register. Valid for 1 year. + description: Host JWT returned from POST /v1/hosts/register or POST /v1/hosts/auth/refresh. Valid for 7 days. schemas: SignupRequest: type: object - required: [email, password] + required: [email, password, name] properties: email: type: string @@ -1043,6 +1942,9 @@ components: password: type: string minLength: 8 + name: + type: string + maxLength: 100 LoginRequest: type: object @@ -1066,6 +1968,8 @@ components: type: string email: type: string + name: + type: string CreateAPIKeyRequest: type: object @@ -1118,6 +2022,57 @@ components: after this duration of inactivity (no exec or ping). 0 means no auto-pause. + SandboxStats: + type: object + properties: + range: + type: string + enum: [5m, 1h, 6h, 24h, 30d] + current: + type: object + properties: + running_count: + type: integer + vcpus_reserved: + type: integer + memory_mb_reserved: + type: integer + sampled_at: + type: string + format: date-time + nullable: true + peaks: + type: object + description: Maximum values over the last 30 days. + properties: + running_count: + type: integer + vcpus: + type: integer + memory_mb: + type: integer + series: + type: object + description: Parallel arrays for chart rendering. + properties: + labels: + type: array + items: + type: string + format: date-time + running: + type: array + items: + type: integer + vcpus: + type: array + items: + type: integer + memory_mb: + type: array + items: + type: integer + Sandbox: type: object properties: @@ -1125,7 +2080,7 @@ components: type: string status: type: string - enum: [pending, running, paused, stopped, error] + enum: [pending, starting, running, paused, hibernated, stopped, missing, error] template: type: string vcpus: @@ -1227,6 +2182,78 @@ components: type: string description: Absolute file path inside the sandbox + ListDirRequest: + type: object + required: [path] + properties: + path: + type: string + description: Directory path inside the sandbox + depth: + type: integer + default: 1 + description: Recursion depth (0 = non-recursive, 1 = immediate children) + + ListDirResponse: + type: object + properties: + entries: + type: array + items: + $ref: "#/components/schemas/FileEntry" + + FileEntry: + type: object + properties: + name: + type: string + path: + type: string + type: + type: string + enum: [file, directory, symlink] + size: + type: integer + format: int64 + mode: + type: integer + permissions: + type: string + description: Human-readable permissions (e.g. "-rwxr-xr-x") + owner: + type: string + group: + type: string + modified_at: + type: integer + format: int64 + description: Unix timestamp (seconds) + symlink_target: + type: string + nullable: true + + MakeDirRequest: + type: object + required: [path] + properties: + path: + type: string + description: Directory path to create inside the sandbox + + MakeDirResponse: + type: object + properties: + entry: + $ref: "#/components/schemas/FileEntry" + + RemoveRequest: + type: object + required: [path] + properties: + path: + type: string + description: Path to remove inside the sandbox + CreateHostRequest: type: object required: [type] @@ -1281,7 +2308,10 @@ components: $ref: "#/components/schemas/Host" token: type: string - description: Long-lived host JWT for X-Host-Token header. Valid for 1 year. + description: Host JWT for X-Host-Token header. Valid for 7 days. + refresh_token: + type: string + description: Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use. Host: type: object @@ -1317,7 +2347,7 @@ components: nullable: true status: type: string - enum: [pending, online, offline, draining] + enum: [pending, online, offline, draining, unreachable] last_heartbeat_at: type: string format: date-time @@ -1331,6 +2361,54 @@ components: type: string format: date-time + RefreshHostTokenRequest: + type: object + required: [refresh_token] + properties: + refresh_token: + type: string + description: Refresh token obtained from registration or a previous refresh. + + RefreshHostTokenResponse: + type: object + properties: + host: + $ref: "#/components/schemas/Host" + token: + type: string + description: New host JWT. Valid for 7 days. + refresh_token: + type: string + description: New refresh token. Valid for 60 days; old token is revoked. + + HostDeletePreview: + type: object + properties: + host: + $ref: "#/components/schemas/Host" + sandbox_ids: + type: array + items: + type: string + description: IDs of sandboxes that would be destroyed on force-delete. + + HostHasSandboxesError: + type: object + properties: + error: + type: object + properties: + code: + type: string + example: host_has_sandboxes + message: + type: string + sandbox_ids: + type: array + items: + type: string + description: IDs of active sandboxes blocking deletion. + AddTagRequest: type: object required: [tag] @@ -1338,6 +2416,199 @@ components: tag: type: string + UserSearchResult: + type: object + properties: + user_id: + type: string + email: + type: string + + Team: + type: object + properties: + id: + type: string + name: + type: string + slug: + type: string + description: Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3) + created_at: + type: string + format: date-time + + TeamWithRole: + allOf: + - $ref: "#/components/schemas/Team" + - type: object + properties: + role: + type: string + enum: [owner, admin, member] + + TeamMember: + type: object + properties: + user_id: + type: string + email: + type: string + role: + type: string + enum: [owner, admin, member] + joined_at: + type: string + format: date-time + + TeamDetail: + type: object + properties: + team: + $ref: "#/components/schemas/Team" + members: + type: array + items: + $ref: "#/components/schemas/TeamMember" + + SandboxMetrics: + type: object + properties: + sandbox_id: + type: string + range: + type: string + enum: ["5m", "10m", "1h", "2h", "6h", "12h", "24h"] + points: + type: array + items: + $ref: "#/components/schemas/MetricPoint" + + MetricPoint: + type: object + properties: + timestamp_unix: + type: integer + format: int64 + cpu_pct: + type: number + format: double + description: "CPU utilization percentage (0-100), normalized to vCPU count" + mem_bytes: + type: integer + format: int64 + description: "Resident memory in bytes (VmRSS of Firecracker process)" + disk_bytes: + type: integer + format: int64 + description: "Allocated disk bytes for the CoW sparse file" + + CreateChannelRequest: + type: object + required: [name, provider, config, events] + properties: + name: + type: string + description: Unique channel name within the team. + provider: + type: string + enum: [discord, slack, teams, googlechat, telegram, matrix, webhook] + config: + type: object + additionalProperties: + type: string + description: > + Provider-specific configuration fields. + Discord/Slack/Teams/Google Chat: {"webhook_url": "..."}. + Telegram: {"bot_token": "...", "chat_id": "..."}. + Matrix: {"homeserver_url": "...", "access_token": "...", "room_id": "..."}. + Webhook: {"url": "...", "secret": "..."} (secret is auto-generated if omitted). + events: + type: array + items: + type: string + enum: + - capsule.created + - capsule.running + - capsule.paused + - capsule.destroyed + - template.snapshot.created + - template.snapshot.deleted + - host.up + - host.down + + TestChannelRequest: + type: object + required: [provider, config] + properties: + provider: + type: string + enum: [discord, slack, teams, googlechat, telegram, matrix, webhook] + config: + type: object + additionalProperties: + type: string + description: Provider-specific configuration fields (same as CreateChannelRequest.config). + + RotateConfigRequest: + type: object + required: [config] + properties: + config: + type: object + additionalProperties: + type: string + description: > + New provider configuration fields. Must include all required fields + for the channel's provider. Replaces the existing config entirely. + + UpdateChannelRequest: + type: object + required: [name, events] + properties: + name: + type: string + events: + type: array + items: + type: string + enum: + - capsule.created + - capsule.running + - capsule.paused + - capsule.destroyed + - template.snapshot.created + - template.snapshot.deleted + - host.up + - host.down + + ChannelResponse: + type: object + properties: + id: + type: string + team_id: + type: string + name: + type: string + provider: + type: string + enum: [discord, slack, teams, googlechat, telegram, matrix, webhook] + events: + type: array + items: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + secret: + type: string + nullable: true + description: Webhook secret. Only returned on creation, never again. + Error: type: object properties: diff --git a/src/wrenn/__init__.py b/src/wrenn/__init__.py index 1b90919..d478216 100644 --- a/src/wrenn/__init__.py +++ b/src/wrenn/__init__.py @@ -11,6 +11,8 @@ from wrenn.exceptions import ( WrennNotFoundError, WrennValidationError, ) +from wrenn.models import FileEntry +from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession from wrenn.sandbox import ( CodeResult, ExecResult, @@ -27,9 +29,14 @@ __version__ = "0.1.0" __all__ = [ "__version__", + "AsyncPtySession", "AsyncWrennClient", "CodeResult", "ExecResult", + "FileEntry", + "PtyEvent", + "PtyEventType", + "PtySession", "Sandbox", "StreamErrorEvent", "StreamEvent", diff --git a/src/wrenn/client.py b/src/wrenn/client.py index 6ffa25c..bd7fb69 100644 --- a/src/wrenn/client.py +++ b/src/wrenn/client.py @@ -5,80 +5,24 @@ from typing import cast import httpx -from wrenn.exceptions import ( - WrennAgentError, - WrennAuthenticationError, - WrennConflictError, - WrennError, - WrennForbiddenError, - WrennHostHasSandboxesError, - WrennHostUnavailableError, - WrennInternalError, - WrennNotFoundError, - WrennValidationError, -) +from wrenn.exceptions import handle_response from wrenn.models import ( APIKeyResponse, AuthResponse, CreateHostResponse, Host, - Sandbox as SandboxModel, Template, ) +from wrenn.models import ( + Sandbox as SandboxModel, +) from wrenn.sandbox import Sandbox DEFAULT_BASE_URL = "https://api.wrenn.dev" -_ERROR_MAP: dict[str, type[WrennError]] = { - "invalid_request": WrennValidationError, - "unauthorized": WrennAuthenticationError, - "forbidden": WrennForbiddenError, - "not_found": WrennNotFoundError, - "invalid_state": WrennConflictError, - "conflict": WrennConflictError, - "host_has_sandboxes": WrennHostHasSandboxesError, - "host_unavailable": WrennHostUnavailableError, - "agent_error": WrennAgentError, - "internal_error": WrennInternalError, -} - - -def _handle_response(resp: httpx.Response) -> dict | list: - if resp.status_code >= 400: - try: - body = resp.json() - except Exception: - resp.raise_for_status() - raise - - err = body.get("error", {}) - code = err.get("code", "internal_error") - message = err.get("message", resp.text) - - exc_cls = _ERROR_MAP.get(code, WrennError) - - if exc_cls is WrennHostHasSandboxesError: - raise WrennHostHasSandboxesError( - code=code, - message=message, - status_code=resp.status_code, - sandbox_ids=body.get("sandbox_ids", []), - ) - - raise exc_cls( - code=code, - message=message, - status_code=resp.status_code, - ) - - if resp.status_code == 204: - return {} - - return resp.json() - def _build_headers(api_key: str | None, token: str | None) -> dict[str, str]: - headers: dict[str, str] = {"Content-Type": "application/json"} + headers: dict[str, str] = {} if api_key: headers["X-API-Key"] = api_key if token: @@ -96,13 +40,13 @@ class AuthResource: resp = self._http.post( "/v1/auth/signup", json={"email": email, "password": password} ) - return AuthResponse.model_validate(_handle_response(resp)) + return AuthResponse.model_validate(handle_response(resp)) def login(self, email: str, password: str) -> AuthResponse: resp = self._http.post( "/v1/auth/login", json={"email": email, "password": password} ) - return AuthResponse.model_validate(_handle_response(resp)) + return AuthResponse.model_validate(handle_response(resp)) class AsyncAuthResource: @@ -115,13 +59,13 @@ class AsyncAuthResource: resp = await self._http.post( "/v1/auth/signup", json={"email": email, "password": password} ) - return AuthResponse.model_validate(_handle_response(resp)) + return AuthResponse.model_validate(handle_response(resp)) async def login(self, email: str, password: str) -> AuthResponse: resp = await self._http.post( "/v1/auth/login", json={"email": email, "password": password} ) - return AuthResponse.model_validate(_handle_response(resp)) + return AuthResponse.model_validate(handle_response(resp)) class APIKeysResource: @@ -135,15 +79,15 @@ class APIKeysResource: if name is not None: payload["name"] = name resp = self._http.post("/v1/api-keys", json=payload) - return APIKeyResponse.model_validate(_handle_response(resp)) + return APIKeyResponse.model_validate(handle_response(resp)) def list(self) -> list[APIKeyResponse]: resp = self._http.get("/v1/api-keys") - return [APIKeyResponse.model_validate(item) for item in _handle_response(resp)] + return [APIKeyResponse.model_validate(item) for item in handle_response(resp)] def delete(self, id: str) -> None: resp = self._http.delete(f"/v1/api-keys/{id}") - _handle_response(resp) + handle_response(resp) class AsyncAPIKeysResource: @@ -157,15 +101,15 @@ class AsyncAPIKeysResource: if name is not None: payload["name"] = name resp = await self._http.post("/v1/api-keys", json=payload) - return APIKeyResponse.model_validate(_handle_response(resp)) + return APIKeyResponse.model_validate(handle_response(resp)) async def list(self) -> list[APIKeyResponse]: resp = await self._http.get("/v1/api-keys") - return [APIKeyResponse.model_validate(item) for item in _handle_response(resp)] + return [APIKeyResponse.model_validate(item) for item in handle_response(resp)] async def delete(self, id: str) -> None: resp = await self._http.delete(f"/v1/api-keys/{id}") - _handle_response(resp) + handle_response(resp) class SandboxesResource: @@ -200,22 +144,22 @@ class SandboxesResource: if timeout_sec is not None: payload["timeout_sec"] = timeout_sec resp = self._http.post("/v1/sandboxes", json=payload) - model = SandboxModel.model_validate(_handle_response(resp)) + model = SandboxModel.model_validate(handle_response(resp)) sb = Sandbox.model_validate(model.model_dump()) sb._bind(self._http, self._base_url, self._api_key, self._token) return sb def list(self) -> list[SandboxModel]: resp = self._http.get("/v1/sandboxes") - return [SandboxModel.model_validate(item) for item in _handle_response(resp)] + return [SandboxModel.model_validate(item) for item in handle_response(resp)] def get(self, id: str) -> SandboxModel: resp = self._http.get(f"/v1/sandboxes/{id}") - return SandboxModel.model_validate(_handle_response(resp)) + return SandboxModel.model_validate(handle_response(resp)) def destroy(self, id: str) -> None: resp = self._http.delete(f"/v1/sandboxes/{id}") - _handle_response(resp) + handle_response(resp) class AsyncSandboxesResource: @@ -250,22 +194,22 @@ class AsyncSandboxesResource: if timeout_sec is not None: payload["timeout_sec"] = timeout_sec resp = await self._http.post("/v1/sandboxes", json=payload) - model = SandboxModel.model_validate(_handle_response(resp)) + model = SandboxModel.model_validate(handle_response(resp)) sb = Sandbox.model_validate(model.model_dump()) sb._bind(self._http, self._base_url, self._api_key, self._token) return sb async def list(self) -> list[SandboxModel]: resp = await self._http.get("/v1/sandboxes") - return [SandboxModel.model_validate(item) for item in _handle_response(resp)] + return [SandboxModel.model_validate(item) for item in handle_response(resp)] async def get(self, id: str) -> SandboxModel: resp = await self._http.get(f"/v1/sandboxes/{id}") - return SandboxModel.model_validate(_handle_response(resp)) + return SandboxModel.model_validate(handle_response(resp)) async def destroy(self, id: str) -> None: resp = await self._http.delete(f"/v1/sandboxes/{id}") - _handle_response(resp) + handle_response(resp) class SnapshotsResource: @@ -287,18 +231,18 @@ class SnapshotsResource: if overwrite: params["overwrite"] = "true" resp = self._http.post("/v1/snapshots", json=payload, params=params) - return Template.model_validate(_handle_response(resp)) + return Template.model_validate(handle_response(resp)) def list(self, type: str | None = None) -> list[Template]: params: dict = {} if type is not None: params["type"] = type resp = self._http.get("/v1/snapshots", params=params) - return [Template.model_validate(item) for item in _handle_response(resp)] + return [Template.model_validate(item) for item in handle_response(resp)] def delete(self, name: str) -> None: resp = self._http.delete(f"/v1/snapshots/{name}") - _handle_response(resp) + handle_response(resp) class AsyncSnapshotsResource: @@ -320,18 +264,18 @@ class AsyncSnapshotsResource: if overwrite: params["overwrite"] = "true" resp = await self._http.post("/v1/snapshots", json=payload, params=params) - return Template.model_validate(_handle_response(resp)) + return Template.model_validate(handle_response(resp)) async def list(self, type: str | None = None) -> list[Template]: params: dict = {} if type is not None: params["type"] = type resp = await self._http.get("/v1/snapshots", params=params) - return [Template.model_validate(item) for item in _handle_response(resp)] + return [Template.model_validate(item) for item in handle_response(resp)] async def delete(self, name: str) -> None: resp = await self._http.delete(f"/v1/snapshots/{name}") - _handle_response(resp) + handle_response(resp) class HostsResource: @@ -355,35 +299,35 @@ class HostsResource: if availability_zone is not None: payload["availability_zone"] = availability_zone resp = self._http.post("/v1/hosts", json=payload) - return CreateHostResponse.model_validate(_handle_response(resp)) + return CreateHostResponse.model_validate(handle_response(resp)) def list(self) -> list[Host]: resp = self._http.get("/v1/hosts") - return [Host.model_validate(item) for item in _handle_response(resp)] + return [Host.model_validate(item) for item in handle_response(resp)] def get(self, id: str) -> Host: resp = self._http.get(f"/v1/hosts/{id}") - return Host.model_validate(_handle_response(resp)) + return Host.model_validate(handle_response(resp)) def delete(self, id: str) -> None: resp = self._http.delete(f"/v1/hosts/{id}") - _handle_response(resp) + handle_response(resp) def regenerate_token(self, id: str) -> CreateHostResponse: resp = self._http.post(f"/v1/hosts/{id}/token") - return CreateHostResponse.model_validate(_handle_response(resp)) + return CreateHostResponse.model_validate(handle_response(resp)) def list_tags(self, id: str) -> builtins.list[str]: resp = self._http.get(f"/v1/hosts/{id}/tags") - return cast(builtins.list[str], _handle_response(resp)) + return cast(builtins.list[str], handle_response(resp)) def add_tag(self, id: str, tag: str) -> None: resp = self._http.post(f"/v1/hosts/{id}/tags", json={"tag": tag}) - _handle_response(resp) + handle_response(resp) def remove_tag(self, id: str, tag: str) -> None: resp = self._http.delete(f"/v1/hosts/{id}/tags/{tag}") - _handle_response(resp) + handle_response(resp) class AsyncHostsResource: @@ -407,35 +351,35 @@ class AsyncHostsResource: if availability_zone is not None: payload["availability_zone"] = availability_zone resp = await self._http.post("/v1/hosts", json=payload) - return CreateHostResponse.model_validate(_handle_response(resp)) + return CreateHostResponse.model_validate(handle_response(resp)) async def list(self) -> list[Host]: resp = await self._http.get("/v1/hosts") - return [Host.model_validate(item) for item in _handle_response(resp)] + return [Host.model_validate(item) for item in handle_response(resp)] async def get(self, id: str) -> Host: resp = await self._http.get(f"/v1/hosts/{id}") - return Host.model_validate(_handle_response(resp)) + return Host.model_validate(handle_response(resp)) async def delete(self, id: str) -> None: resp = await self._http.delete(f"/v1/hosts/{id}") - _handle_response(resp) + handle_response(resp) async def regenerate_token(self, id: str) -> CreateHostResponse: resp = await self._http.post(f"/v1/hosts/{id}/token") - return CreateHostResponse.model_validate(_handle_response(resp)) + return CreateHostResponse.model_validate(handle_response(resp)) async def list_tags(self, id: str) -> builtins.list[str]: resp = await self._http.get(f"/v1/hosts/{id}/tags") - return cast(builtins.list[str], _handle_response(resp)) + return cast(builtins.list[str], handle_response(resp)) async def add_tag(self, id: str, tag: str) -> None: resp = await self._http.post(f"/v1/hosts/{id}/tags", json={"tag": tag}) - _handle_response(resp) + handle_response(resp) async def remove_tag(self, id: str, tag: str) -> None: resp = await self._http.delete(f"/v1/hosts/{id}/tags/{tag}") - _handle_response(resp) + handle_response(resp) class WrennClient: diff --git a/src/wrenn/exceptions.py b/src/wrenn/exceptions.py index 0a6b644..713aff7 100644 --- a/src/wrenn/exceptions.py +++ b/src/wrenn/exceptions.py @@ -1,5 +1,7 @@ from __future__ import annotations +import httpx + class WrennError(Exception): """Base exception for all Wrenn SDK errors.""" @@ -51,3 +53,51 @@ class WrennAgentError(WrennError): class WrennInternalError(WrennError): """500 — Unexpected server error.""" + + +_ERROR_MAP: dict[str, type[WrennError]] = { + "invalid_request": WrennValidationError, + "unauthorized": WrennAuthenticationError, + "forbidden": WrennForbiddenError, + "not_found": WrennNotFoundError, + "invalid_state": WrennConflictError, + "conflict": WrennConflictError, + "host_has_sandboxes": WrennHostHasSandboxesError, + "host_unavailable": WrennHostUnavailableError, + "agent_error": WrennAgentError, + "internal_error": WrennInternalError, +} + + +def handle_response(resp: httpx.Response) -> dict | list: + if resp.status_code >= 400: + try: + body = resp.json() + except Exception: + resp.raise_for_status() + raise + + err = body.get("error", {}) + code = err.get("code", "internal_error") + message = err.get("message", resp.text) + + exc_cls = _ERROR_MAP.get(code, WrennError) + + if exc_cls is WrennHostHasSandboxesError: + raise WrennHostHasSandboxesError( + code=code, + message=message, + status_code=resp.status_code, + sandbox_ids=body.get("sandbox_ids", []), + ) + + raise exc_cls( + code=code, + message=message, + status_code=resp.status_code, + ) + + if resp.status_code == 204: + return {} + + return resp.json() diff --git a/src/wrenn/models/__init__.py b/src/wrenn/models/__init__.py index bddfa94..7e51557 100644 --- a/src/wrenn/models/__init__.py +++ b/src/wrenn/models/__init__.py @@ -11,11 +11,17 @@ from wrenn.models._generated import ( Error1, ExecRequest, ExecResponse, + FileEntry, Host, + ListDirRequest, + ListDirResponse, LoginRequest, + MakeDirRequest, + MakeDirResponse, ReadFileRequest, RegisterHostRequest, RegisterHostResponse, + RemoveRequest, Sandbox, SignupRequest, Status, @@ -39,11 +45,17 @@ __all__ = [ "Error1", "ExecRequest", "ExecResponse", + "FileEntry", "Host", + "ListDirRequest", + "ListDirResponse", "LoginRequest", + "MakeDirRequest", + "MakeDirResponse", "ReadFileRequest", "RegisterHostRequest", "RegisterHostResponse", + "RemoveRequest", "Sandbox", "SignupRequest", "Status", diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py index ec70bef..a211a9b 100644 --- a/src/wrenn/models/_generated.py +++ b/src/wrenn/models/_generated.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-04-09T15:01:48+00:00 +# timestamp: 2026-04-11T15:00:55+00:00 from __future__ import annotations @@ -13,6 +13,7 @@ from pydantic import AwareDatetime, BaseModel, EmailStr, Field class SignupRequest(BaseModel): email: EmailStr password: Annotated[str, Field(min_length=8)] + name: Annotated[str, Field(max_length=100)] class LoginRequest(BaseModel): @@ -27,6 +28,7 @@ class AuthResponse(BaseModel): user_id: str | None = None team_id: str | None = None email: str | None = None + name: str | None = None class CreateAPIKeyRequest(BaseModel): @@ -62,11 +64,61 @@ class CreateSandboxRequest(BaseModel): ] = 0 +class Range(StrEnum): + field_5m = "5m" + field_1h = "1h" + field_6h = "6h" + field_24h = "24h" + field_30d = "30d" + + +class Current(BaseModel): + running_count: int | None = None + vcpus_reserved: int | None = None + memory_mb_reserved: int | None = None + sampled_at: AwareDatetime | None = None + + +class Peaks(BaseModel): + """ + Maximum values over the last 30 days. + """ + + running_count: int | None = None + vcpus: int | None = None + memory_mb: int | None = None + + +class Series(BaseModel): + """ + Parallel arrays for chart rendering. + """ + + labels: list[AwareDatetime] | None = None + running: list[int] | None = None + vcpus: list[int] | None = None + memory_mb: list[int] | None = None + + +class SandboxStats(BaseModel): + range: Range | None = None + current: Current | None = None + peaks: Annotated[ + Peaks | None, Field(description="Maximum values over the last 30 days.") + ] = None + series: Annotated[ + Series | None, Field(description="Parallel arrays for chart rendering.") + ] = None + + class Status(StrEnum): pending = "pending" + starting = "starting" running = "running" paused = "paused" + hibernated = "hibernated" stopped = "stopped" + missing = "missing" error = "error" @@ -143,7 +195,54 @@ class ReadFileRequest(BaseModel): path: Annotated[str, Field(description="Absolute file path inside the sandbox")] +class ListDirRequest(BaseModel): + path: Annotated[str, Field(description="Directory path inside the sandbox")] + depth: Annotated[ + int | None, + Field( + description="Recursion depth (0 = non-recursive, 1 = immediate children)" + ), + ] = 1 + + class Type1(StrEnum): + file = "file" + directory = "directory" + symlink = "symlink" + + +class FileEntry(BaseModel): + name: str | None = None + path: str | None = None + type: Type1 | None = None + size: int | None = None + mode: int | None = None + permissions: Annotated[ + str | None, Field(description='Human-readable permissions (e.g. "-rwxr-xr-x")') + ] = None + owner: str | None = None + group: str | None = None + modified_at: Annotated[ + int | None, Field(description="Unix timestamp (seconds)") + ] = None + symlink_target: str | None = None + + +class MakeDirRequest(BaseModel): + path: Annotated[ + str, Field(description="Directory path to create inside the sandbox") + ] + + +class MakeDirResponse(BaseModel): + entry: FileEntry | None = None + + +class RemoveRequest(BaseModel): + path: Annotated[str, Field(description="Path to remove inside the sandbox")] + + +class Type2(StrEnum): """ Host type. Regular hosts are shared; BYOC hosts belong to a team. """ @@ -154,7 +253,7 @@ class Type1(StrEnum): class CreateHostRequest(BaseModel): type: Annotated[ - Type1, + Type2, Field( description="Host type. Regular hosts are shared; BYOC hosts belong to a team." ), @@ -182,7 +281,7 @@ class RegisterHostRequest(BaseModel): address: Annotated[str, Field(description="Host agent address (ip:port).")] -class Type2(StrEnum): +class Type3(StrEnum): regular = "regular" byoc = "byoc" @@ -192,11 +291,12 @@ class Status1(StrEnum): online = "online" offline = "offline" draining = "draining" + unreachable = "unreachable" class Host(BaseModel): id: str | None = None - type: Type2 | None = None + type: Type3 | None = None team_id: str | None = None provider: str | None = None availability_zone: str | None = None @@ -212,17 +312,198 @@ class Host(BaseModel): updated_at: AwareDatetime | None = None +class RefreshHostTokenRequest(BaseModel): + refresh_token: Annotated[ + str, + Field( + description="Refresh token obtained from registration or a previous refresh." + ), + ] + + +class RefreshHostTokenResponse(BaseModel): + host: Host | None = None + token: Annotated[ + str | None, Field(description="New host JWT. Valid for 7 days.") + ] = None + refresh_token: Annotated[ + str | None, + Field( + description="New refresh token. Valid for 60 days; old token is revoked." + ), + ] = None + + +class HostDeletePreview(BaseModel): + host: Host | None = None + sandbox_ids: Annotated[ + list[str] | None, + Field(description="IDs of sandboxes that would be destroyed on force-delete."), + ] = None + + +class Error(BaseModel): + code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None + message: str | None = None + sandbox_ids: Annotated[ + list[str] | None, + Field(description="IDs of active sandboxes blocking deletion."), + ] = None + + +class HostHasSandboxesError(BaseModel): + error: Error | None = None + + class AddTagRequest(BaseModel): tag: str -class Error1(BaseModel): +class UserSearchResult(BaseModel): + user_id: str | None = None + email: str | None = None + + +class Team(BaseModel): + id: str | None = None + name: str | None = None + slug: Annotated[ + str | None, Field(description="Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)") + ] = None + created_at: AwareDatetime | None = None + + +class Role(StrEnum): + owner = "owner" + admin = "admin" + member = "member" + + +class TeamWithRole(Team): + role: Role | None = None + + +class TeamMember(BaseModel): + user_id: str | None = None + email: str | None = None + role: Role | None = None + joined_at: AwareDatetime | None = None + + +class TeamDetail(BaseModel): + team: Team | None = None + members: list[TeamMember] | None = None + + +class Range1(StrEnum): + field_5m = "5m" + field_10m = "10m" + field_1h = "1h" + field_2h = "2h" + field_6h = "6h" + field_12h = "12h" + field_24h = "24h" + + +class MetricPoint(BaseModel): + timestamp_unix: int | None = None + cpu_pct: Annotated[ + float | None, + Field( + description="CPU utilization percentage (0-100), normalized to vCPU count" + ), + ] = None + mem_bytes: Annotated[ + int | None, + Field(description="Resident memory in bytes (VmRSS of Firecracker process)"), + ] = None + disk_bytes: Annotated[ + int | None, Field(description="Allocated disk bytes for the CoW sparse file") + ] = None + + +class Provider(StrEnum): + discord = "discord" + slack = "slack" + teams = "teams" + googlechat = "googlechat" + telegram = "telegram" + matrix = "matrix" + webhook = "webhook" + + +class Event(StrEnum): + capsule_created = "capsule.created" + capsule_running = "capsule.running" + capsule_paused = "capsule.paused" + capsule_destroyed = "capsule.destroyed" + template_snapshot_created = "template.snapshot.created" + template_snapshot_deleted = "template.snapshot.deleted" + host_up = "host.up" + host_down = "host.down" + + +class CreateChannelRequest(BaseModel): + name: Annotated[str, Field(description="Unique channel name within the team.")] + provider: Provider + config: Annotated[ + dict[str, str], + Field( + description='Provider-specific configuration fields. Discord/Slack/Teams/Google Chat: {"webhook_url": "..."}. Telegram: {"bot_token": "...", "chat_id": "..."}. Matrix: {"homeserver_url": "...", "access_token": "...", "room_id": "..."}. Webhook: {"url": "...", "secret": "..."} (secret is auto-generated if omitted).\n' + ), + ] + events: list[Event] + + +class TestChannelRequest(BaseModel): + provider: Provider + config: Annotated[ + dict[str, str], + Field( + description="Provider-specific configuration fields (same as CreateChannelRequest.config)." + ), + ] + + +class RotateConfigRequest(BaseModel): + config: Annotated[ + dict[str, str], + Field( + description="New provider configuration fields. Must include all required fields for the channel's provider. Replaces the existing config entirely.\n" + ), + ] + + +class UpdateChannelRequest(BaseModel): + name: str + events: list[Event] + + +class ChannelResponse(BaseModel): + id: str | None = None + team_id: str | None = None + name: str | None = None + provider: Provider | None = None + events: list[str] | None = None + created_at: AwareDatetime | None = None + updated_at: AwareDatetime | None = None + secret: Annotated[ + str | None, + Field(description="Webhook secret. Only returned on creation, never again."), + ] = None + + +class Error2(BaseModel): code: str | None = None message: str | None = None -class Error(BaseModel): - error: Error1 | None = None +class Error1(BaseModel): + error: Error2 | None = None + + +class ListDirResponse(BaseModel): + entries: list[FileEntry] | None = None class CreateHostResponse(BaseModel): @@ -238,8 +519,18 @@ class CreateHostResponse(BaseModel): class RegisterHostResponse(BaseModel): host: Host | None = None token: Annotated[ + str | None, + Field(description="Host JWT for X-Host-Token header. Valid for 7 days."), + ] = None + refresh_token: Annotated[ str | None, Field( - description="Long-lived host JWT for X-Host-Token header. Valid for 1 year." + description="Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use." ), ] = None + + +class SandboxMetrics(BaseModel): + sandbox_id: str | None = None + range: Range1 | None = None + points: list[MetricPoint] | None = None diff --git a/src/wrenn/pty.py b/src/wrenn/pty.py new file mode 100644 index 0000000..cde476c --- /dev/null +++ b/src/wrenn/pty.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import base64 +import json +from collections.abc import AsyncIterator, Iterator +from enum import StrEnum +from typing import Any + +import httpx_ws +from pydantic import BaseModel + + +class PtyEventType(StrEnum): + started = "started" + output = "output" + exit = "exit" + error = "error" + ping = "ping" + + +class PtyEvent(BaseModel): + type: PtyEventType + pid: int | None = None + tag: str | None = None + data: bytes | str | None = None + exit_code: int | None = None + fatal: bool | None = None + + +def _parse_pty_event(raw: dict[str, Any]) -> PtyEvent: + msg_type = raw.get("type", "") + if msg_type == "started": + return PtyEvent( + type=PtyEventType.started, + pid=raw.get("pid"), + tag=raw.get("tag"), + ) + if msg_type == "output": + raw_data = raw.get("data", "") + decoded = base64.b64decode(raw_data) if raw_data else b"" + return PtyEvent(type=PtyEventType.output, data=decoded) + if msg_type == "exit": + return PtyEvent(type=PtyEventType.exit, exit_code=raw.get("exit_code", -1)) + if msg_type == "error": + return PtyEvent( + type=PtyEventType.error, + data=raw.get("data", ""), + fatal=raw.get("fatal", False), + ) + if msg_type == "ping": + return PtyEvent(type=PtyEventType.ping) + return PtyEvent(type=PtyEventType(msg_type) if msg_type else PtyEventType.ping) + + +class PtySession: + """Interactive PTY session backed by a WebSocket. + + Use as a context manager and iterate over events:: + + with sb.pty(cmd="/bin/bash") as term: + term.write(b"ls -la\\n") + for event in term: + if event.type == "output": + sys.stdout.buffer.write(event.data) + elif event.type == "exit": + break + """ + + def __init__(self, ws: httpx_ws.WebSocketSession, sandbox_id: str) -> None: + self._ws = ws + self._sandbox_id = sandbox_id + self._tag: str | None = None + self._pid: int | None = None + self._done = False + + @property + def tag(self) -> str | None: + """Session tag. Available after the ``started`` event.""" + return self._tag + + @property + def pid(self) -> int | None: + """Process PID. Available after the ``started`` event.""" + return self._pid + + def _send_start( + self, + cmd: str = "/bin/bash", + args: list[str] | None = None, + cols: int = 80, + rows: int = 24, + envs: dict[str, str] | None = None, + cwd: str | None = None, + ) -> None: + msg: dict[str, Any] = { + "type": "start", + "cmd": cmd, + "cols": cols or 80, + "rows": rows or 24, + } + if args: + msg["args"] = args + if envs: + msg["envs"] = envs + if cwd: + msg["cwd"] = cwd + self._ws.send_text(json.dumps(msg)) + + def _send_connect(self, tag: str) -> None: + self._ws.send_text(json.dumps({"type": "connect", "tag": tag})) + + def write(self, data: bytes) -> None: + """Send raw bytes to the PTY stdin. + + Args: + data: Raw bytes to send. Base64-encoded internally. + """ + encoded = base64.b64encode(data).decode("ascii") + self._ws.send_text(json.dumps({"type": "input", "data": encoded})) + + def resize(self, cols: int, rows: int) -> None: + """Resize the PTY terminal. + + Args: + cols: New column count. Must be > 0. + rows: New row count. Must be > 0. + + Raises: + ValueError: If cols or rows is 0. + """ + if cols <= 0 or rows <= 0: + raise ValueError("cols and rows must be greater than 0") + self._ws.send_text(json.dumps({"type": "resize", "cols": cols, "rows": rows})) + + def kill(self) -> None: + """Send SIGKILL to the PTY process.""" + self._ws.send_text(json.dumps({"type": "kill"})) + + def __iter__(self) -> Iterator[PtyEvent]: + return self + + def __next__(self) -> PtyEvent: + if self._done: + raise StopIteration + try: + raw = self._ws.receive_text() + except httpx_ws.WebSocketDisconnect: + raise StopIteration + event = _parse_pty_event(json.loads(raw)) + if event.type == PtyEventType.started: + if event.tag is not None: + self._tag = event.tag + if event.pid is not None: + self._pid = event.pid + if event.type == PtyEventType.exit: + raise StopIteration + if event.type == PtyEventType.error and event.fatal: + self._done = True + return event + return event + + def __enter__(self) -> PtySession: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object, + ) -> None: + try: + self.kill() + except Exception: + pass + try: + self._ws.close() + except Exception: + pass + + +class AsyncPtySession: + """Async interactive PTY session backed by a WebSocket. + + Use as an async context manager and async iterate over events:: + + async with sb.pty(cmd="/bin/bash") as term: + await term.write(b"ls -la\\n") + async for event in term: + if event.type == "output": + sys.stdout.buffer.write(event.data) + elif event.type == "exit": + break + """ + + def __init__(self, ws: httpx_ws.AsyncWebSocketSession, sandbox_id: str) -> None: + self._ws = ws + self._sandbox_id = sandbox_id + self._tag: str | None = None + self._pid: int | None = None + self._done = False + + @property + def tag(self) -> str | None: + """Session tag. Available after the ``started`` event.""" + return self._tag + + @property + def pid(self) -> int | None: + """Process PID. Available after the ``started`` event.""" + return self._pid + + async def _send_start( + self, + cmd: str = "/bin/bash", + args: list[str] | None = None, + cols: int = 80, + rows: int = 24, + envs: dict[str, str] | None = None, + cwd: str | None = None, + ) -> None: + msg: dict[str, Any] = { + "type": "start", + "cmd": cmd, + "cols": cols or 80, + "rows": rows or 24, + } + if args: + msg["args"] = args + if envs: + msg["envs"] = envs + if cwd: + msg["cwd"] = cwd + await self._ws.send_text(json.dumps(msg)) + + async def _send_connect(self, tag: str) -> None: + await self._ws.send_text(json.dumps({"type": "connect", "tag": tag})) + + async def write(self, data: bytes) -> None: + """Send raw bytes to the PTY stdin. + + Args: + data: Raw bytes to send. Base64-encoded internally. + """ + encoded = base64.b64encode(data).decode("ascii") + await self._ws.send_text(json.dumps({"type": "input", "data": encoded})) + + async def resize(self, cols: int, rows: int) -> None: + """Resize the PTY terminal. + + Args: + cols: New column count. Must be > 0. + rows: New row count. Must be > 0. + + Raises: + ValueError: If cols or rows is 0. + """ + if cols <= 0 or rows <= 0: + raise ValueError("cols and rows must be greater than 0") + await self._ws.send_text( + json.dumps({"type": "resize", "cols": cols, "rows": rows}) + ) + + async def kill(self) -> None: + """Send SIGKILL to the PTY process.""" + await self._ws.send_text(json.dumps({"type": "kill"})) + + def __aiter__(self) -> AsyncIterator[PtyEvent]: + return self + + async def __anext__(self) -> PtyEvent: + if self._done: + raise StopAsyncIteration + try: + raw = await self._ws.receive_text() + except httpx_ws.WebSocketDisconnect: + raise StopAsyncIteration + event = _parse_pty_event(json.loads(raw)) + if event.type == PtyEventType.started: + if event.tag is not None: + self._tag = event.tag + if event.pid is not None: + self._pid = event.pid + if event.type == PtyEventType.exit: + raise StopAsyncIteration + if event.type == PtyEventType.error and event.fatal: + self._done = True + return event + return event + + async def __aenter__(self) -> AsyncPtySession: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object, + ) -> None: + try: + await self.kill() + except Exception: + pass + try: + await self._ws.close() + except Exception: + pass diff --git a/src/wrenn/sandbox.py b/src/wrenn/sandbox.py index ac9b237..09b40de 100644 --- a/src/wrenn/sandbox.py +++ b/src/wrenn/sandbox.py @@ -3,17 +3,55 @@ from __future__ import annotations import asyncio import base64 import json +import os import time import uuid from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager, contextmanager from typing import Any import httpx import httpx_ws -from wrenn.exceptions import WrennAuthenticationError -from wrenn.models import ExecResponse, Status +from wrenn.exceptions import handle_response +from wrenn.models import ( + ExecResponse, + FileEntry, + ListDirResponse, + MakeDirResponse, + Status, +) from wrenn.models import Sandbox as SandboxModel +from wrenn.pty import AsyncPtySession, PtySession + + +class _IterableReader: + """Internal adapter to make iterables/generators act like files with a . + read() method""" + + def __init__(self, iterable: Any) -> None: + self.iterator = iter(iterable) + self.buffer = b"" + + def read(self, size: int = -1) -> bytes: + if size == -1: + return self.buffer + b"".join( + chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") + for chunk in self.iterator + ) + + while len(self.buffer) < size: + try: + chunk = next(self.iterator) + self.buffer += ( + chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") + ) + except StopIteration: + break + + result = self.buffer[:size] + self.buffer = self.buffer[size:] + return result class ExecResult: @@ -187,14 +225,13 @@ class Sandbox(SandboxModel): self._http = None # type: ignore[assignment] self._async_http = http - def _require_api_key(self) -> str: - if not self._api_key: - raise WrennAuthenticationError( - code="unauthorized", - message="Proxy requires an API key. JWT-only clients cannot use proxy routes.", - status_code=401, - ) - return self._api_key + def _proxy_headers(self) -> dict[str, str]: + headers: dict[str, str] = {} + if self._api_key: + headers["X-API-Key"] = self._api_key + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + return headers def _clear_content_type(self) -> dict[str, str]: assert self._http is not None @@ -216,24 +253,16 @@ class Sandbox(SandboxModel): Returns: A URL string like ``http://8888-cl-abc123.api.wrenn.dev``. - - Raises: - WrennAuthenticationError: If the client was constructed with JWT only. """ - self._require_api_key() return _build_proxy_url(self._base_url, self.id, port) @property def http_client(self) -> httpx.Client: """A pre-configured ``httpx.Client`` targeting the sandbox proxy on port 8888. - The client has the ``X-API-Key`` header set and ``base_url`` pointing to + The client has auth headers set and ``base_url`` pointing to the proxy URL for port 8888. Closed automatically when the sandbox exits. - - Raises: - WrennAuthenticationError: If the client was constructed with JWT only. """ - self._require_api_key() if self._proxy_client is None: url = ( _build_proxy_url(self._base_url, self.id, 8888) @@ -242,7 +271,7 @@ class Sandbox(SandboxModel): ) self._proxy_client = httpx.Client( base_url=url, - headers={"X-API-Key": self._api_key}, # type: ignore[dict-item, arg-type] + headers=self._proxy_headers(), ) return self._proxy_client @@ -377,7 +406,7 @@ class Sandbox(SandboxModel): ``StreamExitEvent``, or ``StreamErrorEvent``. """ assert self._http is not None - with httpx_ws.ws_connect( # type: ignore[attr-defined] + with httpx_ws.connect_ws( # type: ignore[attr-defined] f"/v1/sandboxes/{self.id}/exec/stream", self._http, ) as ws: @@ -423,33 +452,22 @@ class Sandbox(SandboxModel): data: File contents as bytes. """ assert self._http is not None - original_ct = self._http.headers.pop("Content-Type", None) - try: - resp = self._http.post( - f"/v1/sandboxes/{self.id}/files/write", - files={"file": ("upload", data)}, - data={"path": path}, - ) - finally: - if original_ct is not None: - self._http.headers["content-type"] = original_ct + resp = self._http.post( + f"/v1/sandboxes/{self.id}/files/write", + files={"file": ("upload", data)}, + data={"path": path}, + ) resp.raise_for_status() async def async_upload(self, path: str, data: bytes) -> None: """Async version of ``upload``.""" assert self._async_http is not None - original_ct = self._async_http.headers.pop("Content-Type", None) - try: - resp = await self._async_http.post( - f"/v1/sandboxes/{self.id}/files/write", - files={"file": ("upload", data)}, - data={"path": path}, - ) - finally: - if original_ct is not None: - self._async_http.headers["Content-Type"] = original_ct - + resp = await self._async_http.post( + f"/v1/sandboxes/{self.id}/files/write", + files={"file": ("upload", data)}, + data={"path": path}, + ) resp.raise_for_status() def download(self, path: str) -> bytes: @@ -488,20 +506,31 @@ class Sandbox(SandboxModel): """ assert self._http is not None - def _gen() -> Iterator[bytes]: - yield from stream + boundary = os.urandom(16).hex().encode("utf-8") - original_ct = self._http.headers.pop("Content-Type", None) - try: - resp = self._http.post( - f"/v1/sandboxes/{self.id}/files/stream/write", - files={"file": ("upload", _gen())}, # type: ignore[dict-item] - data={"path": path}, - ) - finally: - if original_ct is not None: - self._http.headers["Content-Type"] = original_ct + def _multipart_stream() -> Iterator[bytes]: + yield b"--" + boundary + b"\r\n" + yield b'Content-Disposition: form-data; name="path"\r\n\r\n' + yield path.encode("utf-8") + b"\r\n" + yield b"--" + boundary + b"\r\n" + yield b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n' + yield b"Content-Type: application/octet-stream\r\n\r\n" + + for chunk in stream: + yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") + + yield b"\r\n--" + boundary + b"--\r\n" + + headers = { + "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}" + } + + resp = self._http.post( + f"/v1/sandboxes/{self.id}/files/stream/write", + content=_multipart_stream(), + headers=headers, + ) resp.raise_for_status() async def async_stream_upload( @@ -510,21 +539,32 @@ class Sandbox(SandboxModel): """Async version of ``stream_upload``.""" assert self._async_http is not None - async def _gen() -> AsyncIterator[bytes]: + boundary = os.urandom(16).hex().encode("utf-8") + + async def _async_multipart_stream() -> AsyncIterator[bytes]: + yield b"--" + boundary + b"\r\n" + yield b'Content-Disposition: form-data; name="path"\r\n\r\n' + yield path.encode("utf-8") + b"\r\n" + + yield b"--" + boundary + b"\r\n" + yield b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n' + yield b"Content-Type: application/octet-stream\r\n\r\n" + async for chunk in stream: - yield chunk + yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") - original_ct = self._async_http.headers.pop("Content-Type", None) - try: - resp = await self._async_http.post( - f"/v1/sandboxes/{self.id}/files/stream/write", - files={"file": ("upload", _gen())}, # type: ignore[dict-item] - data={"path": path}, - ) - finally: - if original_ct is not None: - self._async_http.headers["Content-Type"] = original_ct + yield b"\r\n--" + boundary + b"--\r\n" + headers = { + "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}" + } + + # Use content= and headers= just like the sync version + resp = await self._async_http.post( + f"/v1/sandboxes/{self.id}/files/stream/write", + content=_async_multipart_stream(), + headers=headers, + ) resp.raise_for_status() def stream_download(self, path: str) -> Iterator[bytes]: @@ -557,6 +597,229 @@ class Sandbox(SandboxModel): async for chunk in resp.aiter_bytes(): yield chunk + def list_dir(self, path: str, depth: int = 1) -> list[FileEntry]: + """List directory contents inside the sandbox. + + Args: + path: Absolute directory path. + depth: Recursion depth. 1 = immediate children only. + + Returns: + List of FileEntry objects with full metadata. + + Raises: + WrennValidationError: Invalid path. + WrennNotFoundError: Sandbox or directory not found. + WrennConflictError: Sandbox is not running. + WrennAgentError: Agent error. + WrennHostUnavailableError: Host agent not reachable. + """ + assert self._http is not None + resp = self._http.post( + f"/v1/sandboxes/{self.id}/files/list", + json={"path": path, "depth": depth}, + ) + data = handle_response(resp) + parsed = ListDirResponse.model_validate(data) + return parsed.entries or [] + + async def async_list_dir(self, path: str, depth: int = 1) -> list[FileEntry]: + """Async version of ``list_dir``.""" + assert self._async_http is not None + resp = await self._async_http.post( + f"/v1/sandboxes/{self.id}/files/list", + json={"path": path, "depth": depth}, + ) + data = handle_response(resp) + parsed = ListDirResponse.model_validate(data) + return parsed.entries or [] + + def mkdir(self, path: str) -> FileEntry: + """Create a directory inside the sandbox (with parents). + + Args: + path: Absolute directory path to create. + + Returns: + FileEntry for the created directory. + + Raises: + WrennValidationError: Path exists and is not a directory. + WrennConflictError: Directory already exists (returns existing entry). + Sandbox is not running. + WrennNotFoundError: Sandbox not found. + WrennAgentError: Agent error. + WrennHostUnavailableError: Host agent not reachable. + """ + assert self._http is not None + resp = self._http.post( + f"/v1/sandboxes/{self.id}/files/mkdir", + json={"path": path}, + ) + if resp.status_code == 409: + try: + body = resp.json() + err = body.get("error", {}) + if err.get("code") == "conflict": + parent_dir = os.path.dirname(path) + dir_name = os.path.basename(path) + + listing = self.list_dir(parent_dir, depth=0) + for entry in listing: + if entry.name == dir_name: + return entry + except Exception: + pass + data = handle_response(resp) + parsed = MakeDirResponse.model_validate(data) + entry = parsed.entry + if entry is None: + raise RuntimeError("mkdir response missing entry") + return entry + + async def async_mkdir(self, path: str) -> FileEntry: + """Async version of ``mkdir``.""" + assert self._async_http is not None + resp = await self._async_http.post( + f"/v1/sandboxes/{self.id}/files/mkdir", + json={"path": path}, + ) + if resp.status_code == 409: + try: + body = resp.json() + err = body.get("error", {}) + if err.get("code") == "conflict": + listing = await self.async_list_dir(path, depth=0) + parent_dir = os.path.dirname(path) + dir_name = os.path.basename(path) + + listing = self.list_dir(parent_dir, depth=0) + for entry in listing: + if entry.name == dir_name: + return entry + except Exception: + pass + data = handle_response(resp) + parsed = MakeDirResponse.model_validate(data) + entry = parsed.entry + if entry is None: + raise RuntimeError("mkdir response missing entry") + return entry + + def remove(self, path: str) -> None: + """Remove a file or directory inside the sandbox. + + Removes recursively. No confirmation or dry-run. Equivalent to rm -rf. + + Args: + path: Absolute path to remove. + + Raises: + WrennValidationError: Invalid path. + WrennNotFoundError: Sandbox not found. + WrennConflictError: Sandbox is not running. + WrennAgentError: Agent error. + WrennHostUnavailableError: Host agent not reachable. + """ + assert self._http is not None + resp = self._http.post( + f"/v1/sandboxes/{self.id}/files/remove", + json={"path": path}, + ) + handle_response(resp) + + async def async_remove(self, path: str) -> None: + """Async version of ``remove``.""" + assert self._async_http is not None + resp = await self._async_http.post( + f"/v1/sandboxes/{self.id}/files/remove", + json={"path": path}, + ) + handle_response(resp) + + @contextmanager + def pty( + self, + cmd: str = "/bin/bash", + args: list[str] | None = None, + cols: int = 80, + rows: int = 24, + envs: dict[str, str] | None = None, + cwd: str | None = None, + ) -> PtySession: + """Open an interactive PTY session. + + Args: + cmd: Command to run. Defaults to /bin/bash. + args: Command arguments. + cols: Terminal columns. Defaults to 80. + rows: Terminal rows. Defaults to 24. + envs: Environment variables. + cwd: Working directory. + + Returns: + A PtySession context manager. Use with a ``with`` statement. + """ + assert self._http is not None + with httpx_ws.connect_ws( + f"/v1/sandboxes/{self.id}/pty", client=self._http + ) as ws: + session = PtySession(ws, self.id) + session._send_start( + cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd + ) + yield session + + @contextmanager + def pty_connect(self, tag: str) -> PtySession: + """Reconnect to an existing PTY session. + + Args: + tag: Session tag from a previous PtySession. + + Returns: + A PtySession context manager. + """ + assert self._http is not None + with httpx_ws.connect_ws( + f"/v1/sandboxes/{self.id}/pty", client=self._http + ) as ws: + session = PtySession(ws, self.id) + session._send_connect(tag) + yield session + + @asynccontextmanager + async def async_pty( + self, + cmd: str = "/bin/bash", + args: list[str] | None = None, + cols: int = 80, + rows: int = 24, + envs: dict[str, str] | None = None, + cwd: str | None = None, + ) -> AsyncPtySession: + """Async version of ``pty``.""" + assert self._async_http is not None + with await httpx_ws.aconnect_ws( + f"/v1/sandboxes/{self.id}/pty", client=self._http + ) as ws: + session = AsyncPtySession(ws, self.id) + await session._send_start( + cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd + ) + yield session + + @asynccontextmanager + async def async_pty_connect(self, tag: str) -> AsyncPtySession: + """Async version of ``pty_connect``.""" + assert self._async_http is not None + with await httpx_ws.aconnect_ws( + f"/v1/sandboxes/{self.id}/pty", client=self._http + ) as ws: + session = AsyncPtySession(ws, self.id) + await session._send_connect(tag) + yield session + def ping(self) -> None: """Reset the sandbox inactivity timer.""" assert self._http is not None @@ -657,7 +920,7 @@ class Sandbox(SandboxModel): request=resp.request, response=resp, ) - except (httpx.HTTPStatusError, WrennAuthenticationError): + except httpx.HTTPStatusError: raise except Exception as exc: last_exc = exc @@ -674,7 +937,6 @@ class Sandbox(SandboxModel): if current_kernel is not None: return current_kernel - self._require_api_key() if self._async_proxy_client is None: url = ( _build_proxy_url(self._base_url, self.id, 8888) @@ -683,7 +945,7 @@ class Sandbox(SandboxModel): ) self._async_proxy_client = httpx.AsyncClient( base_url=url, - headers={"X-API-Key": self._api_key}, # type: ignore[dict-item, arg-type] + headers=self._proxy_headers(), ) deadline = time.monotonic() + jupyter_timeout @@ -760,14 +1022,10 @@ class Sandbox(SandboxModel): Returns: A ``CodeResult`` with ``.text``, ``.data``, ``.stdout``, ``.stderr``, ``.error``. - - Raises: - WrennAuthenticationError: If the client was constructed with JWT only. """ assert self._http is not None kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout) ws_url = self._jupyter_ws_url(kernel_id) - api_key = self._require_api_key() msg = self._jupyter_execute_request(code) msg_id = msg["msg_id"] @@ -775,9 +1033,7 @@ class Sandbox(SandboxModel): result = CodeResult() deadline = time.monotonic() + timeout - headers = {"X-API-Key": api_key} - if self._token: - headers["Authorization"] = f"Bearer {self._token}" + headers = self._proxy_headers() with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: ignore[attr-defined, var-annotated] ws.send_text(json.dumps(msg)) @@ -828,7 +1084,6 @@ class Sandbox(SandboxModel): assert self._async_http is not None kernel_id = await self._async_ensure_kernel(jupyter_timeout=jupyter_timeout) ws_url = self._jupyter_ws_url(kernel_id) - api_key = self._require_api_key() msg = self._jupyter_execute_request(code) msg_id = msg["msg_id"] @@ -836,9 +1091,7 @@ class Sandbox(SandboxModel): result = CodeResult() deadline = time.monotonic() + timeout - headers = {"X-API-Key": api_key} - if self._token: - headers["Authorization"] = f"Bearer {self._token}" + headers = self._proxy_headers() async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: ignore[attr-defined, var-annotated] await ws.send_text(json.dumps(msg)) diff --git a/tests/test_filesystem_pty.py b/tests/test_filesystem_pty.py new file mode 100644 index 0000000..983daa6 --- /dev/null +++ b/tests/test_filesystem_pty.py @@ -0,0 +1,506 @@ +from __future__ import annotations + +import base64 +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +import respx + +from wrenn.client import WrennClient +from wrenn.models import FileEntry +from wrenn.pty import ( + AsyncPtySession, + PtyEventType, + PtySession, + _parse_pty_event, +) +from wrenn.sandbox import Sandbox + + +@pytest.fixture +def client(): + with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: + yield c + + +def _make_sandbox(client: WrennClient, sb_id: str = "cl-abc") -> Sandbox: + respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + 201, json={"id": sb_id, "status": "running"} + ) + return client.sandboxes.create() + + +class TestListDir: + @respx.mock + def test_list_dir_returns_entries(self, client): + sb = _make_sandbox(client) + respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list").respond( + 200, + json={ + "entries": [ + { + "name": "main.py", + "path": "/home/user/main.py", + "type": "file", + "size": 1024, + "mode": 33188, + "permissions": "-rw-r--r--", + "owner": "root", + "group": "root", + "modified_at": 1712899200, + "symlink_target": None, + }, + { + "name": "config", + "path": "/home/user/config", + "type": "directory", + "size": 4096, + "mode": 16877, + "permissions": "drwxr-xr-x", + "owner": "root", + "group": "root", + "modified_at": 1712899100, + "symlink_target": None, + }, + ] + }, + ) + entries = sb.list_dir("/home/user") + assert len(entries) == 2 + assert isinstance(entries[0], FileEntry) + assert entries[0].name == "main.py" + assert entries[0].type == "file" + assert entries[1].name == "config" + assert entries[1].type == "directory" + + @respx.mock + def test_list_dir_with_depth(self, client): + sb = _make_sandbox(client) + route = respx.post( + "https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list" + ).respond(200, json={"entries": []}) + sb.list_dir("/home/user", depth=3) + body = json.loads(route.calls[0].request.content) + assert body["depth"] == 3 + + @respx.mock + def test_list_dir_empty(self, client): + sb = _make_sandbox(client) + respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list").respond( + 200, json={"entries": []} + ) + entries = sb.list_dir("/empty") + assert entries == [] + + @respx.mock + def test_list_dir_symlink(self, client): + sb = _make_sandbox(client) + respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list").respond( + 200, + json={ + "entries": [ + { + "name": "link", + "path": "/home/user/link", + "type": "symlink", + "size": 4, + "mode": 41471, + "permissions": "lrwxrwxrwx", + "owner": "root", + "group": "root", + "modified_at": 1712899000, + "symlink_target": "/bin", + } + ] + }, + ) + entries = sb.list_dir("/home/user") + assert len(entries) == 1 + assert entries[0].type == "symlink" + assert entries[0].symlink_target == "/bin" + + +class TestMkdir: + @respx.mock + def test_mkdir_returns_entry(self, client): + sb = _make_sandbox(client) + respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/mkdir").respond( + 200, + json={ + "entry": { + "name": "data", + "path": "/home/user/data", + "type": "directory", + "size": 4096, + "mode": 16877, + "permissions": "drwxr-xr-x", + "owner": "root", + "group": "root", + "modified_at": 1712899200, + "symlink_target": None, + } + }, + ) + entry = sb.mkdir("/home/user/data") + assert isinstance(entry, FileEntry) + assert entry.name == "data" + assert entry.type == "directory" + + @respx.mock + def test_mkdir_existing_returns_gracefully(self, client): + sb = _make_sandbox(client) + respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/mkdir").respond( + 409, + json={"error": {"code": "conflict", "message": "already exists"}}, + ) + respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list").respond( + 200, + json={ + "entries": [ + { + "name": "data", + "path": "/home/user/data", + "type": "directory", + "size": 4096, + "mode": 16877, + "permissions": "drwxr-xr-x", + "owner": "root", + "group": "root", + "modified_at": 1712899200, + "symlink_target": None, + } + ] + }, + ) + entry = sb.mkdir("/home/user/data") + assert entry.name == "data" + + +class TestRemove: + @respx.mock + def test_remove_succeeds(self, client): + sb = _make_sandbox(client) + route = respx.post( + "https://api.wrenn.dev/v1/sandboxes/cl-abc/files/remove" + ).respond(204) + sb.remove("/home/user/old_data") + assert route.called + + @respx.mock + def test_remove_sends_path(self, client): + sb = _make_sandbox(client) + route = respx.post( + "https://api.wrenn.dev/v1/sandboxes/cl-abc/files/remove" + ).respond(204) + sb.remove("/tmp/test.txt") + body = json.loads(route.calls[0].request.content) + assert body["path"] == "/tmp/test.txt" + + +class TestUpload: + @respx.mock + def test_upload_sends_multipart(self, client): + sb = _make_sandbox(client) + route = respx.post( + "https://api.wrenn.dev/v1/sandboxes/cl-abc/files/write" + ).respond(204) + sb.upload("/app/main.py", b"print('hello')") + assert route.called + req = route.calls[0].request + assert b"multipart/form-data" in req.headers.get("content-type", "").encode() + + @respx.mock + def test_download_returns_bytes(self, client): + sb = _make_sandbox(client) + content = b"file contents here" + respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/read").respond( + 200, content=content + ) + data = sb.download("/app/main.py") + assert data == content + + +class TestPtyEventParsing: + def test_started_event(self): + raw = {"type": "started", "tag": "pty-a1b2c3d4", "pid": 42} + event = _parse_pty_event(raw) + assert event.type == PtyEventType.started + assert event.pid == 42 + assert event.tag == "pty-a1b2c3d4" + + def test_output_event_base64(self): + encoded = base64.b64encode(b"ls -la\n").decode() + raw = {"type": "output", "data": encoded} + event = _parse_pty_event(raw) + assert event.type == PtyEventType.output + assert event.data == b"ls -la\n" + + def test_output_event_empty(self): + raw = {"type": "output", "data": ""} + event = _parse_pty_event(raw) + assert event.data == b"" + + def test_exit_event(self): + raw = {"type": "exit", "exit_code": 0} + event = _parse_pty_event(raw) + assert event.type == PtyEventType.exit + assert event.exit_code == 0 + + def test_error_event(self): + raw = {"type": "error", "data": "process not found", "fatal": True} + event = _parse_pty_event(raw) + assert event.type == PtyEventType.error + assert event.data == "process not found" + assert event.fatal is True + + def test_error_event_non_fatal(self): + raw = {"type": "error", "data": "something", "fatal": False} + event = _parse_pty_event(raw) + assert event.fatal is False + + def test_ping_event(self): + raw = {"type": "ping"} + event = _parse_pty_event(raw) + assert event.type == PtyEventType.ping + + +class TestPtySessionWrite: + def test_write_sends_base64_input(self): + ws = MagicMock() + session = PtySession(ws, "cl-abc") + session.write(b"ls -la\n") + sent = json.loads(ws.send_text.call_args[0][0]) + assert sent["type"] == "input" + assert base64.b64decode(sent["data"]) == b"ls -la\n" + + +class TestPtySessionResize: + def test_resize_sends_dimensions(self): + ws = MagicMock() + session = PtySession(ws, "cl-abc") + session.resize(120, 40) + sent = json.loads(ws.send_text.call_args[0][0]) + assert sent["type"] == "resize" + assert sent["cols"] == 120 + assert sent["rows"] == 40 + + def test_resize_zero_raises(self): + ws = MagicMock() + session = PtySession(ws, "cl-abc") + with pytest.raises(ValueError, match="greater than 0"): + session.resize(0, 40) + with pytest.raises(ValueError, match="greater than 0"): + session.resize(80, 0) + + +class TestPtySessionKill: + def test_kill_sends_message(self): + ws = MagicMock() + session = PtySession(ws, "cl-abc") + session.kill() + sent = json.loads(ws.send_text.call_args[0][0]) + assert sent["type"] == "kill" + + +class TestPtySessionIteration: + def test_iter_yields_events_until_exit(self): + ws = MagicMock() + messages = [ + json.dumps({"type": "started", "tag": "pty-abc12345", "pid": 1}), + json.dumps({"type": "output", "data": base64.b64encode(b"hello").decode()}), + json.dumps({"type": "exit", "exit_code": 0}), + ] + ws.receive_text.side_effect = messages + session = PtySession(ws, "cl-abc") + events = list(session) + assert len(events) == 2 + 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" + + def test_iter_stops_on_fatal_error(self): + ws = MagicMock() + messages = [ + json.dumps({"type": "error", "data": "fatal", "fatal": True}), + ] + ws.receive_text.side_effect = messages + session = PtySession(ws, "cl-abc") + events = list(session) + assert len(events) == 1 + assert events[0].type == PtyEventType.error + + def test_iter_stops_on_disconnect(self): + import httpx_ws + + ws = MagicMock() + ws.receive_text.side_effect = httpx_ws.WebSocketDisconnect() + session = PtySession(ws, "cl-abc") + events = list(session) + assert events == [] + + +class TestPtySessionContextManager: + def test_exit_kills_and_closes(self): + ws = MagicMock() + session = PtySession(ws, "cl-abc") + with session: + pass + ws.send_text.assert_called() + ws.close.assert_called() + + def test_exit_ignores_errors(self): + ws = MagicMock() + ws.send_text.side_effect = Exception("already closed") + session = PtySession(ws, "cl-abc") + with session: + pass + + +class TestPtySessionSendStart: + def test_send_start_with_defaults(self): + ws = MagicMock() + session = PtySession(ws, "cl-abc") + session._send_start() + sent = json.loads(ws.send_text.call_args[0][0]) + assert sent["type"] == "start" + assert sent["cmd"] == "/bin/bash" + assert sent["cols"] == 80 + assert sent["rows"] == 24 + + def test_send_start_with_all_params(self): + ws = MagicMock() + session = PtySession(ws, "cl-abc") + session._send_start( + cmd="/bin/zsh", + args=["-l"], + cols=120, + rows=40, + envs={"TERM": "xterm-256color"}, + cwd="/home/user", + ) + sent = json.loads(ws.send_text.call_args[0][0]) + assert sent["cmd"] == "/bin/zsh" + assert sent["args"] == ["-l"] + assert sent["cols"] == 120 + assert sent["rows"] == 40 + assert sent["envs"] == {"TERM": "xterm-256color"} + assert sent["cwd"] == "/home/user" + + +class TestPtySessionSendConnect: + def test_send_connect(self): + ws = MagicMock() + session = PtySession(ws, "cl-abc") + session._send_connect("pty-abc12345") + sent = json.loads(ws.send_text.call_args[0][0]) + assert sent["type"] == "connect" + assert sent["tag"] == "pty-abc12345" + + +class TestAsyncPtySession: + @pytest.mark.asyncio + async def test_async_write_sends_base64(self): + ws = AsyncMock() + session = AsyncPtySession(ws, "cl-abc") + await session.write(b"hello") + sent = json.loads(ws.send_text.call_args[0][0]) + assert sent["type"] == "input" + assert base64.b64decode(sent["data"]) == b"hello" + + @pytest.mark.asyncio + async def test_async_resize(self): + ws = AsyncMock() + session = AsyncPtySession(ws, "cl-abc") + await session.resize(100, 30) + sent = json.loads(ws.send_text.call_args[0][0]) + assert sent["type"] == "resize" + assert sent["cols"] == 100 + assert sent["rows"] == 30 + + @pytest.mark.asyncio + async def test_async_resize_zero_raises(self): + ws = AsyncMock() + session = AsyncPtySession(ws, "cl-abc") + with pytest.raises(ValueError): + await session.resize(0, 10) + + @pytest.mark.asyncio + async def test_async_kill(self): + ws = AsyncMock() + session = AsyncPtySession(ws, "cl-abc") + await session.kill() + sent = json.loads(ws.send_text.call_args[0][0]) + assert sent["type"] == "kill" + + @pytest.mark.asyncio + async def test_async_context_manager(self): + ws = AsyncMock() + session = AsyncPtySession(ws, "cl-abc") + async with session: + pass + ws.send_text.assert_called() + ws.close.assert_called() + + @pytest.mark.asyncio + async def test_async_send_start(self): + ws = AsyncMock() + session = AsyncPtySession(ws, "cl-abc") + await session._send_start(cmd="/bin/zsh", cols=100, rows=30) + sent = json.loads(ws.send_text.call_args[0][0]) + assert sent["type"] == "start" + assert sent["cmd"] == "/bin/zsh" + assert sent["cols"] == 100 + assert sent["rows"] == 30 + + @pytest.mark.asyncio + async def test_async_send_connect(self): + ws = AsyncMock() + session = AsyncPtySession(ws, "cl-abc") + await session._send_connect("pty-abc12345") + sent = json.loads(ws.send_text.call_args[0][0]) + assert sent["type"] == "connect" + assert sent["tag"] == "pty-abc12345" + + @pytest.mark.asyncio + async def test_async_iteration(self): + ws = AsyncMock() + messages = [ + json.dumps({"type": "started", "tag": "pty-xyz", "pid": 5}), + json.dumps({"type": "output", "data": base64.b64encode(b"hi").decode()}), + json.dumps({"type": "exit", "exit_code": 0}), + ] + ws.receive_text.side_effect = messages + session = AsyncPtySession(ws, "cl-abc") + events = [] + async for event in session: + events.append(event) + assert len(events) == 2 + assert events[0].type == PtyEventType.started + assert session.tag == "pty-xyz" + assert session.pid == 5 + + +class TestExports: + def test_file_entry_importable(self): + from wrenn import FileEntry as FE + + assert FE is not None + + def test_pty_session_importable(self): + from wrenn import PtySession as PS + + assert PS is not None + + def test_async_pty_session_importable(self): + from wrenn import AsyncPtySession as APS + + assert APS is not None + + def test_pty_event_importable(self): + from wrenn import PtyEvent as PE, PtyEventType as PET + + assert PE is not None + assert PET is not None diff --git a/tests/test_integration.py b/tests/test_integration.py index e4f51ea..ca99b14 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -7,6 +7,7 @@ import pytest from wrenn.client import AsyncWrennClient, WrennClient from wrenn.exceptions import WrennNotFoundError, WrennValidationError +from wrenn.pty import PtyEventType WRENN_API_KEY = os.environ.get("WRENN_API_KEY") WRENN_TOKEN = os.environ.get("WRENN_TOKEN") @@ -287,3 +288,281 @@ class TestAsyncSandboxLifecycle: assert r.text == "84" finally: await sb.async_destroy() + + +@requires_auth +class TestFilesystemListDir: + def test_list_dir_root(self, client: WrennClient): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + sb.mkdir("/tmp/ls_test_root") + sb.upload("/tmp/ls_test_root/hello.txt", b"hello") + entries = sb.list_dir("/tmp/ls_test_root") + assert isinstance(entries, list) + names = [e.name for e in entries] + assert "hello.txt" in names + + def test_list_dir_after_mkdir(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + sb.mkdir("/tmp/fs_test_dir") + entries = sb.list_dir("/tmp") + names = [e.name for e in entries] + assert "fs_test_dir" in names + + def test_list_dir_file_metadata(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + sb.upload("/tmp/meta_test.txt", b"hello world") + entries = sb.list_dir("/tmp") + match = [e for e in entries if e.name == "meta_test.txt"] + assert len(match) == 1 + f = match[0] + assert f.type == "file" + assert f.size == 11 + assert f.permissions is not None + assert f.owner is not None + assert f.group is not None + assert f.modified_at is not None + + def test_list_dir_depth(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + sb.mkdir("/tmp/depth_a/depth_b") + sb.upload("/tmp/depth_a/depth_b/nested.txt", b"deep") + entries = sb.list_dir("/tmp/depth_a", depth=2) + paths = [e.path for e in entries] + assert any("nested.txt" in p for p in paths) + + def test_list_dir_empty_directory(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + sb.mkdir("/tmp/empty_dir_test") + entries = sb.list_dir("/tmp/empty_dir_test") + assert entries == [] + + +@requires_auth +class TestFilesystemMkdir: + def test_mkdir_creates_directory(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + entry = sb.mkdir("/tmp/mkdir_test") + assert entry.name == "mkdir_test" + assert entry.type == "directory" + assert entry.path == "/tmp/mkdir_test" + + def test_mkdir_creates_parents(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + entry = sb.mkdir("/tmp/a/b/c/d") + assert entry.type == "directory" + + def test_mkdir_already_exists(self, client: WrennClient): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + sb.mkdir("/tmp/exist_test") + entry = sb.mkdir("/tmp/exist_test") + assert entry.type == "directory" + + +@requires_auth +class TestFilesystemRemove: + def test_remove_file(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + sb.upload("/tmp/rm_test.txt", b"delete me") + entries_before = sb.list_dir("/tmp") + assert any(e.name == "rm_test.txt" for e in entries_before) + sb.remove("/tmp/rm_test.txt") + entries_after = sb.list_dir("/tmp") + assert not any(e.name == "rm_test.txt" for e in entries_after) + + def test_remove_directory(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + sb.mkdir("/tmp/rm_dir_test") + sb.upload("/tmp/rm_dir_test/file.txt", b"inside") + sb.remove("/tmp/rm_dir_test") + entries = sb.list_dir("/tmp") + assert not any(e.name == "rm_dir_test" for e in entries) + + def test_upload_download_remove_roundtrip(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + content = b"round trip test data " * 100 + sb.upload("/tmp/rt.txt", content) + downloaded = sb.download("/tmp/rt.txt") + assert downloaded == content + sb.remove("/tmp/rt.txt") + with pytest.raises(Exception): + sb.download("/tmp/rt.txt") + + +@requires_auth +class TestStreamUploadDownload: + def test_stream_upload_and_download(self, client: WrennClient): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + chunks = [b"chunk0_", b"chunk1_", b"chunk2"] + + def data_gen(): + yield from chunks + + sb.stream_upload("/tmp/stream_test.bin", data_gen()) + downloaded = sb.download("/tmp/stream_test.bin") + assert downloaded == b"chunk0_chunk1_chunk2" + + def test_stream_download_large(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + content = b"x" * 65536 * 3 + sb.upload("/tmp/large.bin", content) + collected = b"" + for chunk in sb.stream_download("/tmp/large.bin"): + collected += chunk + assert collected == content + + +@requires_auth +class TestPty: + def test_pty_basic_output(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + with sb.pty(cmd="/bin/sh", cwd="/tmp") as term: + term.write(b"echo pty_hello\n") + output = b"" + for event in term: + if event.type == PtyEventType.output: + output += event.data + elif event.type == PtyEventType.exit: + break + if b"pty_hello" in output: + term.write(b"exit\n") + assert b"pty_hello" in output + + def test_pty_tag_and_pid(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + with sb.pty(cmd="/bin/sh") as term: + started = False + for event in term: + if event.type == PtyEventType.started: + started = True + assert term.tag is not None + assert term.pid is not None + assert term.tag.startswith("pty-") + elif event.type == PtyEventType.output: + term.write(b"exit\n") + elif event.type == PtyEventType.exit: + break + assert started + + def test_pty_exit_on_command_exit(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + with sb.pty(cmd="/bin/echo", args=["immediate"]) as term: + events = list(term) + types = [e.type for e in events] + assert PtyEventType.started in types + assert PtyEventType.output in types or PtyEventType.exit in types + + def test_pty_resize(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + with sb.pty(cmd="/bin/sh", cols=80, rows=24) as term: + for event in term: + if event.type == PtyEventType.started: + term.resize(120, 40) + term.write(b"exit\n") + elif event.type == PtyEventType.exit: + break + + def test_pty_envs(self, client): + with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: + sb.wait_ready(timeout=60, interval=1) + with sb.pty(cmd="/bin/sh", envs={"MY_VAR": "hello_env"}) as term: + output = b"" + for event in term: + if event.type == PtyEventType.started: + term.write(b"echo $MY_VAR\n") + elif event.type == PtyEventType.output: + output += event.data + if b"hello_env" in output: + term.write(b"exit\n") + elif event.type == PtyEventType.exit: + break + assert b"hello_env" in output + + +@requires_auth +class TestAsyncFilesystem: + @pytest.mark.asyncio + async def test_async_list_dir(self, async_client): + async with async_client: + sb = await async_client.sandboxes.create( + template="minimal", timeout_sec=120 + ) + try: + await sb.async_wait_ready(timeout=60, interval=1) + await sb.async_mkdir("/tmp/async_ls_test") + await sb.async_upload("/tmp/async_ls_test/file.txt", b"data") + entries = await sb.async_list_dir("/tmp/async_ls_test") + assert isinstance(entries, list) + assert any(e.name == "file.txt" for e in entries) + finally: + await sb.async_destroy() + + @pytest.mark.asyncio + async def test_async_mkdir(self, async_client): + async with async_client: + sb = await async_client.sandboxes.create( + template="minimal", timeout_sec=120 + ) + try: + await sb.async_wait_ready(timeout=60, interval=1) + entry = await sb.async_mkdir("/tmp/async_mkdir_test") + assert entry.type == "directory" + assert entry.name == "async_mkdir_test" + finally: + await sb.async_destroy() + + @pytest.mark.asyncio + async def test_async_remove(self, async_client): + async with async_client: + sb = await async_client.sandboxes.create( + template="minimal", timeout_sec=120 + ) + try: + await sb.async_wait_ready(timeout=60, interval=1) + await sb.async_upload("/tmp/async_rm.txt", b"bye") + entries = await sb.async_list_dir("/tmp") + assert any(e.name == "async_rm.txt" for e in entries) + await sb.async_remove("/tmp/async_rm.txt") + entries = await sb.async_list_dir("/tmp") + assert not any(e.name == "async_rm.txt" for e in entries) + finally: + await sb.async_destroy() + + @pytest.mark.asyncio + async def test_async_full_filesystem_roundtrip(self, async_client): + async with async_client: + sb = await async_client.sandboxes.create( + template="minimal", timeout_sec=120 + ) + try: + await sb.async_wait_ready(timeout=60, interval=1) + + await sb.async_mkdir("/tmp/async_rt") + await sb.async_upload("/tmp/async_rt/file.txt", b"async content") + entries = await sb.async_list_dir("/tmp/async_rt") + assert any(e.name == "file.txt" for e in entries) + + data = await sb.async_download("/tmp/async_rt/file.txt") + assert data == b"async content" + + await sb.async_remove("/tmp/async_rt/file.txt") + entries = await sb.async_list_dir("/tmp/async_rt") + assert not any(e.name == "file.txt" for e in entries) + finally: + await sb.async_destroy() diff --git a/tests/test_sandbox_features.py b/tests/test_sandbox_features.py index d5538ef..7737b45 100644 --- a/tests/test_sandbox_features.py +++ b/tests/test_sandbox_features.py @@ -5,7 +5,6 @@ import pytest import respx from wrenn.client import WrennClient -from wrenn.exceptions import WrennAuthenticationError from wrenn.sandbox import CodeResult, Sandbox, _build_proxy_url @@ -57,22 +56,6 @@ class TestSandboxGetUrl: assert url == "ws://3000-cl-xyz.localhost:8080" -class TestProxyAuthGuard: - def test_jwt_only_get_url_raises(self): - with WrennClient(token="jwt-abc") as c: - sb = Sandbox(id="cl-abc") - sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") - with pytest.raises(WrennAuthenticationError): - sb.get_url(8888) - - def test_jwt_only_http_client_raises(self): - with WrennClient(token="jwt-abc") as c: - sb = Sandbox(id="cl-abc") - sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") - with pytest.raises(WrennAuthenticationError): - _ = sb.http_client - - class TestSandboxHttpClient: @respx.mock def test_http_client_has_api_key_header(self, client): @@ -96,6 +79,20 @@ class TestSandboxHttpClient: assert resp.status_code == 200 assert route.called + def test_jwt_only_get_url_works(self): + with WrennClient(token="jwt-abc") as c: + sb = Sandbox(id="cl-abc") + sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") + url = sb.get_url(8888) + assert "8888-cl-abc" in url + + def test_jwt_only_http_client_has_bearer_header(self): + with WrennClient(token="jwt-abc") as c: + sb = Sandbox(id="cl-abc") + sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") + hc = sb.http_client + assert hc.headers["Authorization"] == "Bearer jwt-abc" + class TestCreateReturnsBoundSandbox: @respx.mock @@ -148,15 +145,6 @@ class TestCodeResult: assert "ZeroDivisionError" in r.error -class TestRunCodeAuthGuard: - def test_jwt_only_run_code_raises(self): - with WrennClient(token="jwt-abc") as c: - sb = Sandbox(id="cl-abc") - sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") - with pytest.raises(WrennAuthenticationError): - sb.run_code("print(1)") - - class TestJupyterMessageFormat: def test_execute_request_structure(self): sb = Sandbox(id="test") -- 2.49.0 From 340ed46df60a9064c9b5f872d17e1cf461567ec1 Mon Sep 17 00:00:00 2001 From: Tasnim Kabir Sadik Date: Sun, 12 Apr 2026 02:51:14 +0600 Subject: [PATCH 03/44] CI for linting and testing --- .woodpecker/check.yml | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .woodpecker/check.yml diff --git a/.woodpecker/check.yml b/.woodpecker/check.yml new file mode 100644 index 0000000..1f50437 --- /dev/null +++ b/.woodpecker/check.yml @@ -0,0 +1,42 @@ +kind: pipeline +name: static-analysis + +when: + - event: push + branch: + - main + - dev + +variables: + - &python_image "ghcr.io/astral-sh/uv:python3.13-bookworm-slim" + - &uv_cache_dir "/root/.cache/uv" + - &cache_key "uv-${CI_REPO_NAME}-${CI_COMMIT_BRANCH}" + +steps: + lint: + image: *python_image + environment: + UV_CACHE_DIR: *uv_cache_dir + UV_FROZEN: "1" + commands: + - uv sync --no-install-project + - make lint + volumes: + - name: uv-cache + path: *uv_cache_dir + + test: + image: *python_image + environment: + UV_CACHE_DIR: *uv_cache_dir + UV_FROZEN: "1" + commands: + - uv sync --no-install-project + - make test + volumes: + - name: uv-cache + path: *uv_cache_dir + +volumes: + - name: uv-cache + temp: {} -- 2.49.0 From f3fd6865f991ff9b0c29a799258473edc6869b42 Mon Sep 17 00:00:00 2001 From: Tasnim Kabir Sadik Date: Sun, 12 Apr 2026 03:03:33 +0600 Subject: [PATCH 04/44] ci: bug fixes --- .woodpecker/check.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.woodpecker/check.yml b/.woodpecker/check.yml index 1f50437..c47c292 100644 --- a/.woodpecker/check.yml +++ b/.woodpecker/check.yml @@ -8,9 +8,8 @@ when: - dev variables: - - &python_image "ghcr.io/astral-sh/uv:python3.13-bookworm-slim" - - &uv_cache_dir "/root/.cache/uv" - - &cache_key "uv-${CI_REPO_NAME}-${CI_COMMIT_BRANCH}" + - &python_image ghcr.io/astral-sh/uv:python3.13-bookworm-slim + - &uv_cache_dir /root/.cache/uv steps: lint: @@ -39,4 +38,5 @@ steps: volumes: - name: uv-cache - temp: {} + host: + path: /var/lib/woodpecker/cache/uv/${CI_REPO_NAME}/${CI_COMMIT_BRANCH} -- 2.49.0 From 976af9a2096715df30aa4d6616918771b2901423 Mon Sep 17 00:00:00 2001 From: Tasnim Kabir Sadik Date: Sun, 12 Apr 2026 03:08:34 +0600 Subject: [PATCH 05/44] ci: woodpecker doesn't support variable expansions outside of commands --- .woodpecker/check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker/check.yml b/.woodpecker/check.yml index c47c292..7b4b167 100644 --- a/.woodpecker/check.yml +++ b/.woodpecker/check.yml @@ -39,4 +39,4 @@ steps: volumes: - name: uv-cache host: - path: /var/lib/woodpecker/cache/uv/${CI_REPO_NAME}/${CI_COMMIT_BRANCH} + path: /var/lib/woodpecker/cache/uv -- 2.49.0 From bf5914c0a8e7ba622b1fce060f7602f14dad5270 Mon Sep 17 00:00:00 2001 From: Tasnim Kabir Sadik Date: Mon, 13 Apr 2026 03:16:27 +0600 Subject: [PATCH 06/44] fix: renamed sandbox to capsule --- .woodpecker/check.yml | 50 +- api/openapi.yaml | 262 ++-- src/wrenn/__init__.py | 58 +- src/wrenn/capsule.py | 1171 ++++++++++++++++ src/wrenn/client.py | 95 +- src/wrenn/exceptions.py | 39 +- src/wrenn/models/__init__.py | 8 +- src/wrenn/models/_generated.py | 196 +-- src/wrenn/pty.py | 8 +- src/wrenn/sandbox.py | 1197 +---------------- ...x_features.py => test_capsule_features.py} | 119 +- tests/test_client.py | 88 +- tests/test_filesystem_pty.py | 75 +- tests/test_integration.py | 368 ++--- 14 files changed, 1929 insertions(+), 1805 deletions(-) create mode 100644 src/wrenn/capsule.py rename tests/{test_sandbox_features.py => test_capsule_features.py} (53%) diff --git a/.woodpecker/check.yml b/.woodpecker/check.yml index 7b4b167..83a35d7 100644 --- a/.woodpecker/check.yml +++ b/.woodpecker/check.yml @@ -1,42 +1,46 @@ -kind: pipeline -name: static-analysis - when: - - event: push - branch: - - main - - dev + event: push + branch: + - main + - dev variables: - - &python_image ghcr.io/astral-sh/uv:python3.13-bookworm-slim - - &uv_cache_dir /root/.cache/uv + - &python_image "ghcr.io/astral-sh/uv:python3.13-bookworm-slim" + - &uv_cache_dir "/root/.cache/uv" steps: - lint: + - name: restore-cache + image: woodpeckerci/plugin-cache + settings: + restore: true + cache_key: "uv-{{ checksum \"uv.lock\" }}" + mount: + - /root/.cache/uv + + - name: lint image: *python_image environment: UV_CACHE_DIR: *uv_cache_dir - UV_FROZEN: "1" + UV_FROZEN: 1 commands: - uv sync --no-install-project - make lint - volumes: - - name: uv-cache - path: *uv_cache_dir - test: + - name: test image: *python_image environment: UV_CACHE_DIR: *uv_cache_dir - UV_FROZEN: "1" + UV_FROZEN: 1 commands: - uv sync --no-install-project - make test - volumes: - - name: uv-cache - path: *uv_cache_dir -volumes: - - name: uv-cache - host: - path: /var/lib/woodpecker/cache/uv + - name: rebuild-cache + image: woodpeckerci/plugin-cache + when: + - status: [success] + settings: + rebuild: true + cache_key: "uv-{{ checksum \"uv.lock\" }}" + mount: + - /root/.cache/uv diff --git a/api/openapi.yaml b/api/openapi.yaml index 0b56fe5..b6bd643 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,6 +1,6 @@ openapi: "3.1.0" info: - title: Wrenn Sandbox API + title: Wrenn API description: MicroVM-based code execution platform API. version: "0.1.0" @@ -393,7 +393,7 @@ paths: - bearerAuth: [] description: | Owner only. Soft-deletes the team and destroys all running/paused/starting - sandboxes. All DB records are preserved. The team slug is permanently reserved. + capsulees. All DB records are preserved. The team slug is permanently reserved. responses: "204": description: Team deleted @@ -570,11 +570,11 @@ paths: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes: + /v1/capsules: post: - summary: Create a sandbox - operationId: createSandbox - tags: [sandboxes] + summary: Create a capsule + operationId: createCapsule + tags: [capsules] security: - apiKeyAuth: [] requestBody: @@ -582,14 +582,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CreateSandboxRequest" + $ref: "#/components/schemas/CreateCapsuleRequest" responses: "201": - description: Sandbox created + description: Capsule created content: application/json: schema: - $ref: "#/components/schemas/Sandbox" + $ref: "#/components/schemas/Capsule" "502": description: Host agent error content: @@ -598,26 +598,26 @@ paths: $ref: "#/components/schemas/Error" get: - summary: List sandboxes for your team - operationId: listSandboxes - tags: [sandboxes] + summary: List capsulees for your team + operationId: listCapsules + tags: [capsules] security: - apiKeyAuth: [] responses: "200": - description: List of sandboxes + description: List of capsulees content: application/json: schema: type: array items: - $ref: "#/components/schemas/Sandbox" + $ref: "#/components/schemas/Capsule" - /v1/sandboxes/stats: + /v1/capsules/stats: get: - summary: Get sandbox usage stats for your team - operationId: getSandboxStats - tags: [sandboxes] + summary: Get capsule usage stats for your team + operationId: getCapsuleStats + tags: [capsules] security: - apiKeyAuth: [] parameters: @@ -631,15 +631,15 @@ paths: description: Time window for the time-series data. responses: "200": - description: Sandbox stats for the team + description: Capsule stats for the team content: application/json: schema: - $ref: "#/components/schemas/SandboxStats" + $ref: "#/components/schemas/CapsuleStats" "400": $ref: "#/components/responses/BadRequest" - /v1/sandboxes/{id}: + /v1/capsules/{id}: parameters: - name: id in: path @@ -648,36 +648,36 @@ paths: type: string get: - summary: Get sandbox details - operationId: getSandbox - tags: [sandboxes] + summary: Get capsule details + operationId: getCapsule + tags: [capsules] security: - apiKeyAuth: [] responses: "200": - description: Sandbox details + description: Capsule details content: application/json: schema: - $ref: "#/components/schemas/Sandbox" + $ref: "#/components/schemas/Capsule" "404": - description: Sandbox not found + description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" delete: - summary: Destroy a sandbox - operationId: destroySandbox - tags: [sandboxes] + summary: Destroy a capsule + operationId: destroyCapsule + tags: [capsules] security: - apiKeyAuth: [] responses: "204": - description: Sandbox destroyed + description: Capsule destroyed - /v1/sandboxes/{id}/exec: + /v1/capsules/{id}/exec: parameters: - name: id in: path @@ -688,7 +688,7 @@ paths: post: summary: Execute a command operationId: execCommand - tags: [sandboxes] + tags: [capsules] security: - apiKeyAuth: [] requestBody: @@ -705,19 +705,19 @@ paths: schema: $ref: "#/components/schemas/ExecResponse" "404": - description: Sandbox not found + description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/ping: + /v1/capsules/{id}/ping: parameters: - name: id in: path @@ -726,32 +726,32 @@ paths: type: string post: - summary: Reset sandbox inactivity timer - operationId: pingSandbox - tags: [sandboxes] + summary: Reset capsule inactivity timer + operationId: pingCapsule + tags: [capsules] security: - apiKeyAuth: [] description: | - Resets the last_active_at timestamp for a running sandbox, preventing - the auto-pause TTL from expiring. Use this as a keepalive for sandboxes + Resets the last_active_at timestamp for a running capsule, preventing + the auto-pause TTL from expiring. Use this as a keepalive for capsulees that are idle but should remain running. responses: "204": description: Ping acknowledged, inactivity timer reset "404": - description: Sandbox not found + description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/metrics: + /v1/capsules/{id}/metrics: parameters: - name: id in: path @@ -760,22 +760,22 @@ paths: type: string get: - summary: Get per-sandbox resource metrics - operationId: getSandboxMetrics - tags: [sandboxes] + summary: Get per-capsule resource metrics + operationId: getCapsuleMetrics + tags: [capsules] security: - apiKeyAuth: [] - bearerAuth: [] description: | - Returns time-series CPU, memory, and disk metrics for a sandbox. + Returns time-series CPU, memory, and disk metrics for a capsule. Three tiers are available with different granularity and retention: - `10m`: 500ms samples, last 10 minutes - `2h`: 30-second averages, last 2 hours - `24h`: 5-minute averages, last 24 hours - For running sandboxes, data comes from the host agent's in-memory - ring buffer. For paused sandboxes, data is read from persisted - snapshots in the database. Stopped/destroyed sandboxes return 404. + For running capsulees, data comes from the host agent's in-memory + ring buffer. For paused capsulees, data is read from persisted + snapshots in the database. Stopped/destroyed capsulees return 404. parameters: - name: range in: query @@ -791,7 +791,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/SandboxMetrics" + $ref: "#/components/schemas/CapsuleMetrics" "400": description: Invalid range parameter content: @@ -799,13 +799,13 @@ paths: schema: $ref: "#/components/schemas/Error" "404": - description: Sandbox not found or metrics not available + description: Capsule not found or metrics not available content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/pause: + /v1/capsules/{id}/pause: parameters: - name: id in: path @@ -814,30 +814,30 @@ paths: type: string post: - summary: Pause a running sandbox - operationId: pauseSandbox - tags: [sandboxes] + summary: Pause a running capsule + operationId: pauseCapsule + tags: [capsules] security: - apiKeyAuth: [] description: | - Takes a snapshot of the sandbox (VM state + memory + rootfs), then - destroys all running resources. The sandbox exists only as files on + Takes a snapshot of the capsule (VM state + memory + rootfs), then + destroys all running resources. The capsule exists only as files on disk and can be resumed later. responses: "200": - description: Sandbox paused (snapshot taken, resources released) + description: Capsule paused (snapshot taken, resources released) content: application/json: schema: - $ref: "#/components/schemas/Sandbox" + $ref: "#/components/schemas/Capsule" "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/resume: + /v1/capsules/{id}/resume: parameters: - name: id in: path @@ -846,24 +846,24 @@ paths: type: string post: - summary: Resume a paused sandbox - operationId: resumeSandbox - tags: [sandboxes] + summary: Resume a paused capsule + operationId: resumeCapsule + tags: [capsules] security: - apiKeyAuth: [] description: | - Restores a paused sandbox from its snapshot using UFFD for lazy + Restores a paused capsule from its snapshot using UFFD for lazy memory loading. Boots a fresh Firecracker process, sets up a new network slot, and waits for envd to become ready. responses: "200": - description: Sandbox resumed (new VM booted from snapshot) + description: Capsule resumed (new VM booted from snapshot) content: application/json: schema: - $ref: "#/components/schemas/Sandbox" + $ref: "#/components/schemas/Capsule" "409": - description: Sandbox not paused + description: Capsule not paused content: application/json: schema: @@ -877,9 +877,9 @@ paths: security: - apiKeyAuth: [] description: | - Pauses a running sandbox, takes a full snapshot, copies the snapshot + Pauses a running capsule, takes a full snapshot, copies the snapshot files to the images directory as a reusable template, then destroys - the sandbox. The template can be used to create new sandboxes. + the capsule. The template can be used to create new capsulees. parameters: - name: overwrite in: query @@ -902,7 +902,7 @@ paths: schema: $ref: "#/components/schemas/Template" "409": - description: Name already exists or sandbox not running + description: Name already exists or capsule not running content: application/json: schema: @@ -957,7 +957,7 @@ paths: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/files/write: + /v1/capsules/{id}/files/write: parameters: - name: id in: path @@ -968,7 +968,7 @@ paths: post: summary: Upload a file operationId: uploadFile - tags: [sandboxes] + tags: [capsules] security: - apiKeyAuth: [] requestBody: @@ -981,7 +981,7 @@ paths: properties: path: type: string - description: Absolute destination path inside the sandbox + description: Absolute destination path inside the capsule file: type: string format: binary @@ -990,7 +990,7 @@ paths: "204": description: File uploaded "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: @@ -1002,7 +1002,7 @@ paths: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/files/read: + /v1/capsules/{id}/files/read: parameters: - name: id in: path @@ -1013,7 +1013,7 @@ paths: post: summary: Download a file operationId: downloadFile - tags: [sandboxes] + tags: [capsules] security: - apiKeyAuth: [] requestBody: @@ -1031,13 +1031,13 @@ paths: type: string format: binary "404": - description: Sandbox or file not found + description: Capsule or file not found content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/files/list: + /v1/capsules/{id}/files/list: parameters: - name: id in: path @@ -1048,7 +1048,7 @@ paths: post: summary: List directory contents operationId: listDir - tags: [sandboxes] + tags: [capsules] security: - apiKeyAuth: [] requestBody: @@ -1065,19 +1065,19 @@ paths: schema: $ref: "#/components/schemas/ListDirResponse" "404": - description: Sandbox not found + description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/files/mkdir: + /v1/capsules/{id}/files/mkdir: parameters: - name: id in: path @@ -1088,7 +1088,7 @@ paths: post: summary: Create a directory operationId: makeDir - tags: [sandboxes] + tags: [capsules] security: - apiKeyAuth: [] requestBody: @@ -1105,19 +1105,19 @@ paths: schema: $ref: "#/components/schemas/MakeDirResponse" "404": - description: Sandbox not found + description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/files/remove: + /v1/capsules/{id}/files/remove: parameters: - name: id in: path @@ -1128,7 +1128,7 @@ paths: post: summary: Remove a file or directory operationId: removePath - tags: [sandboxes] + tags: [capsules] security: - apiKeyAuth: [] requestBody: @@ -1141,19 +1141,19 @@ paths: "204": description: File or directory removed "404": - description: Sandbox not found + description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/exec/stream: + /v1/capsules/{id}/exec/stream: parameters: - name: id in: path @@ -1164,7 +1164,7 @@ paths: get: summary: Stream command execution via WebSocket operationId: execStream - tags: [sandboxes] + tags: [capsules] security: - apiKeyAuth: [] description: | @@ -1194,19 +1194,19 @@ paths: "101": description: WebSocket upgrade "404": - description: Sandbox not found + description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/pty: + /v1/capsules/{id}/pty: parameters: - name: id in: path @@ -1217,7 +1217,7 @@ paths: get: summary: Interactive PTY session via WebSocket operationId: ptySession - tags: [sandboxes] + tags: [capsules] security: - apiKeyAuth: [] description: | @@ -1266,25 +1266,25 @@ paths: Sessions have a 120-second inactivity timeout (reset on input/resize). Sessions persist across WebSocket disconnections — the process keeps - running in the sandbox. Use the `tag` from the "started" response to + running in the capsule. Use the `tag` from the "started" response to reconnect later. responses: "101": description: WebSocket upgrade "404": - description: Sandbox not found + description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/files/stream/write: + /v1/capsules/{id}/files/stream/write: parameters: - name: id in: path @@ -1295,11 +1295,11 @@ paths: post: summary: Upload a file (streaming) operationId: streamUploadFile - tags: [sandboxes] + tags: [capsules] security: - apiKeyAuth: [] description: | - Streams file content to the sandbox without buffering in memory. + Streams file content to the capsule without buffering in memory. Suitable for large files. Uses the same multipart/form-data format as the non-streaming upload endpoint. requestBody: @@ -1312,7 +1312,7 @@ paths: properties: path: type: string - description: Absolute destination path inside the sandbox + description: Absolute destination path inside the capsule file: type: string format: binary @@ -1321,19 +1321,19 @@ paths: "204": description: File uploaded "404": - description: Sandbox not found + description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" - /v1/sandboxes/{id}/files/stream/read: + /v1/capsules/{id}/files/stream/read: parameters: - name: id in: path @@ -1344,11 +1344,11 @@ paths: post: summary: Download a file (streaming) operationId: streamDownloadFile - tags: [sandboxes] + tags: [capsules] security: - apiKeyAuth: [] description: | - Streams file content from the sandbox without buffering in memory. + Streams file content from the capsule without buffering in memory. Suitable for large files. Returns raw bytes with chunked transfer encoding. requestBody: required: true @@ -1365,13 +1365,13 @@ paths: type: string format: binary "404": - description: Sandbox or file not found + description: Capsule or file not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": - description: Sandbox not running + description: Capsule not running content: application/json: schema: @@ -1469,14 +1469,14 @@ paths: description: | Admins can delete any host. Team owners and admins can delete BYOC hosts belonging to their team. Without `?force=true`, returns 409 if the host - has active sandboxes. With `?force=true`, destroys all sandboxes first. + has active capsulees. With `?force=true`, destroys all capsulees first. parameters: - name: force in: query required: false schema: type: boolean - description: If true, destroy all sandboxes on the host before deleting. + description: If true, destroy all capsulees on the host before deleting. responses: "204": description: Host deleted @@ -1487,11 +1487,11 @@ paths: schema: $ref: "#/components/schemas/Error" "409": - description: Host has active sandboxes (only when force is not set) + description: Host has active capsulees (only when force is not set) content: application/json: schema: - $ref: "#/components/schemas/HostHasSandboxesError" + $ref: "#/components/schemas/HostHasCapsulesError" /v1/hosts/{id}/token: parameters: @@ -1644,7 +1644,7 @@ paths: security: - bearerAuth: [] description: | - Returns the list of sandbox IDs that would be destroyed if the host + Returns the list of capsule IDs that would be destroyed if the host were deleted with `?force=true`. No state is modified. responses: "200": @@ -1917,7 +1917,7 @@ components: type: apiKey in: header name: X-API-Key - description: API key for sandbox lifecycle operations. Create via POST /v1/api-keys. + description: API key for capsule lifecycle operations. Create via POST /v1/api-keys. bearerAuth: type: http @@ -2002,7 +2002,7 @@ components: description: Full plaintext key. Only returned on creation, never again. nullable: true - CreateSandboxRequest: + CreateCapsuleRequest: type: object properties: template: @@ -2018,11 +2018,11 @@ components: type: integer default: 0 description: > - Auto-pause TTL in seconds. The sandbox is automatically paused + Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause. - SandboxStats: + CapsuleStats: type: object properties: range: @@ -2073,7 +2073,7 @@ components: items: type: integer - Sandbox: + Capsule: type: object properties: id: @@ -2114,7 +2114,7 @@ components: properties: sandbox_id: type: string - description: ID of the running sandbox to snapshot. + description: ID of the running capsule to snapshot. name: type: string description: Name for the snapshot template. Auto-generated if omitted. @@ -2180,7 +2180,7 @@ components: properties: path: type: string - description: Absolute file path inside the sandbox + description: Absolute file path inside the capsule ListDirRequest: type: object @@ -2188,7 +2188,7 @@ components: properties: path: type: string - description: Directory path inside the sandbox + description: Directory path inside the capsule depth: type: integer default: 1 @@ -2238,7 +2238,7 @@ components: properties: path: type: string - description: Directory path to create inside the sandbox + description: Directory path to create inside the capsule MakeDirResponse: type: object @@ -2252,7 +2252,7 @@ components: properties: path: type: string - description: Path to remove inside the sandbox + description: Path to remove inside the capsule CreateHostRequest: type: object @@ -2390,9 +2390,9 @@ components: type: array items: type: string - description: IDs of sandboxes that would be destroyed on force-delete. + description: IDs of capsulees that would be destroyed on force-delete. - HostHasSandboxesError: + HostHasCapsulesError: type: object properties: error: @@ -2407,7 +2407,7 @@ components: type: array items: type: string - description: IDs of active sandboxes blocking deletion. + description: IDs of active capsulees blocking deletion. AddTagRequest: type: object @@ -2471,7 +2471,7 @@ components: items: $ref: "#/components/schemas/TeamMember" - SandboxMetrics: + CapsuleMetrics: type: object properties: sandbox_id: diff --git a/src/wrenn/__init__.py b/src/wrenn/__init__.py index d478216..c25aaf8 100644 --- a/src/wrenn/__init__.py +++ b/src/wrenn/__init__.py @@ -1,22 +1,7 @@ -from wrenn.client import AsyncWrennClient, WrennClient -from wrenn.exceptions import ( - WrennAgentError, - WrennAuthenticationError, - WrennConflictError, - WrennError, - WrennForbiddenError, - WrennHostHasSandboxesError, - WrennHostUnavailableError, - WrennInternalError, - WrennNotFoundError, - WrennValidationError, -) -from wrenn.models import FileEntry -from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession -from wrenn.sandbox import ( +from wrenn.capsule import ( + Capsule, CodeResult, ExecResult, - Sandbox, StreamErrorEvent, StreamEvent, StreamExitEvent, @@ -24,6 +9,21 @@ from wrenn.sandbox import ( StreamStderrEvent, StreamStdoutEvent, ) +from wrenn.client import AsyncWrennClient, WrennClient +from wrenn.exceptions import ( + WrennAgentError, + WrennAuthenticationError, + WrennConflictError, + WrennError, + WrennForbiddenError, + WrennHostHasCapsulesError, + WrennHostUnavailableError, + WrennInternalError, + WrennNotFoundError, + WrennValidationError, +) +from wrenn.models import FileEntry +from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession __version__ = "0.1.0" @@ -31,6 +31,7 @@ __all__ = [ "__version__", "AsyncPtySession", "AsyncWrennClient", + "Capsule", "CodeResult", "ExecResult", "FileEntry", @@ -50,9 +51,32 @@ __all__ = [ "WrennConflictError", "WrennError", "WrennForbiddenError", + "WrennHostHasCapsulesError", "WrennHostHasSandboxesError", "WrennHostUnavailableError", "WrennInternalError", "WrennNotFoundError", "WrennValidationError", ] + + +def __getattr__(name: str) -> type: + if name == "Sandbox": + import warnings + + warnings.warn( + "'Sandbox' is deprecated, use 'Capsule' instead", + DeprecationWarning, + stacklevel=2, + ) + return Capsule + if name == "WrennHostHasSandboxesError": + import warnings + + warnings.warn( + "'WrennHostHasSandboxesError' is deprecated, use 'WrennHostHasCapsulesError' instead", + DeprecationWarning, + stacklevel=2, + ) + return WrennHostHasCapsulesError + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py new file mode 100644 index 0000000..17fec62 --- /dev/null +++ b/src/wrenn/capsule.py @@ -0,0 +1,1171 @@ +from __future__ import annotations + +import asyncio +import base64 +import json +import os +import time +import uuid +import warnings +from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager, contextmanager +from typing import Any + +import httpx +import httpx_ws + +from wrenn.exceptions import handle_response +from wrenn.models import Capsule as CapsuleModel +from wrenn.models import ( + ExecResponse, + FileEntry, + ListDirResponse, + MakeDirResponse, + Status, +) +from wrenn.pty import AsyncPtySession, PtySession + + +class ExecResult: + """Typed result from a synchronous exec call.""" + + __slots__ = ("stdout", "stderr", "exit_code", "duration_ms", "encoding") + + def __init__( + self, + stdout: str, + stderr: str, + exit_code: int, + duration_ms: int | None, + encoding: str | None, + ) -> None: + self.stdout = stdout + self.stderr = stderr + self.exit_code = exit_code + self.duration_ms = duration_ms + self.encoding = encoding + + +class CodeResult: + """Typed result from stateful code execution (``run_code``). + + Attributes: + text: text/plain representation of the result. + data: rich MIME bundle (e.g. ``{"image/png": "..."}``). + stdout: accumulated stdout output. + stderr: accumulated stderr output. + error: language-specific error/traceback string. + """ + + __slots__ = ("text", "data", "stdout", "stderr", "error") + + def __init__( + self, + text: str | None = None, + data: dict[str, str] | None = None, + stdout: str = "", + stderr: str = "", + error: str | None = None, + ) -> None: + self.text = text + self.data = data + self.stdout = stdout + self.stderr = stderr + self.error = error + + +class StreamEvent: + """Base class for streaming exec events.""" + + __slots__ = ("type",) + + def __init__(self, type: str) -> None: + self.type = type + + +class StreamStartEvent(StreamEvent): + """Process started.""" + + __slots__ = ("pid",) + + def __init__(self, pid: int) -> None: + super().__init__("start") + self.pid = pid + + +class StreamStdoutEvent(StreamEvent): + """Stdout data received.""" + + __slots__ = ("data",) + + def __init__(self, data: str) -> None: + super().__init__("stdout") + self.data = data + + +class StreamStderrEvent(StreamEvent): + """Stderr data received.""" + + __slots__ = ("data",) + + def __init__(self, data: str) -> None: + super().__init__("stderr") + self.data = data + + +class StreamExitEvent(StreamEvent): + """Process exited.""" + + __slots__ = ("exit_code",) + + def __init__(self, exit_code: int) -> None: + super().__init__("exit") + self.exit_code = exit_code + + +class StreamErrorEvent(StreamEvent): + """Error occurred.""" + + __slots__ = ("data",) + + def __init__(self, data: str) -> None: + super().__init__("error") + self.data = data + + +def _parse_stream_event(raw: dict) -> StreamEvent: + t = raw.get("type") + if t == "start": + return StreamStartEvent(pid=raw.get("pid", 0)) + if t == "stdout": + return StreamStdoutEvent(data=raw.get("data", "")) + if t == "stderr": + return StreamStderrEvent(data=raw.get("data", "")) + if t == "exit": + return StreamExitEvent(exit_code=raw.get("exit_code", -1)) + if t == "error": + return StreamErrorEvent(data=raw.get("data", "")) + return StreamEvent(type=t or "unknown") + + +def _build_proxy_url(base_url: str, capsule_id: str | None, port: int) -> str: + parsed = httpx.URL(base_url) + host = parsed.host + if parsed.port: + host = f"{host}:{parsed.port}" + scheme = "ws" if parsed.scheme == "http" else "wss" + return f"{scheme}://{port}-{capsule_id}.{host}" + + +class Capsule(CapsuleModel): + """Developer-facing capsule interface wrapping the generated Capsule model. + + Provides data-plane methods (exec, file I/O, lifecycle), capsule proxy + helpers, and context-manager support for automatic cleanup. + """ + + _http: httpx.Client | None + _async_http: httpx.AsyncClient | None + _base_url: str + _api_key: str | None + _token: str | None + _proxy_client: httpx.Client | None + _async_proxy_client: httpx.AsyncClient | None + _kernel_id: str | None + _jupyter_ws: Any + _async_jupyter_ws: Any + + def _bind( + self, + http: httpx.Client | httpx.AsyncClient, + base_url: str, + api_key: str | None = None, + token: str | None = None, + ) -> None: + self._base_url = base_url + self._api_key = api_key + self._token = token + self._proxy_client = None + self._async_proxy_client = None + self._kernel_id = None + self._jupyter_ws = None + self._async_jupyter_ws = None + if isinstance(http, httpx.Client): + self._http = http + self._async_http = None + else: + self._http = None # type: ignore[assignment] + self._async_http = http + + def _proxy_headers(self) -> dict[str, str]: + headers: dict[str, str] = {} + if self._api_key: + headers["X-API-Key"] = self._api_key + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + return headers + + def _clear_content_type(self) -> dict[str, str]: + assert self._http is not None + headers = dict(self._http.headers) + headers.pop("Content-Type", None) + return headers + + def _async_clear_content_type(self) -> dict[str, str]: + assert self._async_http is not None + headers = dict(self._async_http.headers) + headers.pop("Content-Type", None) + return headers + + def get_url(self, port: int) -> str: + """Construct the proxy URL for a port inside this capsule. + + Args: + port: Port number of the service running inside the capsule. + + Returns: + A URL string like ``http://8888-cl-abc123.api.wrenn.dev``. + """ + return _build_proxy_url(self._base_url, self.id, port) + + @property + def http_client(self) -> httpx.Client: + """A pre-configured ``httpx.Client`` targeting the capsule proxy on port 8888. + + The client has auth headers set and ``base_url`` pointing to + the proxy URL for port 8888. Closed automatically when the capsule exits. + """ + if self._proxy_client is None: + url = ( + _build_proxy_url(self._base_url, self.id, 8888) + .replace("ws://", "http://") + .replace("wss://", "https://") + ) + self._proxy_client = httpx.Client( + base_url=url, + headers=self._proxy_headers(), + ) + return self._proxy_client + + def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None: + """Block until the capsule status is ``running``. + + Args: + timeout: Maximum seconds to wait. + interval: Seconds between polls. + + Raises: + TimeoutError: If the capsule does not become ready in time. + """ + assert self._http is not None + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + resp = self._http.get(f"/v1/capsules/{self.id}") + data = resp.json() + status = data.get("status") + if status == Status.running: + self.status = Status.running + return + if status in (Status.error, Status.stopped): + raise RuntimeError(f"Capsule entered {status} state while waiting") + time.sleep(interval) + raise TimeoutError(f"Capsule {self.id} did not become ready within {timeout}s") + + async def async_wait_ready( + self, timeout: float = 30, interval: float = 0.5 + ) -> None: + """Async version of ``wait_ready``.""" + assert self._async_http is not None + import asyncio + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + resp = await self._async_http.get(f"/v1/capsules/{self.id}") + data = resp.json() + status = data.get("status") + if status == Status.running: + self.status = Status.running + return + if status in (Status.error, Status.stopped): + raise RuntimeError(f"Capsule entered {status} state while waiting") + await asyncio.sleep(interval) + raise TimeoutError(f"Capsule {self.id} did not become ready within {timeout}s") + + def exec( + self, + cmd: str, + args: list[str] | None = None, + timeout_sec: int | None = 30, + ) -> ExecResult: + """Execute a command synchronously inside the capsule. + + Args: + cmd: Command to run. + args: Optional positional arguments. + timeout_sec: Execution timeout in seconds. + + Returns: + An ``ExecResult`` with ``stdout``, ``stderr``, ``exit_code``, ``duration_ms``. + """ + assert self._http is not None + payload: dict = {"cmd": cmd} + if args is not None: + payload["args"] = args + if timeout_sec is not None: + payload["timeout_sec"] = timeout_sec + resp = self._http.post(f"/v1/capsules/{self.id}/exec", json=payload) + resp.raise_for_status() + er = ExecResponse.model_validate(resp.json()) + stdout = er.stdout or "" + stderr = er.stderr or "" + if er.encoding == "base64": + stdout = base64.b64decode(stdout).decode("utf-8", errors="replace") + if stderr: + stderr = base64.b64decode(stderr).decode("utf-8", errors="replace") + return ExecResult( + stdout=stdout, + stderr=stderr, + exit_code=er.exit_code if er.exit_code is not None else -1, + duration_ms=er.duration_ms, + encoding=er.encoding, + ) + + async def async_exec( + self, + cmd: str, + args: list[str] | None = None, + timeout_sec: int | None = 30, + ) -> ExecResult: + """Async version of ``exec``.""" + assert self._async_http is not None + payload: dict = {"cmd": cmd} + if args is not None: + payload["args"] = args + if timeout_sec is not None: + payload["timeout_sec"] = timeout_sec + resp = await self._async_http.post(f"/v1/capsules/{self.id}/exec", json=payload) + resp.raise_for_status() + er = ExecResponse.model_validate(resp.json()) + stdout = er.stdout or "" + stderr = er.stderr or "" + if er.encoding == "base64": + stdout = base64.b64decode(stdout).decode("utf-8", errors="replace") + if stderr: + stderr = base64.b64decode(stderr).decode("utf-8", errors="replace") + return ExecResult( + stdout=stdout, + stderr=stderr, + exit_code=er.exit_code if er.exit_code is not None else -1, + duration_ms=er.duration_ms, + encoding=er.encoding, + ) + + def exec_stream( + self, + cmd: str, + args: list[str] | None = None, + ) -> Iterator[StreamEvent]: + """Execute a command via WebSocket, yielding ``StreamEvent`` objects. + + Args: + cmd: Command to run. + args: Optional positional arguments. + + Yields: + ``StreamStartEvent``, ``StreamStdoutEvent``, ``StreamStderrEvent``, + ``StreamExitEvent``, or ``StreamErrorEvent``. + """ + assert self._http is not None + ws: httpx_ws.WebSocketSession + with httpx_ws.connect_ws( # type: ignore[attr-defined] + f"/v1/capsules/{self.id}/exec/stream", + self._http, + ) as ws: + start_msg: dict = {"type": "start", "cmd": cmd} + if args: + start_msg["args"] = args + ws.send_text(json.dumps(start_msg)) + while True: + try: + raw_data: dict = ws.receive_json() # type: ignore[assignment] + event = _parse_stream_event(raw_data) + yield event + + if event.type in ("exit", "error"): + break + + except httpx_ws.WebSocketDisconnect: + break + + async def async_exec_stream( + self, cmd: str, args: list[str] | None = None + ) -> AsyncIterator[StreamEvent]: + """Async version of ``exec_stream``.""" + assert self._async_http is not None + ws: httpx_ws.AsyncWebSocketSession + async with httpx_ws.aconnect_ws( # type: ignore[attr-defined, var-annotated] + f"/v1/capsules/{self.id}/exec/stream", self._async_http + ) as ws: + start_msg: dict = {"type": "start", "cmd": cmd} + if args: + start_msg["args"] = args + await ws.send_text(json.dumps(start_msg)) + + try: + while True: + raw_data = await ws.receive_json() + event = _parse_stream_event(raw_data) + yield event + + if event.type in ("exit", "error"): + break + except httpx_ws.WebSocketDisconnect: + pass + + def upload(self, path: str, data: bytes) -> None: + """Upload a small file to the capsule. + + Args: + path: Absolute destination path inside the capsule. + data: File contents as bytes. + """ + assert self._http is not None + resp = self._http.post( + f"/v1/capsules/{self.id}/files/write", + files={"file": ("upload", data)}, + data={"path": path}, + ) + + resp.raise_for_status() + + async def async_upload(self, path: str, data: bytes) -> None: + """Async version of ``upload``.""" + assert self._async_http is not None + resp = await self._async_http.post( + f"/v1/capsules/{self.id}/files/write", + files={"file": ("upload", data)}, + data={"path": path}, + ) + resp.raise_for_status() + + def download(self, path: str) -> bytes: + """Download a small file from the capsule. + + Args: + path: Absolute file path inside the capsule. + + Returns: + File contents as bytes. + """ + assert self._http is not None + resp = self._http.post( + f"/v1/capsules/{self.id}/files/read", + json={"path": path}, + ) + resp.raise_for_status() + return resp.content + + async def async_download(self, path: str) -> bytes: + """Async version of ``download``.""" + assert self._async_http is not None + resp = await self._async_http.post( + f"/v1/capsules/{self.id}/files/read", + json={"path": path}, + ) + resp.raise_for_status() + return resp.content + + def stream_upload(self, path: str, stream: Iterator[bytes]) -> None: + """Streaming upload for large files. + + Args: + path: Absolute destination path inside the capsule. + stream: An iterator yielding byte chunks. + """ + assert self._http is not None + + boundary = os.urandom(16).hex().encode("utf-8") + + def _multipart_stream() -> Iterator[bytes]: + yield b"--" + boundary + b"\r\n" + yield b'Content-Disposition: form-data; name="path"\r\n\r\n' + yield path.encode("utf-8") + b"\r\n" + + yield b"--" + boundary + b"\r\n" + yield b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n' + yield b"Content-Type: application/octet-stream\r\n\r\n" + + for chunk in stream: + yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") + + yield b"\r\n--" + boundary + b"--\r\n" + + headers = { + "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}" + } + + resp = self._http.post( + f"/v1/capsules/{self.id}/files/stream/write", + content=_multipart_stream(), + headers=headers, + ) + resp.raise_for_status() + + async def async_stream_upload( + self, path: str, stream: AsyncIterator[bytes] + ) -> None: + """Async version of ``stream_upload``.""" + assert self._async_http is not None + + boundary = os.urandom(16).hex().encode("utf-8") + + async def _async_multipart_stream() -> AsyncIterator[bytes]: + yield b"--" + boundary + b"\r\n" + yield b'Content-Disposition: form-data; name="path"\r\n\r\n' + yield path.encode("utf-8") + b"\r\n" + + yield b"--" + boundary + b"\r\n" + yield b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n' + yield b"Content-Type: application/octet-stream\r\n\r\n" + + async for chunk in stream: + yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") + + yield b"\r\n--" + boundary + b"--\r\n" + + headers = { + "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}" + } + + resp = await self._async_http.post( + f"/v1/capsules/{self.id}/files/stream/write", + content=_async_multipart_stream(), + headers=headers, + ) + resp.raise_for_status() + + def stream_download(self, path: str) -> Iterator[bytes]: + """Streaming download for large files. + + Args: + path: Absolute file path inside the capsule. + + Yields: + Byte chunks. + """ + assert self._http is not None + with self._http.stream( + "POST", + f"/v1/capsules/{self.id}/files/stream/read", + json={"path": path}, + ) as resp: + resp.raise_for_status() + yield from resp.iter_bytes() + + async def async_stream_download(self, path: str) -> AsyncIterator[bytes]: + """Async version of ``stream_download``.""" + assert self._async_http is not None + async with self._async_http.stream( + "POST", + f"/v1/capsules/{self.id}/files/stream/read", + json={"path": path}, + ) as resp: + resp.raise_for_status() + async for chunk in resp.aiter_bytes(): + yield chunk + + def list_dir(self, path: str, depth: int = 1) -> list[FileEntry]: + """List directory contents inside the capsule. + + Args: + path: Absolute directory path. + depth: Recursion depth. 1 = immediate children only. + + Returns: + List of FileEntry objects with full metadata. + + Raises: + WrennValidationError: Invalid path. + WrennNotFoundError: Capsule or directory not found. + WrennConflictError: Capsule is not running. + WrennAgentError: Agent error. + WrennHostUnavailableError: Host agent not reachable. + """ + assert self._http is not None + resp = self._http.post( + f"/v1/capsules/{self.id}/files/list", + json={"path": path, "depth": depth}, + ) + data = handle_response(resp) + parsed = ListDirResponse.model_validate(data) + return parsed.entries or [] + + async def async_list_dir(self, path: str, depth: int = 1) -> list[FileEntry]: + """Async version of ``list_dir``.""" + assert self._async_http is not None + resp = await self._async_http.post( + f"/v1/capsules/{self.id}/files/list", + json={"path": path, "depth": depth}, + ) + data = handle_response(resp) + parsed = ListDirResponse.model_validate(data) + return parsed.entries or [] + + def mkdir(self, path: str) -> FileEntry: + """Create a directory inside the capsule (with parents). + + Args: + path: Absolute directory path to create. + + Returns: + FileEntry for the created directory. + + Raises: + WrennValidationError: Path exists and is not a directory. + WrennConflictError: Directory already exists (returns existing entry). + Capsule is not running. + WrennNotFoundError: Capsule not found. + WrennAgentError: Agent error. + WrennHostUnavailableError: Host agent not reachable. + """ + assert self._http is not None + resp = self._http.post( + f"/v1/capsules/{self.id}/files/mkdir", + json={"path": path}, + ) + if resp.status_code == 409: + try: + body = resp.json() + err = body.get("error", {}) + if err.get("code") == "conflict": + parent_dir = os.path.dirname(path) + dir_name = os.path.basename(path) + + listing = self.list_dir(parent_dir, depth=0) + for entry in listing: + if entry.name == dir_name: + return entry + except Exception: + pass + data = handle_response(resp) + parsed = MakeDirResponse.model_validate(data) + if parsed.entry is None: + raise RuntimeError("mkdir response missing entry") + return parsed.entry + + async def async_mkdir(self, path: str) -> FileEntry: + """Async version of ``mkdir``.""" + assert self._async_http is not None + resp = await self._async_http.post( + f"/v1/capsules/{self.id}/files/mkdir", + json={"path": path}, + ) + if resp.status_code == 409: + try: + body = resp.json() + err = body.get("error", {}) + if err.get("code") == "conflict": + listing = await self.async_list_dir(path, depth=0) + parent_dir = os.path.dirname(path) + dir_name = os.path.basename(path) + + listing = self.list_dir(parent_dir, depth=0) + for entry in listing: + if entry.name == dir_name: + return entry + except Exception: + pass + data = handle_response(resp) + parsed = MakeDirResponse.model_validate(data) + if parsed.entry is None: + raise RuntimeError("mkdir response missing entry") + return parsed.entry + + def remove(self, path: str) -> None: + """Remove a file or directory inside the capsule. + + Removes recursively. No confirmation or dry-run. Equivalent to rm -rf. + + Args: + path: Absolute path to remove. + + Raises: + WrennValidationError: Invalid path. + WrennNotFoundError: Capsule not found. + WrennConflictError: Capsule is not running. + WrennAgentError: Agent error. + WrennHostUnavailableError: Host agent not reachable. + """ + assert self._http is not None + resp = self._http.post( + f"/v1/capsules/{self.id}/files/remove", + json={"path": path}, + ) + handle_response(resp) + + async def async_remove(self, path: str) -> None: + """Async version of ``remove``.""" + assert self._async_http is not None + resp = await self._async_http.post( + f"/v1/capsules/{self.id}/files/remove", + json={"path": path}, + ) + handle_response(resp) + + @contextmanager + def pty( + self, + cmd: str = "/bin/bash", + args: list[str] | None = None, + cols: int = 80, + rows: int = 24, + envs: dict[str, str] | None = None, + cwd: str | None = None, + ) -> Iterator[PtySession]: + """Open an interactive PTY session. + + Args: + cmd: Command to run. Defaults to /bin/bash. + args: Command arguments. + cols: Terminal columns. Defaults to 80. + rows: Terminal rows. Defaults to 24. + envs: Environment variables. + cwd: Working directory. + + Returns: + A PtySession context manager. Use with a ``with`` statement. + """ + assert self._http is not None + assert self.id is not None + with httpx_ws.connect_ws( # type: ignore[attr-defined] + f"/v1/capsules/{self.id}/pty", client=self._http + ) as ws: + session = PtySession(ws, self.id) + session._send_start( + cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd + ) + yield session + + @contextmanager + def pty_connect(self, tag: str) -> Iterator[PtySession]: + """Reconnect to an existing PTY session. + + Args: + tag: Session tag from a previous PtySession. + + Returns: + A PtySession context manager. + """ + assert self._http is not None + assert self.id is not None + with httpx_ws.connect_ws( + f"/v1/capsules/{self.id}/pty", client=self._http + ) as ws: + session = PtySession(ws, self.id) + session._send_connect(tag) + yield session + + @asynccontextmanager + async def async_pty( + self, + cmd: str = "/bin/bash", + args: list[str] | None = None, + cols: int = 80, + rows: int = 24, + envs: dict[str, str] | None = None, + cwd: str | None = None, + ) -> AsyncIterator[AsyncPtySession]: + """Async version of ``pty``.""" + assert self._async_http is not None + assert self.id is not None + async with httpx_ws.aconnect_ws( # type: ignore[attr-defined, misc] + f"/v1/capsules/{self.id}/pty", client=self._async_http + ) as ws: + session = AsyncPtySession(ws, self.id) + await session._send_start( + cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd + ) + yield session + + @asynccontextmanager + async def async_pty_connect(self, tag: str) -> AsyncIterator[AsyncPtySession]: + """Async version of ``pty_connect``.""" + assert self._async_http is not None + assert self.id is not None + async with httpx_ws.aconnect_ws( # type: ignore[attr-defined, misc] + f"/v1/capsules/{self.id}/pty", client=self._async_http + ) as ws: + session = AsyncPtySession(ws, self.id) + await session._send_connect(tag) + yield session + + def ping(self) -> None: + """Reset the capsule inactivity timer.""" + assert self._http is not None + resp = self._http.post(f"/v1/capsules/{self.id}/ping") + resp.raise_for_status() + + async def async_ping(self) -> None: + """Async version of ``ping``.""" + assert self._async_http is not None + resp = await self._async_http.post(f"/v1/capsules/{self.id}/ping") + resp.raise_for_status() + + def pause(self) -> Capsule: + """Pause the capsule (snapshot and release resources). + + Returns: + Updated ``Capsule`` with new status. + """ + assert self._http is not None + resp = self._http.post(f"/v1/capsules/{self.id}/pause") + resp.raise_for_status() + updated = Capsule.model_validate(resp.json()) + self.status = updated.status + return self + + async def async_pause(self) -> Capsule: + """Async version of ``pause``.""" + assert self._async_http is not None + resp = await self._async_http.post(f"/v1/capsules/{self.id}/pause") + resp.raise_for_status() + updated = Capsule.model_validate(resp.json()) + self.status = updated.status + return self + + def resume(self) -> Capsule: + """Resume a paused capsule from its snapshot. + + Returns: + Updated ``Capsule`` with new status. + """ + assert self._http is not None + resp = self._http.post(f"/v1/capsules/{self.id}/resume") + resp.raise_for_status() + updated = Capsule.model_validate(resp.json()) + self.status = updated.status + return self + + async def async_resume(self) -> Capsule: + """Async version of ``resume``.""" + assert self._async_http is not None + resp = await self._async_http.post(f"/v1/capsules/{self.id}/resume") + resp.raise_for_status() + updated = Capsule.model_validate(resp.json()) + self.status = updated.status + return self + + def destroy(self) -> None: + """Tear down the capsule.""" + assert self._http is not None + resp = self._http.delete(f"/v1/capsules/{self.id}") + resp.raise_for_status() + + async def async_destroy(self) -> None: + """Async version of ``destroy``.""" + assert self._async_http is not None + resp = await self._async_http.delete(f"/v1/capsules/{self.id}") + resp.raise_for_status() + + def _ensure_kernel(self, jupyter_timeout: float = 30) -> str: + """Ensure a Jupyter kernel is running, creating one if needed. + + Polls the Jupyter server until it responds, then creates a kernel. + + Args: + jupyter_timeout: Maximum seconds to wait for Jupyter to become available. + + Returns: + The kernel ID. + + Raises: + TimeoutError: If Jupyter doesn't respond within the timeout. + """ + current_kernel = self._kernel_id + if current_kernel is not None: + return current_kernel + deadline = time.monotonic() + jupyter_timeout + last_exc: Exception | None = None + while time.monotonic() < deadline: + try: + resp = self.http_client.post("/api/kernels") + if resp.status_code < 500: + resp.raise_for_status() + data = resp.json() + self._kernel_id = data["id"] + return str(self._kernel_id) + last_exc = httpx.HTTPStatusError( + f"Jupyter returned {resp.status_code}", + request=resp.request, + response=resp, + ) + except httpx.HTTPStatusError: + raise + except Exception as exc: + last_exc = exc + time.sleep(0.5) + raise TimeoutError( + f"Jupyter not available within {jupyter_timeout}s: {last_exc}" + ) + + async def _async_ensure_kernel(self, jupyter_timeout: float = 30) -> str: + """Async version of ``_ensure_kernel``.""" + import asyncio + + current_kernel = self._kernel_id + if current_kernel is not None: + return current_kernel + + if self._async_proxy_client is None: + url = ( + _build_proxy_url(self._base_url, self.id, 8888) + .replace("ws://", "http://") + .replace("wss://", "https://") + ) + self._async_proxy_client = httpx.AsyncClient( + base_url=url, + headers=self._proxy_headers(), + ) + + deadline = time.monotonic() + jupyter_timeout + last_exc: Exception | None = None + while time.monotonic() < deadline: + try: + resp = await self._async_proxy_client.post("/api/kernels") + if resp.status_code < 500: + resp.raise_for_status() + data = resp.json() + self._kernel_id = data["id"] + return str(self._kernel_id) + last_exc = httpx.HTTPStatusError( + f"Jupyter returned {resp.status_code}", + request=resp.request, + response=resp, + ) + except httpx.HTTPStatusError: + raise + except Exception as exc: + last_exc = exc + await asyncio.sleep(0.5) + raise TimeoutError( + f"Jupyter not available within {jupyter_timeout}s: {last_exc}" + ) + + def _jupyter_ws_url(self, kernel_id: str) -> str: + proxy = _build_proxy_url(self._base_url, self.id, 8888) + return f"{proxy}/api/kernels/{kernel_id}/channels" + + def _jupyter_execute_request(self, code: str) -> dict: + msg_id = str(uuid.uuid4()) + return { + "header": { + "msg_id": msg_id, + "msg_type": "execute_request", + "username": "wrenn-sdk", + "session": str(uuid.uuid4()), + "date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), + "version": "5.3", + }, + "parent_header": {}, + "metadata": {}, + "content": { + "code": code, + "silent": False, + "store_history": True, + "user_expressions": {}, + "allow_stdin": False, + "stop_on_error": True, + }, + "buffers": [], + "channel": "shell", + "msg_id": msg_id, + "msg_type": "execute_request", + } + + def run_code( + self, + code: str, + language: str = "python", + timeout: float = 30, + jupyter_timeout: float = 30, + ) -> CodeResult: + """Execute code in a persistent kernel inside the capsule. + + Variables, imports, and function definitions survive across calls. + + Args: + code: Code string to execute. + language: Execution backend language. Currently only ``"python"``. + timeout: Maximum seconds to wait for execution to complete. + jupyter_timeout: Maximum seconds to wait for Jupyter to become available. + + Returns: + A ``CodeResult`` with ``.text``, ``.data``, ``.stdout``, ``.stderr``, ``.error``. + """ + assert self._http is not None + kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout) + ws_url = self._jupyter_ws_url(kernel_id) + + msg = self._jupyter_execute_request(code) + msg_id = msg["msg_id"] + + result = CodeResult() + deadline = time.monotonic() + timeout + + headers = self._proxy_headers() + + with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: ignore[attr-defined, var-annotated] + ws.send_text(json.dumps(msg)) + while time.monotonic() < deadline: + time_left = deadline - time.monotonic() + if time_left <= 0: + break + try: + data = ws.receive_json(timeout=time_left) + except (TimeoutError, Exception): + break + if not data: + break + parent = data.get("parent_header", {}).get("msg_id") + if parent != msg_id: + continue + msg_type = data.get("msg_type") or data.get("header", {}).get( + "msg_type" + ) + content = data.get("content", {}) + + if msg_type == "stream": + name = content.get("name", "stdout") + if name == "stderr": + result.stderr += content.get("text", "") + else: + result.stdout += content.get("text", "") + elif msg_type == "execute_result": + bundle = content.get("data", {}) + result.text = bundle.get("text/plain") + result.data = bundle + elif msg_type == "error": + traceback = content.get("traceback", []) + result.error = "\n".join(traceback) + elif msg_type == "status" and content.get("execution_state") == "idle": + break + + return result + + async def async_run_code( + self, + code: str, + language: str = "python", + timeout: float = 30, + jupyter_timeout: float = 30, + ) -> CodeResult: + """Async version of ``run_code``.""" + assert self._async_http is not None + kernel_id = await self._async_ensure_kernel(jupyter_timeout=jupyter_timeout) + ws_url = self._jupyter_ws_url(kernel_id) + + msg = self._jupyter_execute_request(code) + msg_id = msg["msg_id"] + + result = CodeResult() + deadline = time.monotonic() + timeout + + headers = self._proxy_headers() + + async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: ignore[attr-defined, var-annotated] + await ws.send_text(json.dumps(msg)) + while time.monotonic() < deadline: + time_left = deadline - time.monotonic() + if time_left <= 0: + break + + try: + data = await asyncio.wait_for(ws.receive_json(), timeout=time_left) # type: ignore[misc] + except (asyncio.TimeoutError, Exception): + break + + if not data: + break + + parent = data.get("parent_header", {}).get("msg_id") + if parent != msg_id: + continue + msg_type = data.get("msg_type") or data.get("header", {}).get( + "msg_type" + ) + content = data.get("content", {}) + + if msg_type == "stream": + name = content.get("name", "stdout") + if name == "stderr": + result.stderr += content.get("text", "") + else: + result.stdout += content.get("text", "") + elif msg_type == "execute_result": + bundle = content.get("data", {}) + result.text = bundle.get("text/plain") + result.data = bundle + elif msg_type == "error": + traceback = content.get("traceback", []) + result.error = "\n".join(traceback) + elif msg_type == "status" and content.get("execution_state") == "idle": + break + + return result + + def _cleanup(self) -> None: + if self._proxy_client is not None: + try: + self._proxy_client.close() + except Exception: + pass + self._proxy_client = None + + async def _async_cleanup(self) -> None: + if self._async_proxy_client is not None: + try: + await self._async_proxy_client.aclose() + except Exception: + pass + self._async_proxy_client = None + + def __enter__(self) -> Capsule: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object, + ) -> None: + try: + self.destroy() + except Exception: + pass + self._cleanup() + + async def __aenter__(self) -> Capsule: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object, + ) -> None: + try: + await self.async_destroy() + except Exception: + pass + await self._async_cleanup() + + +def __getattr__(name: str) -> type: + if name == "Sandbox": + warnings.warn( + "'Sandbox' is deprecated, use 'Capsule' instead", + DeprecationWarning, + stacklevel=2, + ) + return Capsule + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/wrenn/client.py b/src/wrenn/client.py index bd7fb69..4c06b35 100644 --- a/src/wrenn/client.py +++ b/src/wrenn/client.py @@ -1,10 +1,12 @@ from __future__ import annotations import builtins +import warnings from typing import cast import httpx +from wrenn.capsule import Capsule from wrenn.exceptions import handle_response from wrenn.models import ( APIKeyResponse, @@ -14,9 +16,8 @@ from wrenn.models import ( Template, ) from wrenn.models import ( - Sandbox as SandboxModel, + Capsule as CapsuleModel, ) -from wrenn.sandbox import Sandbox DEFAULT_BASE_URL = "https://api.wrenn.dev" @@ -112,8 +113,8 @@ class AsyncAPIKeysResource: handle_response(resp) -class SandboxesResource: - """Sync sandbox control-plane operations.""" +class CapsulesResource: + """Sync capsule control-plane operations.""" def __init__( self, @@ -133,7 +134,7 @@ class SandboxesResource: vcpus: int | None = None, memory_mb: int | None = None, timeout_sec: int | None = None, - ) -> Sandbox: + ) -> Capsule: payload: dict = {} if template is not None: payload["template"] = template @@ -143,27 +144,27 @@ class SandboxesResource: payload["memory_mb"] = memory_mb if timeout_sec is not None: payload["timeout_sec"] = timeout_sec - resp = self._http.post("/v1/sandboxes", json=payload) - model = SandboxModel.model_validate(handle_response(resp)) - sb = Sandbox.model_validate(model.model_dump()) - sb._bind(self._http, self._base_url, self._api_key, self._token) - return sb + resp = self._http.post("/v1/capsules", json=payload) + model = CapsuleModel.model_validate(handle_response(resp)) + cap = Capsule.model_validate(model.model_dump()) + cap._bind(self._http, self._base_url, self._api_key, self._token) + return cap - def list(self) -> list[SandboxModel]: - resp = self._http.get("/v1/sandboxes") - return [SandboxModel.model_validate(item) for item in handle_response(resp)] + def list(self) -> list[CapsuleModel]: + resp = self._http.get("/v1/capsules") + return [CapsuleModel.model_validate(item) for item in handle_response(resp)] - def get(self, id: str) -> SandboxModel: - resp = self._http.get(f"/v1/sandboxes/{id}") - return SandboxModel.model_validate(handle_response(resp)) + def get(self, id: str) -> CapsuleModel: + resp = self._http.get(f"/v1/capsules/{id}") + return CapsuleModel.model_validate(handle_response(resp)) def destroy(self, id: str) -> None: - resp = self._http.delete(f"/v1/sandboxes/{id}") + resp = self._http.delete(f"/v1/capsules/{id}") handle_response(resp) -class AsyncSandboxesResource: - """Async sandbox control-plane operations.""" +class AsyncCapsulesResource: + """Async capsule control-plane operations.""" def __init__( self, @@ -183,7 +184,7 @@ class AsyncSandboxesResource: vcpus: int | None = None, memory_mb: int | None = None, timeout_sec: int | None = None, - ) -> Sandbox: + ) -> Capsule: payload: dict = {} if template is not None: payload["template"] = template @@ -193,22 +194,22 @@ class AsyncSandboxesResource: payload["memory_mb"] = memory_mb if timeout_sec is not None: payload["timeout_sec"] = timeout_sec - resp = await self._http.post("/v1/sandboxes", json=payload) - model = SandboxModel.model_validate(handle_response(resp)) - sb = Sandbox.model_validate(model.model_dump()) - sb._bind(self._http, self._base_url, self._api_key, self._token) - return sb + resp = await self._http.post("/v1/capsules", json=payload) + model = CapsuleModel.model_validate(handle_response(resp)) + cap = Capsule.model_validate(model.model_dump()) + cap._bind(self._http, self._base_url, self._api_key, self._token) + return cap - async def list(self) -> list[SandboxModel]: - resp = await self._http.get("/v1/sandboxes") - return [SandboxModel.model_validate(item) for item in handle_response(resp)] + async def list(self) -> list[CapsuleModel]: + resp = await self._http.get("/v1/capsules") + return [CapsuleModel.model_validate(item) for item in handle_response(resp)] - async def get(self, id: str) -> SandboxModel: - resp = await self._http.get(f"/v1/sandboxes/{id}") - return SandboxModel.model_validate(handle_response(resp)) + async def get(self, id: str) -> CapsuleModel: + resp = await self._http.get(f"/v1/capsules/{id}") + return CapsuleModel.model_validate(handle_response(resp)) async def destroy(self, id: str) -> None: - resp = await self._http.delete(f"/v1/sandboxes/{id}") + resp = await self._http.delete(f"/v1/capsules/{id}") handle_response(resp) @@ -220,11 +221,11 @@ class SnapshotsResource: def create( self, - sandbox_id: str, + capsule_id: str, name: str | None = None, overwrite: bool = False, ) -> Template: - payload: dict = {"sandbox_id": sandbox_id} + payload: dict = {"sandbox_id": capsule_id} if name is not None: payload["name"] = name params: dict = {} @@ -253,11 +254,11 @@ class AsyncSnapshotsResource: async def create( self, - sandbox_id: str, + capsule_id: str, name: str | None = None, overwrite: bool = False, ) -> Template: - payload: dict = {"sandbox_id": sandbox_id} + payload: dict = {"sandbox_id": capsule_id} if name is not None: payload["name"] = name params: dict = {} @@ -410,10 +411,19 @@ class WrennClient: self.auth = AuthResource(self._http) self.api_keys = APIKeysResource(self._http) - self.sandboxes = SandboxesResource(self._http, base_url, api_key, token) + self.capsules = CapsulesResource(self._http, base_url, api_key, token) self.snapshots = SnapshotsResource(self._http) self.hosts = HostsResource(self._http) + @property + def sandboxes(self) -> CapsulesResource: + warnings.warn( + "'client.sandboxes' is deprecated, use 'client.capsules' instead", + DeprecationWarning, + stacklevel=2, + ) + return self.capsules + def close(self) -> None: """Close the underlying HTTP connection pool.""" self._http.close() @@ -458,10 +468,19 @@ class AsyncWrennClient: self.auth = AsyncAuthResource(self._http) self.api_keys = AsyncAPIKeysResource(self._http) - self.sandboxes = AsyncSandboxesResource(self._http, base_url, api_key, token) + self.capsules = AsyncCapsulesResource(self._http, base_url, api_key, token) self.snapshots = AsyncSnapshotsResource(self._http) self.hosts = AsyncHostsResource(self._http) + @property + def sandboxes(self) -> AsyncCapsulesResource: + warnings.warn( + "'client.sandboxes' is deprecated, use 'client.capsules' instead", + DeprecationWarning, + stacklevel=2, + ) + return self.capsules + async def aclose(self) -> None: """Close the underlying async HTTP connection pool.""" await self._http.aclose() diff --git a/src/wrenn/exceptions.py b/src/wrenn/exceptions.py index 713aff7..c4b39d8 100644 --- a/src/wrenn/exceptions.py +++ b/src/wrenn/exceptions.py @@ -1,5 +1,7 @@ from __future__ import annotations +import warnings + import httpx @@ -33,15 +35,24 @@ class WrennConflictError(WrennError): """409 — State conflict (e.g. invalid_state).""" -class WrennHostHasSandboxesError(WrennConflictError): - """409 — Host still has running sandboxes.""" +class WrennHostHasCapsulesError(WrennConflictError): + """409 — Host still has running capsules.""" def __init__( - self, code: str, message: str, status_code: int, sandbox_ids: list[str] + self, code: str, message: str, status_code: int, capsule_ids: list[str] ) -> None: - self.sandbox_ids = sandbox_ids + self.capsule_ids = capsule_ids super().__init__(code, message, status_code) + @property + def sandbox_ids(self) -> list[str]: + warnings.warn( + "'sandbox_ids' is deprecated, use 'capsule_ids' instead", + DeprecationWarning, + stacklevel=2, + ) + return self.capsule_ids + class WrennHostUnavailableError(WrennError): """503 — No suitable host available.""" @@ -62,7 +73,8 @@ _ERROR_MAP: dict[str, type[WrennError]] = { "not_found": WrennNotFoundError, "invalid_state": WrennConflictError, "conflict": WrennConflictError, - "host_has_sandboxes": WrennHostHasSandboxesError, + "host_has_sandboxes": WrennHostHasCapsulesError, + "host_has_capsules": WrennHostHasCapsulesError, "host_unavailable": WrennHostUnavailableError, "agent_error": WrennAgentError, "internal_error": WrennInternalError, @@ -83,12 +95,12 @@ def handle_response(resp: httpx.Response) -> dict | list: exc_cls = _ERROR_MAP.get(code, WrennError) - if exc_cls is WrennHostHasSandboxesError: - raise WrennHostHasSandboxesError( + if exc_cls is WrennHostHasCapsulesError: + raise WrennHostHasCapsulesError( code=code, message=message, status_code=resp.status_code, - sandbox_ids=body.get("sandbox_ids", []), + capsule_ids=body.get("sandbox_ids", []), ) raise exc_cls( @@ -101,3 +113,14 @@ def handle_response(resp: httpx.Response) -> dict | list: return {} return resp.json() + + +def __getattr__(name: str) -> type: + if name == "WrennHostHasSandboxesError": + warnings.warn( + "'WrennHostHasSandboxesError' is deprecated, use 'WrennHostHasCapsulesError' instead", + DeprecationWarning, + stacklevel=2, + ) + return WrennHostHasCapsulesError + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/wrenn/models/__init__.py b/src/wrenn/models/__init__.py index 7e51557..5628e11 100644 --- a/src/wrenn/models/__init__.py +++ b/src/wrenn/models/__init__.py @@ -1,10 +1,11 @@ from wrenn.models._generated import ( APIKeyResponse, AuthResponse, + Capsule, CreateAPIKeyRequest, + CreateCapsuleRequest, CreateHostRequest, CreateHostResponse, - CreateSandboxRequest, CreateSnapshotRequest, Encoding, Error, @@ -22,7 +23,6 @@ from wrenn.models._generated import ( RegisterHostRequest, RegisterHostResponse, RemoveRequest, - Sandbox, SignupRequest, Status, Status1, @@ -38,7 +38,7 @@ __all__ = [ "CreateAPIKeyRequest", "CreateHostRequest", "CreateHostResponse", - "CreateSandboxRequest", + "CreateCapsuleRequest", "CreateSnapshotRequest", "Encoding", "Error", @@ -56,7 +56,7 @@ __all__ = [ "RegisterHostRequest", "RegisterHostResponse", "RemoveRequest", - "Sandbox", + "Capsule", "SignupRequest", "Status", "Status1", diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py index a211a9b..55a5742 100644 --- a/src/wrenn/models/_generated.py +++ b/src/wrenn/models/_generated.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-04-11T15:00:55+00:00 +# timestamp: 2026-04-12T20:56:29+00:00 from __future__ import annotations @@ -22,7 +22,7 @@ class LoginRequest(BaseModel): class AuthResponse(BaseModel): - token: Annotated[str | None, Field(description="JWT token (valid for 6 hours)")] = ( + token: Annotated[str | None, Field(description='JWT token (valid for 6 hours)')] = ( None ) user_id: str | None = None @@ -32,7 +32,7 @@ class AuthResponse(BaseModel): class CreateAPIKeyRequest(BaseModel): - name: str | None = "Unnamed API Key" + name: str | None = 'Unnamed API Key' class APIKeyResponse(BaseModel): @@ -47,29 +47,29 @@ class APIKeyResponse(BaseModel): key: Annotated[ str | None, Field( - description="Full plaintext key. Only returned on creation, never again." + description='Full plaintext key. Only returned on creation, never again.' ), ] = None -class CreateSandboxRequest(BaseModel): - template: str | None = "minimal" +class CreateCapsuleRequest(BaseModel): + template: str | None = 'minimal' vcpus: int | None = 1 memory_mb: int | None = 512 timeout_sec: Annotated[ int | None, Field( - description="Auto-pause TTL in seconds. The sandbox is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause.\n" + description='Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause.\n' ), ] = 0 class Range(StrEnum): - field_5m = "5m" - field_1h = "1h" - field_6h = "6h" - field_24h = "24h" - field_30d = "30d" + field_5m = '5m' + field_1h = '1h' + field_6h = '6h' + field_24h = '24h' + field_30d = '30d' class Current(BaseModel): @@ -100,29 +100,29 @@ class Series(BaseModel): memory_mb: list[int] | None = None -class SandboxStats(BaseModel): +class CapsuleStats(BaseModel): range: Range | None = None current: Current | None = None peaks: Annotated[ - Peaks | None, Field(description="Maximum values over the last 30 days.") + Peaks | None, Field(description='Maximum values over the last 30 days.') ] = None series: Annotated[ - Series | None, Field(description="Parallel arrays for chart rendering.") + Series | None, Field(description='Parallel arrays for chart rendering.') ] = None class Status(StrEnum): - pending = "pending" - starting = "starting" - running = "running" - paused = "paused" - hibernated = "hibernated" - stopped = "stopped" - missing = "missing" - error = "error" + pending = 'pending' + starting = 'starting' + running = 'running' + paused = 'paused' + hibernated = 'hibernated' + stopped = 'stopped' + missing = 'missing' + error = 'error' -class Sandbox(BaseModel): +class Capsule(BaseModel): id: str | None = None status: Status | None = None template: str | None = None @@ -139,17 +139,17 @@ class Sandbox(BaseModel): class CreateSnapshotRequest(BaseModel): sandbox_id: Annotated[ - str, Field(description="ID of the running sandbox to snapshot.") + str, Field(description='ID of the running capsule to snapshot.') ] name: Annotated[ str | None, - Field(description="Name for the snapshot template. Auto-generated if omitted."), + Field(description='Name for the snapshot template. Auto-generated if omitted.'), ] = None class Type(StrEnum): - base = "base" - snapshot = "snapshot" + base = 'base' + snapshot = 'snapshot' class Template(BaseModel): @@ -172,8 +172,8 @@ class Encoding(StrEnum): Output encoding. "base64" when stdout/stderr contain binary data. """ - utf_8 = "utf-8" - base64 = "base64" + utf_8 = 'utf-8' + base64 = 'base64' class ExecResponse(BaseModel): @@ -192,23 +192,23 @@ class ExecResponse(BaseModel): class ReadFileRequest(BaseModel): - path: Annotated[str, Field(description="Absolute file path inside the sandbox")] + path: Annotated[str, Field(description='Absolute file path inside the capsule')] class ListDirRequest(BaseModel): - path: Annotated[str, Field(description="Directory path inside the sandbox")] + path: Annotated[str, Field(description='Directory path inside the capsule')] depth: Annotated[ int | None, Field( - description="Recursion depth (0 = non-recursive, 1 = immediate children)" + description='Recursion depth (0 = non-recursive, 1 = immediate children)' ), ] = 1 class Type1(StrEnum): - file = "file" - directory = "directory" - symlink = "symlink" + file = 'file' + directory = 'directory' + symlink = 'symlink' class FileEntry(BaseModel): @@ -223,14 +223,14 @@ class FileEntry(BaseModel): owner: str | None = None group: str | None = None modified_at: Annotated[ - int | None, Field(description="Unix timestamp (seconds)") + int | None, Field(description='Unix timestamp (seconds)') ] = None symlink_target: str | None = None class MakeDirRequest(BaseModel): path: Annotated[ - str, Field(description="Directory path to create inside the sandbox") + str, Field(description='Directory path to create inside the capsule') ] @@ -239,7 +239,7 @@ class MakeDirResponse(BaseModel): class RemoveRequest(BaseModel): - path: Annotated[str, Field(description="Path to remove inside the sandbox")] + path: Annotated[str, Field(description='Path to remove inside the capsule')] class Type2(StrEnum): @@ -247,51 +247,51 @@ class Type2(StrEnum): Host type. Regular hosts are shared; BYOC hosts belong to a team. """ - regular = "regular" - byoc = "byoc" + regular = 'regular' + byoc = 'byoc' class CreateHostRequest(BaseModel): type: Annotated[ Type2, Field( - description="Host type. Regular hosts are shared; BYOC hosts belong to a team." + description='Host type. Regular hosts are shared; BYOC hosts belong to a team.' ), ] - team_id: Annotated[str | None, Field(description="Required for BYOC hosts.")] = None + team_id: Annotated[str | None, Field(description='Required for BYOC hosts.')] = None provider: Annotated[ str | None, - Field(description="Cloud provider (e.g. aws, gcp, hetzner, bare-metal)."), + Field(description='Cloud provider (e.g. aws, gcp, hetzner, bare-metal).'), ] = None availability_zone: Annotated[ - str | None, Field(description="Availability zone (e.g. us-east, eu-west).") + str | None, Field(description='Availability zone (e.g. us-east, eu-west).') ] = None class RegisterHostRequest(BaseModel): token: Annotated[ - str, Field(description="One-time registration token from POST /v1/hosts.") + str, Field(description='One-time registration token from POST /v1/hosts.') ] arch: Annotated[ - str | None, Field(description="CPU architecture (e.g. x86_64, aarch64).") + str | None, Field(description='CPU architecture (e.g. x86_64, aarch64).') ] = None cpu_cores: int | None = None memory_mb: int | None = None disk_gb: int | None = None - address: Annotated[str, Field(description="Host agent address (ip:port).")] + address: Annotated[str, Field(description='Host agent address (ip:port).')] class Type3(StrEnum): - regular = "regular" - byoc = "byoc" + regular = 'regular' + byoc = 'byoc' class Status1(StrEnum): - pending = "pending" - online = "online" - offline = "offline" - draining = "draining" - unreachable = "unreachable" + pending = 'pending' + online = 'online' + offline = 'offline' + draining = 'draining' + unreachable = 'unreachable' class Host(BaseModel): @@ -316,7 +316,7 @@ class RefreshHostTokenRequest(BaseModel): refresh_token: Annotated[ str, Field( - description="Refresh token obtained from registration or a previous refresh." + description='Refresh token obtained from registration or a previous refresh.' ), ] @@ -324,12 +324,12 @@ class RefreshHostTokenRequest(BaseModel): class RefreshHostTokenResponse(BaseModel): host: Host | None = None token: Annotated[ - str | None, Field(description="New host JWT. Valid for 7 days.") + str | None, Field(description='New host JWT. Valid for 7 days.') ] = None refresh_token: Annotated[ str | None, Field( - description="New refresh token. Valid for 60 days; old token is revoked." + description='New refresh token. Valid for 60 days; old token is revoked.' ), ] = None @@ -338,20 +338,20 @@ class HostDeletePreview(BaseModel): host: Host | None = None sandbox_ids: Annotated[ list[str] | None, - Field(description="IDs of sandboxes that would be destroyed on force-delete."), + Field(description='IDs of capsulees that would be destroyed on force-delete.'), ] = None class Error(BaseModel): - code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None + code: Annotated[str | None, Field(examples=['host_has_sandboxes'])] = None message: str | None = None sandbox_ids: Annotated[ list[str] | None, - Field(description="IDs of active sandboxes blocking deletion."), + Field(description='IDs of active capsulees blocking deletion.'), ] = None -class HostHasSandboxesError(BaseModel): +class HostHasCapsulesError(BaseModel): error: Error | None = None @@ -368,15 +368,15 @@ class Team(BaseModel): id: str | None = None name: str | None = None slug: Annotated[ - str | None, Field(description="Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)") + str | None, Field(description='Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)') ] = None created_at: AwareDatetime | None = None class Role(StrEnum): - owner = "owner" - admin = "admin" - member = "member" + owner = 'owner' + admin = 'admin' + member = 'member' class TeamWithRole(Team): @@ -396,13 +396,13 @@ class TeamDetail(BaseModel): class Range1(StrEnum): - field_5m = "5m" - field_10m = "10m" - field_1h = "1h" - field_2h = "2h" - field_6h = "6h" - field_12h = "12h" - field_24h = "24h" + field_5m = '5m' + field_10m = '10m' + field_1h = '1h' + field_2h = '2h' + field_6h = '6h' + field_12h = '12h' + field_24h = '24h' class MetricPoint(BaseModel): @@ -410,41 +410,41 @@ class MetricPoint(BaseModel): cpu_pct: Annotated[ float | None, Field( - description="CPU utilization percentage (0-100), normalized to vCPU count" + description='CPU utilization percentage (0-100), normalized to vCPU count' ), ] = None mem_bytes: Annotated[ int | None, - Field(description="Resident memory in bytes (VmRSS of Firecracker process)"), + Field(description='Resident memory in bytes (VmRSS of Firecracker process)'), ] = None disk_bytes: Annotated[ - int | None, Field(description="Allocated disk bytes for the CoW sparse file") + int | None, Field(description='Allocated disk bytes for the CoW sparse file') ] = None class Provider(StrEnum): - discord = "discord" - slack = "slack" - teams = "teams" - googlechat = "googlechat" - telegram = "telegram" - matrix = "matrix" - webhook = "webhook" + discord = 'discord' + slack = 'slack' + teams = 'teams' + googlechat = 'googlechat' + telegram = 'telegram' + matrix = 'matrix' + webhook = 'webhook' class Event(StrEnum): - capsule_created = "capsule.created" - capsule_running = "capsule.running" - capsule_paused = "capsule.paused" - capsule_destroyed = "capsule.destroyed" - template_snapshot_created = "template.snapshot.created" - template_snapshot_deleted = "template.snapshot.deleted" - host_up = "host.up" - host_down = "host.down" + capsule_created = 'capsule.created' + capsule_running = 'capsule.running' + capsule_paused = 'capsule.paused' + capsule_destroyed = 'capsule.destroyed' + template_snapshot_created = 'template.snapshot.created' + template_snapshot_deleted = 'template.snapshot.deleted' + host_up = 'host.up' + host_down = 'host.down' class CreateChannelRequest(BaseModel): - name: Annotated[str, Field(description="Unique channel name within the team.")] + name: Annotated[str, Field(description='Unique channel name within the team.')] provider: Provider config: Annotated[ dict[str, str], @@ -460,7 +460,7 @@ class TestChannelRequest(BaseModel): config: Annotated[ dict[str, str], Field( - description="Provider-specific configuration fields (same as CreateChannelRequest.config)." + description='Provider-specific configuration fields (same as CreateChannelRequest.config).' ), ] @@ -489,7 +489,7 @@ class ChannelResponse(BaseModel): updated_at: AwareDatetime | None = None secret: Annotated[ str | None, - Field(description="Webhook secret. Only returned on creation, never again."), + Field(description='Webhook secret. Only returned on creation, never again.'), ] = None @@ -511,7 +511,7 @@ class CreateHostResponse(BaseModel): registration_token: Annotated[ str | None, Field( - description="One-time registration token for the host agent. Expires in 1 hour." + description='One-time registration token for the host agent. Expires in 1 hour.' ), ] = None @@ -520,17 +520,17 @@ class RegisterHostResponse(BaseModel): host: Host | None = None token: Annotated[ str | None, - Field(description="Host JWT for X-Host-Token header. Valid for 7 days."), + Field(description='Host JWT for X-Host-Token header. Valid for 7 days.'), ] = None refresh_token: Annotated[ str | None, Field( - description="Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use." + description='Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use.' ), ] = None -class SandboxMetrics(BaseModel): +class CapsuleMetrics(BaseModel): sandbox_id: str | None = None range: Range1 | None = None points: list[MetricPoint] | None = None diff --git a/src/wrenn/pty.py b/src/wrenn/pty.py index cde476c..83ee871 100644 --- a/src/wrenn/pty.py +++ b/src/wrenn/pty.py @@ -66,9 +66,9 @@ class PtySession: break """ - def __init__(self, ws: httpx_ws.WebSocketSession, sandbox_id: str) -> None: + def __init__(self, ws: httpx_ws.WebSocketSession, capsule_id: str) -> None: self._ws = ws - self._sandbox_id = sandbox_id + self._capsule_id = capsule_id self._tag: str | None = None self._pid: int | None = None self._done = False @@ -192,9 +192,9 @@ class AsyncPtySession: break """ - def __init__(self, ws: httpx_ws.AsyncWebSocketSession, sandbox_id: str) -> None: + def __init__(self, ws: httpx_ws.AsyncWebSocketSession, capsule_id: str) -> None: self._ws = ws - self._sandbox_id = sandbox_id + self._capsule_id = capsule_id self._tag: str | None = None self._pid: int | None = None self._done = False diff --git a/src/wrenn/sandbox.py b/src/wrenn/sandbox.py index 09b40de..09126f8 100644 --- a/src/wrenn/sandbox.py +++ b/src/wrenn/sandbox.py @@ -1,1181 +1,26 @@ -from __future__ import annotations +import warnings as _warnings -import asyncio -import base64 -import json -import os -import time -import uuid -from collections.abc import AsyncIterator, Iterator -from contextlib import asynccontextmanager, contextmanager -from typing import Any - -import httpx -import httpx_ws - -from wrenn.exceptions import handle_response -from wrenn.models import ( - ExecResponse, - FileEntry, - ListDirResponse, - MakeDirResponse, - Status, +from wrenn.capsule import ( # noqa: F401 + CodeResult, + ExecResult, + StreamErrorEvent, + StreamEvent, + StreamExitEvent, + StreamStartEvent, + StreamStderrEvent, + StreamStdoutEvent, + _build_proxy_url, + _parse_stream_event, ) -from wrenn.models import Sandbox as SandboxModel -from wrenn.pty import AsyncPtySession, PtySession +from wrenn.capsule import Capsule -class _IterableReader: - """Internal adapter to make iterables/generators act like files with a . - read() method""" - - def __init__(self, iterable: Any) -> None: - self.iterator = iter(iterable) - self.buffer = b"" - - def read(self, size: int = -1) -> bytes: - if size == -1: - return self.buffer + b"".join( - chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") - for chunk in self.iterator - ) - - while len(self.buffer) < size: - try: - chunk = next(self.iterator) - self.buffer += ( - chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") - ) - except StopIteration: - break - - result = self.buffer[:size] - self.buffer = self.buffer[size:] - return result - - -class ExecResult: - """Typed result from a synchronous exec call.""" - - __slots__ = ("stdout", "stderr", "exit_code", "duration_ms", "encoding") - - def __init__( - self, - stdout: str, - stderr: str, - exit_code: int, - duration_ms: int | None, - encoding: str | None, - ) -> None: - self.stdout = stdout - self.stderr = stderr - self.exit_code = exit_code - self.duration_ms = duration_ms - self.encoding = encoding - - -class CodeResult: - """Typed result from stateful code execution (``run_code``). - - Attributes: - text: text/plain representation of the result. - data: rich MIME bundle (e.g. ``{"image/png": "..."}``). - stdout: accumulated stdout output. - stderr: accumulated stderr output. - error: language-specific error/traceback string. - """ - - __slots__ = ("text", "data", "stdout", "stderr", "error") - - def __init__( - self, - text: str | None = None, - data: dict[str, str] | None = None, - stdout: str = "", - stderr: str = "", - error: str | None = None, - ) -> None: - self.text = text - self.data = data - self.stdout = stdout - self.stderr = stderr - self.error = error - - -class StreamEvent: - """Base class for streaming exec events.""" - - __slots__ = ("type",) - - def __init__(self, type: str) -> None: - self.type = type - - -class StreamStartEvent(StreamEvent): - """Process started.""" - - __slots__ = ("pid",) - - def __init__(self, pid: int) -> None: - super().__init__("start") - self.pid = pid - - -class StreamStdoutEvent(StreamEvent): - """Stdout data received.""" - - __slots__ = ("data",) - - def __init__(self, data: str) -> None: - super().__init__("stdout") - self.data = data - - -class StreamStderrEvent(StreamEvent): - """Stderr data received.""" - - __slots__ = ("data",) - - def __init__(self, data: str) -> None: - super().__init__("stderr") - self.data = data - - -class StreamExitEvent(StreamEvent): - """Process exited.""" - - __slots__ = ("exit_code",) - - def __init__(self, exit_code: int) -> None: - super().__init__("exit") - self.exit_code = exit_code - - -class StreamErrorEvent(StreamEvent): - """Error occurred.""" - - __slots__ = ("data",) - - def __init__(self, data: str) -> None: - super().__init__("error") - self.data = data - - -def _parse_stream_event(raw: dict) -> StreamEvent: - t = raw.get("type") - if t == "start": - return StreamStartEvent(pid=raw.get("pid", 0)) - if t == "stdout": - return StreamStdoutEvent(data=raw.get("data", "")) - if t == "stderr": - return StreamStderrEvent(data=raw.get("data", "")) - if t == "exit": - return StreamExitEvent(exit_code=raw.get("exit_code", -1)) - if t == "error": - return StreamErrorEvent(data=raw.get("data", "")) - return StreamEvent(type=t or "unknown") - - -def _build_proxy_url(base_url: str, sandbox_id: str | None, port: int) -> str: - parsed = httpx.URL(base_url) - host = parsed.host - if parsed.port: - host = f"{host}:{parsed.port}" - scheme = "ws" if parsed.scheme == "http" else "wss" - return f"{scheme}://{port}-{sandbox_id}.{host}" - - -class Sandbox(SandboxModel): - """Developer-facing sandbox interface wrapping the generated Sandbox model. - - Provides data-plane methods (exec, file I/O, lifecycle), sandbox proxy - helpers, and context-manager support for automatic cleanup. - """ - - _http: httpx.Client | None - _async_http: httpx.AsyncClient | None - _base_url: str - _api_key: str | None - _token: str | None - _proxy_client: httpx.Client | None - _async_proxy_client: httpx.AsyncClient | None - _kernel_id: str | None - _jupyter_ws: Any - _async_jupyter_ws: Any - - def _bind( - self, - http: httpx.Client | httpx.AsyncClient, - base_url: str, - api_key: str | None = None, - token: str | None = None, - ) -> None: - self._base_url = base_url - self._api_key = api_key - self._token = token - self._proxy_client = None - self._async_proxy_client = None - self._kernel_id = None - self._jupyter_ws = None - self._async_jupyter_ws = None - if isinstance(http, httpx.Client): - self._http = http - self._async_http = None - else: - self._http = None # type: ignore[assignment] - self._async_http = http - - def _proxy_headers(self) -> dict[str, str]: - headers: dict[str, str] = {} - if self._api_key: - headers["X-API-Key"] = self._api_key - if self._token: - headers["Authorization"] = f"Bearer {self._token}" - return headers - - def _clear_content_type(self) -> dict[str, str]: - assert self._http is not None - headers = dict(self._http.headers) - headers.pop("Content-Type", None) - return headers - - def _async_clear_content_type(self) -> dict[str, str]: - assert self._async_http is not None - headers = dict(self._async_http.headers) - headers.pop("Content-Type", None) - return headers - - def get_url(self, port: int) -> str: - """Construct the proxy URL for a port inside this sandbox. - - Args: - port: Port number of the service running inside the sandbox. - - Returns: - A URL string like ``http://8888-cl-abc123.api.wrenn.dev``. - """ - return _build_proxy_url(self._base_url, self.id, port) - - @property - def http_client(self) -> httpx.Client: - """A pre-configured ``httpx.Client`` targeting the sandbox proxy on port 8888. - - The client has auth headers set and ``base_url`` pointing to - the proxy URL for port 8888. Closed automatically when the sandbox exits. - """ - if self._proxy_client is None: - url = ( - _build_proxy_url(self._base_url, self.id, 8888) - .replace("ws://", "http://") - .replace("wss://", "https://") - ) - self._proxy_client = httpx.Client( - base_url=url, - headers=self._proxy_headers(), - ) - return self._proxy_client - - def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None: - """Block until the sandbox status is ``running``. - - Args: - timeout: Maximum seconds to wait. - interval: Seconds between polls. - - Raises: - TimeoutError: If the sandbox does not become ready in time. - """ - assert self._http is not None - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - resp = self._http.get(f"/v1/sandboxes/{self.id}") - data = resp.json() - status = data.get("status") - if status == Status.running: - self.status = Status.running - return - if status in (Status.error, Status.stopped): - raise RuntimeError(f"Sandbox entered {status} state while waiting") - time.sleep(interval) - raise TimeoutError(f"Sandbox {self.id} did not become ready within {timeout}s") - - async def async_wait_ready( - self, timeout: float = 30, interval: float = 0.5 - ) -> None: - """Async version of ``wait_ready``.""" - assert self._async_http is not None - import asyncio - - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - resp = await self._async_http.get(f"/v1/sandboxes/{self.id}") - data = resp.json() - status = data.get("status") - if status == Status.running: - self.status = Status.running - return - if status in (Status.error, Status.stopped): - raise RuntimeError(f"Sandbox entered {status} state while waiting") - await asyncio.sleep(interval) - raise TimeoutError(f"Sandbox {self.id} did not become ready within {timeout}s") - - def exec( - self, - cmd: str, - args: list[str] | None = None, - timeout_sec: int | None = 30, - ) -> ExecResult: - """Execute a command synchronously inside the sandbox. - - Args: - cmd: Command to run. - args: Optional positional arguments. - timeout_sec: Execution timeout in seconds. - - Returns: - An ``ExecResult`` with ``stdout``, ``stderr``, ``exit_code``, ``duration_ms``. - """ - assert self._http is not None - payload: dict = {"cmd": cmd} - if args is not None: - payload["args"] = args - if timeout_sec is not None: - payload["timeout_sec"] = timeout_sec - resp = self._http.post(f"/v1/sandboxes/{self.id}/exec", json=payload) - resp.raise_for_status() - er = ExecResponse.model_validate(resp.json()) - stdout = er.stdout or "" - stderr = er.stderr or "" - if er.encoding == "base64": - stdout = base64.b64decode(stdout).decode("utf-8", errors="replace") - if stderr: - stderr = base64.b64decode(stderr).decode("utf-8", errors="replace") - return ExecResult( - stdout=stdout, - stderr=stderr, - exit_code=er.exit_code if er.exit_code is not None else -1, - duration_ms=er.duration_ms, - encoding=er.encoding, +def __getattr__(name: str) -> type: + if name == "Sandbox": + _warnings.warn( + "'Sandbox' is deprecated, use 'Capsule' instead", + DeprecationWarning, + stacklevel=2, ) - - async def async_exec( - self, - cmd: str, - args: list[str] | None = None, - timeout_sec: int | None = 30, - ) -> ExecResult: - """Async version of ``exec``.""" - assert self._async_http is not None - payload: dict = {"cmd": cmd} - if args is not None: - payload["args"] = args - if timeout_sec is not None: - payload["timeout_sec"] = timeout_sec - resp = await self._async_http.post( - f"/v1/sandboxes/{self.id}/exec", json=payload - ) - resp.raise_for_status() - er = ExecResponse.model_validate(resp.json()) - stdout = er.stdout or "" - stderr = er.stderr or "" - if er.encoding == "base64": - stdout = base64.b64decode(stdout).decode("utf-8", errors="replace") - if stderr: - stderr = base64.b64decode(stderr).decode("utf-8", errors="replace") - return ExecResult( - stdout=stdout, - stderr=stderr, - exit_code=er.exit_code if er.exit_code is not None else -1, - duration_ms=er.duration_ms, - encoding=er.encoding, - ) - - def exec_stream( - self, - cmd: str, - args: list[str] | None = None, - ) -> Iterator[StreamEvent]: - """Execute a command via WebSocket, yielding ``StreamEvent`` objects. - - Args: - cmd: Command to run. - args: Optional positional arguments. - - Yields: - ``StreamStartEvent``, ``StreamStdoutEvent``, ``StreamStderrEvent``, - ``StreamExitEvent``, or ``StreamErrorEvent``. - """ - assert self._http is not None - with httpx_ws.connect_ws( # type: ignore[attr-defined] - f"/v1/sandboxes/{self.id}/exec/stream", - self._http, - ) as ws: - start_msg: dict = {"type": "start", "cmd": cmd} - if args: - start_msg["args"] = args - ws.send(json.dumps(start_msg)) - for raw_msg in ws: - event = _parse_stream_event(json.loads(raw_msg)) - yield event - if event.type in ("exit", "error"): - break - - async def async_exec_stream( - self, cmd: str, args: list[str] | None = None - ) -> AsyncIterator[StreamEvent]: - """Async version of ``exec_stream``.""" - assert self._async_http is not None - async with httpx_ws.aconnect_ws( # type: ignore[attr-defined, var-annotated] - f"/v1/sandboxes/{self.id}/exec/stream", self._async_http - ) as ws: - start_msg: dict = {"type": "start", "cmd": cmd} - if args: - start_msg["args"] = args - await ws.send_text(json.dumps(start_msg)) - - try: - while True: - raw_data = await ws.receive_json() - event = _parse_stream_event(raw_data) - yield event - - if event.type in ("exit", "error"): - break - except httpx_ws.WebSocketDisconnect: - pass - - def upload(self, path: str, data: bytes) -> None: - """Upload a small file to the sandbox. - - Args: - path: Absolute destination path inside the sandbox. - data: File contents as bytes. - """ - assert self._http is not None - resp = self._http.post( - f"/v1/sandboxes/{self.id}/files/write", - files={"file": ("upload", data)}, - data={"path": path}, - ) - - resp.raise_for_status() - - async def async_upload(self, path: str, data: bytes) -> None: - """Async version of ``upload``.""" - assert self._async_http is not None - resp = await self._async_http.post( - f"/v1/sandboxes/{self.id}/files/write", - files={"file": ("upload", data)}, - data={"path": path}, - ) - resp.raise_for_status() - - def download(self, path: str) -> bytes: - """Download a small file from the sandbox. - - Args: - path: Absolute file path inside the sandbox. - - Returns: - File contents as bytes. - """ - assert self._http is not None - resp = self._http.post( - f"/v1/sandboxes/{self.id}/files/read", - json={"path": path}, - ) - resp.raise_for_status() - return resp.content - - async def async_download(self, path: str) -> bytes: - """Async version of ``download``.""" - assert self._async_http is not None - resp = await self._async_http.post( - f"/v1/sandboxes/{self.id}/files/read", - json={"path": path}, - ) - resp.raise_for_status() - return resp.content - - def stream_upload(self, path: str, stream: Iterator[bytes]) -> None: - """Streaming upload for large files. - - Args: - path: Absolute destination path inside the sandbox. - stream: An iterator yielding byte chunks. - """ - assert self._http is not None - - boundary = os.urandom(16).hex().encode("utf-8") - - def _multipart_stream() -> Iterator[bytes]: - yield b"--" + boundary + b"\r\n" - yield b'Content-Disposition: form-data; name="path"\r\n\r\n' - yield path.encode("utf-8") + b"\r\n" - - yield b"--" + boundary + b"\r\n" - yield b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n' - yield b"Content-Type: application/octet-stream\r\n\r\n" - - for chunk in stream: - yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") - - yield b"\r\n--" + boundary + b"--\r\n" - - headers = { - "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}" - } - - resp = self._http.post( - f"/v1/sandboxes/{self.id}/files/stream/write", - content=_multipart_stream(), - headers=headers, - ) - resp.raise_for_status() - - async def async_stream_upload( - self, path: str, stream: AsyncIterator[bytes] - ) -> None: - """Async version of ``stream_upload``.""" - assert self._async_http is not None - - boundary = os.urandom(16).hex().encode("utf-8") - - async def _async_multipart_stream() -> AsyncIterator[bytes]: - yield b"--" + boundary + b"\r\n" - yield b'Content-Disposition: form-data; name="path"\r\n\r\n' - yield path.encode("utf-8") + b"\r\n" - - yield b"--" + boundary + b"\r\n" - yield b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n' - yield b"Content-Type: application/octet-stream\r\n\r\n" - - async for chunk in stream: - yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") - - yield b"\r\n--" + boundary + b"--\r\n" - - headers = { - "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}" - } - - # Use content= and headers= just like the sync version - resp = await self._async_http.post( - f"/v1/sandboxes/{self.id}/files/stream/write", - content=_async_multipart_stream(), - headers=headers, - ) - resp.raise_for_status() - - def stream_download(self, path: str) -> Iterator[bytes]: - """Streaming download for large files. - - Args: - path: Absolute file path inside the sandbox. - - Yields: - Byte chunks. - """ - assert self._http is not None - with self._http.stream( - "POST", - f"/v1/sandboxes/{self.id}/files/stream/read", - json={"path": path}, - ) as resp: - resp.raise_for_status() - yield from resp.iter_bytes() - - async def async_stream_download(self, path: str) -> AsyncIterator[bytes]: - """Async version of ``stream_download``.""" - assert self._async_http is not None - async with self._async_http.stream( - "POST", - f"/v1/sandboxes/{self.id}/files/stream/read", - json={"path": path}, - ) as resp: - resp.raise_for_status() - async for chunk in resp.aiter_bytes(): - yield chunk - - def list_dir(self, path: str, depth: int = 1) -> list[FileEntry]: - """List directory contents inside the sandbox. - - Args: - path: Absolute directory path. - depth: Recursion depth. 1 = immediate children only. - - Returns: - List of FileEntry objects with full metadata. - - Raises: - WrennValidationError: Invalid path. - WrennNotFoundError: Sandbox or directory not found. - WrennConflictError: Sandbox is not running. - WrennAgentError: Agent error. - WrennHostUnavailableError: Host agent not reachable. - """ - assert self._http is not None - resp = self._http.post( - f"/v1/sandboxes/{self.id}/files/list", - json={"path": path, "depth": depth}, - ) - data = handle_response(resp) - parsed = ListDirResponse.model_validate(data) - return parsed.entries or [] - - async def async_list_dir(self, path: str, depth: int = 1) -> list[FileEntry]: - """Async version of ``list_dir``.""" - assert self._async_http is not None - resp = await self._async_http.post( - f"/v1/sandboxes/{self.id}/files/list", - json={"path": path, "depth": depth}, - ) - data = handle_response(resp) - parsed = ListDirResponse.model_validate(data) - return parsed.entries or [] - - def mkdir(self, path: str) -> FileEntry: - """Create a directory inside the sandbox (with parents). - - Args: - path: Absolute directory path to create. - - Returns: - FileEntry for the created directory. - - Raises: - WrennValidationError: Path exists and is not a directory. - WrennConflictError: Directory already exists (returns existing entry). - Sandbox is not running. - WrennNotFoundError: Sandbox not found. - WrennAgentError: Agent error. - WrennHostUnavailableError: Host agent not reachable. - """ - assert self._http is not None - resp = self._http.post( - f"/v1/sandboxes/{self.id}/files/mkdir", - json={"path": path}, - ) - if resp.status_code == 409: - try: - body = resp.json() - err = body.get("error", {}) - if err.get("code") == "conflict": - parent_dir = os.path.dirname(path) - dir_name = os.path.basename(path) - - listing = self.list_dir(parent_dir, depth=0) - for entry in listing: - if entry.name == dir_name: - return entry - except Exception: - pass - data = handle_response(resp) - parsed = MakeDirResponse.model_validate(data) - entry = parsed.entry - if entry is None: - raise RuntimeError("mkdir response missing entry") - return entry - - async def async_mkdir(self, path: str) -> FileEntry: - """Async version of ``mkdir``.""" - assert self._async_http is not None - resp = await self._async_http.post( - f"/v1/sandboxes/{self.id}/files/mkdir", - json={"path": path}, - ) - if resp.status_code == 409: - try: - body = resp.json() - err = body.get("error", {}) - if err.get("code") == "conflict": - listing = await self.async_list_dir(path, depth=0) - parent_dir = os.path.dirname(path) - dir_name = os.path.basename(path) - - listing = self.list_dir(parent_dir, depth=0) - for entry in listing: - if entry.name == dir_name: - return entry - except Exception: - pass - data = handle_response(resp) - parsed = MakeDirResponse.model_validate(data) - entry = parsed.entry - if entry is None: - raise RuntimeError("mkdir response missing entry") - return entry - - def remove(self, path: str) -> None: - """Remove a file or directory inside the sandbox. - - Removes recursively. No confirmation or dry-run. Equivalent to rm -rf. - - Args: - path: Absolute path to remove. - - Raises: - WrennValidationError: Invalid path. - WrennNotFoundError: Sandbox not found. - WrennConflictError: Sandbox is not running. - WrennAgentError: Agent error. - WrennHostUnavailableError: Host agent not reachable. - """ - assert self._http is not None - resp = self._http.post( - f"/v1/sandboxes/{self.id}/files/remove", - json={"path": path}, - ) - handle_response(resp) - - async def async_remove(self, path: str) -> None: - """Async version of ``remove``.""" - assert self._async_http is not None - resp = await self._async_http.post( - f"/v1/sandboxes/{self.id}/files/remove", - json={"path": path}, - ) - handle_response(resp) - - @contextmanager - def pty( - self, - cmd: str = "/bin/bash", - args: list[str] | None = None, - cols: int = 80, - rows: int = 24, - envs: dict[str, str] | None = None, - cwd: str | None = None, - ) -> PtySession: - """Open an interactive PTY session. - - Args: - cmd: Command to run. Defaults to /bin/bash. - args: Command arguments. - cols: Terminal columns. Defaults to 80. - rows: Terminal rows. Defaults to 24. - envs: Environment variables. - cwd: Working directory. - - Returns: - A PtySession context manager. Use with a ``with`` statement. - """ - assert self._http is not None - with httpx_ws.connect_ws( - f"/v1/sandboxes/{self.id}/pty", client=self._http - ) as ws: - session = PtySession(ws, self.id) - session._send_start( - cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd - ) - yield session - - @contextmanager - def pty_connect(self, tag: str) -> PtySession: - """Reconnect to an existing PTY session. - - Args: - tag: Session tag from a previous PtySession. - - Returns: - A PtySession context manager. - """ - assert self._http is not None - with httpx_ws.connect_ws( - f"/v1/sandboxes/{self.id}/pty", client=self._http - ) as ws: - session = PtySession(ws, self.id) - session._send_connect(tag) - yield session - - @asynccontextmanager - async def async_pty( - self, - cmd: str = "/bin/bash", - args: list[str] | None = None, - cols: int = 80, - rows: int = 24, - envs: dict[str, str] | None = None, - cwd: str | None = None, - ) -> AsyncPtySession: - """Async version of ``pty``.""" - assert self._async_http is not None - with await httpx_ws.aconnect_ws( - f"/v1/sandboxes/{self.id}/pty", client=self._http - ) as ws: - session = AsyncPtySession(ws, self.id) - await session._send_start( - cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd - ) - yield session - - @asynccontextmanager - async def async_pty_connect(self, tag: str) -> AsyncPtySession: - """Async version of ``pty_connect``.""" - assert self._async_http is not None - with await httpx_ws.aconnect_ws( - f"/v1/sandboxes/{self.id}/pty", client=self._http - ) as ws: - session = AsyncPtySession(ws, self.id) - await session._send_connect(tag) - yield session - - def ping(self) -> None: - """Reset the sandbox inactivity timer.""" - assert self._http is not None - resp = self._http.post(f"/v1/sandboxes/{self.id}/ping") - resp.raise_for_status() - - async def async_ping(self) -> None: - """Async version of ``ping``.""" - assert self._async_http is not None - resp = await self._async_http.post(f"/v1/sandboxes/{self.id}/ping") - resp.raise_for_status() - - def pause(self) -> Sandbox: - """Pause the sandbox (snapshot and release resources). - - Returns: - Updated ``Sandbox`` with new status. - """ - assert self._http is not None - resp = self._http.post(f"/v1/sandboxes/{self.id}/pause") - resp.raise_for_status() - updated = Sandbox.model_validate(resp.json()) - self.status = updated.status - return self - - async def async_pause(self) -> Sandbox: - """Async version of ``pause``.""" - assert self._async_http is not None - resp = await self._async_http.post(f"/v1/sandboxes/{self.id}/pause") - resp.raise_for_status() - updated = Sandbox.model_validate(resp.json()) - self.status = updated.status - return self - - def resume(self) -> Sandbox: - """Resume a paused sandbox from its snapshot. - - Returns: - Updated ``Sandbox`` with new status. - """ - assert self._http is not None - resp = self._http.post(f"/v1/sandboxes/{self.id}/resume") - resp.raise_for_status() - updated = Sandbox.model_validate(resp.json()) - self.status = updated.status - return self - - async def async_resume(self) -> Sandbox: - """Async version of ``resume``.""" - assert self._async_http is not None - resp = await self._async_http.post(f"/v1/sandboxes/{self.id}/resume") - resp.raise_for_status() - updated = Sandbox.model_validate(resp.json()) - self.status = updated.status - return self - - def destroy(self) -> None: - """Tear down the sandbox.""" - assert self._http is not None - resp = self._http.delete(f"/v1/sandboxes/{self.id}") - resp.raise_for_status() - - async def async_destroy(self) -> None: - """Async version of ``destroy``.""" - assert self._async_http is not None - resp = await self._async_http.delete(f"/v1/sandboxes/{self.id}") - resp.raise_for_status() - - def _ensure_kernel(self, jupyter_timeout: float = 30) -> str: - """Ensure a Jupyter kernel is running, creating one if needed. - - Polls the Jupyter server until it responds, then creates a kernel. - - Args: - jupyter_timeout: Maximum seconds to wait for Jupyter to become available. - - Returns: - The kernel ID. - - Raises: - TimeoutError: If Jupyter doesn't respond within the timeout. - """ - current_kernel = self._kernel_id - if current_kernel is not None: - return current_kernel - deadline = time.monotonic() + jupyter_timeout - last_exc: Exception | None = None - while time.monotonic() < deadline: - try: - resp = self.http_client.post("/api/kernels") - if resp.status_code < 500: - resp.raise_for_status() - data = resp.json() - self._kernel_id = data["id"] - return str(self._kernel_id) - last_exc = httpx.HTTPStatusError( - f"Jupyter returned {resp.status_code}", - request=resp.request, - response=resp, - ) - except httpx.HTTPStatusError: - raise - except Exception as exc: - last_exc = exc - time.sleep(0.5) - raise TimeoutError( - f"Jupyter not available within {jupyter_timeout}s: {last_exc}" - ) - - async def _async_ensure_kernel(self, jupyter_timeout: float = 30) -> str: - """Async version of ``_ensure_kernel``.""" - import asyncio - - current_kernel = self._kernel_id - if current_kernel is not None: - return current_kernel - - if self._async_proxy_client is None: - url = ( - _build_proxy_url(self._base_url, self.id, 8888) - .replace("ws://", "http://") - .replace("wss://", "https://") - ) - self._async_proxy_client = httpx.AsyncClient( - base_url=url, - headers=self._proxy_headers(), - ) - - deadline = time.monotonic() + jupyter_timeout - last_exc: Exception | None = None - while time.monotonic() < deadline: - try: - resp = await self._async_proxy_client.post("/api/kernels") - if resp.status_code < 500: - resp.raise_for_status() - data = resp.json() - self._kernel_id = data["id"] - return str(self._kernel_id) - last_exc = httpx.HTTPStatusError( - f"Jupyter returned {resp.status_code}", - request=resp.request, - response=resp, - ) - except httpx.HTTPStatusError: - raise - except Exception as exc: - last_exc = exc - await asyncio.sleep(0.5) - raise TimeoutError( - f"Jupyter not available within {jupyter_timeout}s: {last_exc}" - ) - - def _jupyter_ws_url(self, kernel_id: str) -> str: - proxy = _build_proxy_url(self._base_url, self.id, 8888) - return f"{proxy}/api/kernels/{kernel_id}/channels" - - def _jupyter_execute_request(self, code: str) -> dict: - msg_id = str(uuid.uuid4()) - return { - "header": { - "msg_id": msg_id, - "msg_type": "execute_request", - "username": "wrenn-sdk", - "session": str(uuid.uuid4()), - "date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), - "version": "5.3", - }, - "parent_header": {}, - "metadata": {}, - "content": { - "code": code, - "silent": False, - "store_history": True, - "user_expressions": {}, - "allow_stdin": False, - "stop_on_error": True, - }, - "buffers": [], - "channel": "shell", - "msg_id": msg_id, - "msg_type": "execute_request", - } - - def run_code( - self, - code: str, - language: str = "python", - timeout: float = 30, - jupyter_timeout: float = 30, - ) -> CodeResult: - """Execute code in a persistent kernel inside the sandbox. - - Variables, imports, and function definitions survive across calls. - - Args: - code: Code string to execute. - language: Execution backend language. Currently only ``"python"``. - timeout: Maximum seconds to wait for execution to complete. - jupyter_timeout: Maximum seconds to wait for Jupyter to become available. - - Returns: - A ``CodeResult`` with ``.text``, ``.data``, ``.stdout``, ``.stderr``, ``.error``. - """ - assert self._http is not None - kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout) - ws_url = self._jupyter_ws_url(kernel_id) - - msg = self._jupyter_execute_request(code) - msg_id = msg["msg_id"] - - result = CodeResult() - deadline = time.monotonic() + timeout - - headers = self._proxy_headers() - - with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: ignore[attr-defined, var-annotated] - ws.send_text(json.dumps(msg)) - while time.monotonic() < deadline: - time_left = deadline - time.monotonic() - if time_left <= 0: - break - try: - data = ws.receive_json(timeout=time_left) - except (TimeoutError, Exception): - break - if not data: - break - parent = data.get("parent_header", {}).get("msg_id") - if parent != msg_id: - continue - msg_type = data.get("msg_type") or data.get("header", {}).get( - "msg_type" - ) - content = data.get("content", {}) - - if msg_type == "stream": - name = content.get("name", "stdout") - if name == "stderr": - result.stderr += content.get("text", "") - else: - result.stdout += content.get("text", "") - elif msg_type == "execute_result": - bundle = content.get("data", {}) - result.text = bundle.get("text/plain") - result.data = bundle - elif msg_type == "error": - traceback = content.get("traceback", []) - result.error = "\n".join(traceback) - elif msg_type == "status" and content.get("execution_state") == "idle": - break - - return result - - async def async_run_code( - self, - code: str, - language: str = "python", - timeout: float = 30, - jupyter_timeout: float = 30, - ) -> CodeResult: - """Async version of ``run_code``.""" - assert self._async_http is not None - kernel_id = await self._async_ensure_kernel(jupyter_timeout=jupyter_timeout) - ws_url = self._jupyter_ws_url(kernel_id) - - msg = self._jupyter_execute_request(code) - msg_id = msg["msg_id"] - - result = CodeResult() - deadline = time.monotonic() + timeout - - headers = self._proxy_headers() - - async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: ignore[attr-defined, var-annotated] - await ws.send_text(json.dumps(msg)) - while time.monotonic() < deadline: - time_left = deadline - time.monotonic() - if time_left <= 0: - break - - try: - data = await asyncio.wait_for(ws.receive_json(), timeout=time_left) # type: ignore[misc] - except (asyncio.TimeoutError, Exception): - break - - if not data: - break - - parent = data.get("parent_header", {}).get("msg_id") - if parent != msg_id: - continue - msg_type = data.get("msg_type") or data.get("header", {}).get( - "msg_type" - ) - content = data.get("content", {}) - - if msg_type == "stream": - name = content.get("name", "stdout") - if name == "stderr": - result.stderr += content.get("text", "") - else: - result.stdout += content.get("text", "") - elif msg_type == "execute_result": - bundle = content.get("data", {}) - result.text = bundle.get("text/plain") - result.data = bundle - elif msg_type == "error": - traceback = content.get("traceback", []) - result.error = "\n".join(traceback) - elif msg_type == "status" and content.get("execution_state") == "idle": - break - - return result - - def _cleanup(self) -> None: - if self._proxy_client is not None: - try: - self._proxy_client.close() - except Exception: - pass - self._proxy_client = None - - async def _async_cleanup(self) -> None: - if self._async_proxy_client is not None: - try: - await self._async_proxy_client.aclose() - except Exception: - pass - self._async_proxy_client = None - - def __enter__(self) -> Sandbox: - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: object, - ) -> None: - try: - self.destroy() - except Exception: - pass - self._cleanup() - - async def __aenter__(self) -> Sandbox: - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: object, - ) -> None: - try: - await self.async_destroy() - except Exception: - pass - await self._async_cleanup() + return Capsule + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/tests/test_sandbox_features.py b/tests/test_capsule_features.py similarity index 53% rename from tests/test_sandbox_features.py rename to tests/test_capsule_features.py index 7737b45..594a378 100644 --- a/tests/test_sandbox_features.py +++ b/tests/test_capsule_features.py @@ -1,11 +1,10 @@ from __future__ import annotations - import pytest import respx +from wrenn.capsule import Capsule, CodeResult, _build_proxy_url from wrenn.client import WrennClient -from wrenn.sandbox import CodeResult, Sandbox, _build_proxy_url @pytest.fixture @@ -32,14 +31,14 @@ class TestBuildProxyUrl: assert url == "ws://5000-sb-2.192.168.1.1" -class TestSandboxGetUrl: +class TestCapsuleGetUrl: @respx.mock def test_get_url_returns_proxy_url(self, client): - respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + respx.post("https://api.wrenn.dev/v1/capsules").respond( 201, json={"id": "cl-abc", "status": "pending"} ) - sb = client.sandboxes.create(template="minimal") - url = sb.get_url(8888) + cap = client.capsules.create(template="minimal") + url = cap.get_url(8888) assert url == "wss://8888-cl-abc.api.wrenn.dev" @respx.mock @@ -48,22 +47,22 @@ class TestSandboxGetUrl: api_key="wrn_test1234567890abcdef12345678", base_url="http://localhost:8080", ) as c: - respx.post("http://localhost:8080/v1/sandboxes").respond( + respx.post("http://localhost:8080/v1/capsules").respond( 201, json={"id": "cl-xyz", "status": "pending"} ) - sb = c.sandboxes.create() - url = sb.get_url(3000) + cap = c.capsules.create() + url = cap.get_url(3000) assert url == "ws://3000-cl-xyz.localhost:8080" -class TestSandboxHttpClient: +class TestCapsuleHttpClient: @respx.mock def test_http_client_has_api_key_header(self, client): - respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + respx.post("https://api.wrenn.dev/v1/capsules").respond( 201, json={"id": "cl-abc", "status": "pending"} ) - sb = client.sandboxes.create() - hc = sb.http_client + cap = client.capsules.create() + hc = cap.http_client assert hc.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678" @respx.mock @@ -71,51 +70,51 @@ class TestSandboxHttpClient: route = respx.get("https://8888-cl-abc.api.wrenn.dev/api/kernels").respond( 200, json=[] ) - respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + respx.post("https://api.wrenn.dev/v1/capsules").respond( 201, json={"id": "cl-abc", "status": "pending"} ) - sb = client.sandboxes.create() - resp = sb.http_client.get("/api/kernels") + cap = client.capsules.create() + resp = cap.http_client.get("/api/kernels") assert resp.status_code == 200 assert route.called def test_jwt_only_get_url_works(self): with WrennClient(token="jwt-abc") as c: - sb = Sandbox(id="cl-abc") - sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") - url = sb.get_url(8888) + cap = Capsule(id="cl-abc") + cap._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") + url = cap.get_url(8888) assert "8888-cl-abc" in url def test_jwt_only_http_client_has_bearer_header(self): with WrennClient(token="jwt-abc") as c: - sb = Sandbox(id="cl-abc") - sb._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") - hc = sb.http_client + cap = Capsule(id="cl-abc") + cap._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") + hc = cap.http_client assert hc.headers["Authorization"] == "Bearer jwt-abc" -class TestCreateReturnsBoundSandbox: +class TestCreateReturnsBoundCapsule: @respx.mock - def test_create_returns_sandbox_subclass(self, client): - respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + def test_create_returns_capsule_subclass(self, client): + respx.post("https://api.wrenn.dev/v1/capsules").respond( 201, json={"id": "cl-1", "status": "pending", "template": "minimal"} ) - sb = client.sandboxes.create(template="minimal") - assert isinstance(sb, Sandbox) - assert sb.id == "cl-1" - assert hasattr(sb, "exec") - assert hasattr(sb, "run_code") - assert hasattr(sb, "get_url") + cap = client.capsules.create(template="minimal") + assert isinstance(cap, Capsule) + assert cap.id == "cl-1" + assert hasattr(cap, "exec") + assert hasattr(cap, "run_code") + assert hasattr(cap, "get_url") @respx.mock def test_create_context_manager(self, client): - route = respx.delete("https://api.wrenn.dev/v1/sandboxes/cl-1").respond(204) - respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + route = respx.delete("https://api.wrenn.dev/v1/capsules/cl-1").respond(204) + respx.post("https://api.wrenn.dev/v1/capsules").respond( 201, json={"id": "cl-1", "status": "pending"} ) - sb = client.sandboxes.create() - with sb: - assert sb.id == "cl-1" + cap = client.capsules.create() + with cap: + assert cap.id == "cl-1" assert route.called @@ -147,8 +146,8 @@ class TestCodeResult: class TestJupyterMessageFormat: def test_execute_request_structure(self): - sb = Sandbox(id="test") - msg = sb._jupyter_execute_request("x = 42") + cap = Capsule(id="test") + msg = cap._jupyter_execute_request("x = 42") assert msg["msg_type"] == "execute_request" assert msg["content"]["code"] == "x = 42" assert msg["content"]["silent"] is False @@ -157,7 +156,45 @@ class TestJupyterMessageFormat: assert msg["header"]["msg_type"] == "execute_request" def test_execute_request_unique_ids(self): - sb = Sandbox(id="test") - m1 = sb._jupyter_execute_request("a") - m2 = sb._jupyter_execute_request("b") + cap = Capsule(id="test") + m1 = cap._jupyter_execute_request("a") + m2 = cap._jupyter_execute_request("b") assert m1["msg_id"] != m2["msg_id"] + + +class TestDeprecationWarnings: + def test_import_sandbox_from_capsule_warns(self): + import importlib + import warnings + + import wrenn.capsule as capsule_mod + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + klass = capsule_mod.Sandbox + assert klass is Capsule + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "Sandbox" in str(w[0].message) + + def test_import_sandbox_from_wrenn_warns(self): + import warnings + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + from wrenn import Sandbox + + assert Sandbox is Capsule + assert any(issubclass(x.category, DeprecationWarning) for x in w) + + def test_client_sandboxes_property_warns(self): + import warnings + + with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + resource = c.sandboxes + assert resource is c.capsules + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "sandboxes" in str(w[0].message) diff --git a/tests/test_client.py b/tests/test_client.py index b9adb02..17c3586 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -9,7 +9,7 @@ from wrenn.exceptions import ( WrennAuthenticationError, WrennConflictError, WrennForbiddenError, - WrennHostHasSandboxesError, + WrennHostHasCapsulesError, WrennInternalError, WrennNotFoundError, WrennValidationError, @@ -17,9 +17,9 @@ from wrenn.exceptions import ( from wrenn.models import ( APIKeyResponse, AuthResponse, + Capsule, CreateHostResponse, Host, - Sandbox, Status, Template, ) @@ -97,10 +97,10 @@ class TestAPIKeys: assert route.called -class TestSandboxes: +class TestCapsules: @respx.mock def test_create(self, client): - respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + respx.post("https://api.wrenn.dev/v1/capsules").respond( 201, json={ "id": "sb-1", @@ -110,40 +110,40 @@ class TestSandboxes: "memory_mb": 1024, }, ) - resp = client.sandboxes.create(template="base-python", vcpus=2, memory_mb=1024) - assert isinstance(resp, Sandbox) + resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024) + assert isinstance(resp, Capsule) assert resp.id == "sb-1" assert resp.status == Status.pending @respx.mock def test_create_defaults(self, client): - respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + respx.post("https://api.wrenn.dev/v1/capsules").respond( 201, json={"id": "sb-2", "status": "pending"} ) - resp = client.sandboxes.create() + resp = client.capsules.create() assert resp.id == "sb-2" @respx.mock def test_list(self, client): - respx.get("https://api.wrenn.dev/v1/sandboxes").respond( + respx.get("https://api.wrenn.dev/v1/capsules").respond( 200, json=[{"id": "sb-1", "status": "running"}] ) - boxes = client.sandboxes.list() + boxes = client.capsules.list() assert len(boxes) == 1 assert boxes[0].status == Status.running @respx.mock def test_get(self, client): - respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond( + respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond( 200, json={"id": "sb-1", "status": "running"} ) - resp = client.sandboxes.get("sb-1") + resp = client.capsules.get("sb-1") assert resp.id == "sb-1" @respx.mock def test_destroy(self, client): - route = respx.delete("https://api.wrenn.dev/v1/sandboxes/sb-1").respond(204) - client.sandboxes.destroy("sb-1") + route = respx.delete("https://api.wrenn.dev/v1/capsules/sb-1").respond(204) + client.capsules.destroy("sb-1") assert route.called @@ -154,7 +154,7 @@ class TestSnapshots: 201, json={"name": "snap-1", "type": "snapshot", "vcpus": 1}, ) - resp = client.snapshots.create(sandbox_id="sb-1", name="snap-1") + resp = client.snapshots.create(capsule_id="sb-1", name="snap-1") assert isinstance(resp, Template) assert resp.name == "snap-1" @@ -163,7 +163,7 @@ class TestSnapshots: route = respx.post("https://api.wrenn.dev/v1/snapshots").respond( 201, json={"name": "snap-1", "type": "snapshot"} ) - client.snapshots.create(sandbox_id="sb-1", overwrite=True) + client.snapshots.create(capsule_id="sb-1", overwrite=True) req = route.calls[0].request assert "overwrite=true" in str(req.url) @@ -262,23 +262,23 @@ class TestHosts: class TestErrorHandling: @respx.mock def test_validation_error(self, client): - respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + respx.post("https://api.wrenn.dev/v1/capsules").respond( 400, json={"error": {"code": "invalid_request", "message": "bad input"}}, ) with pytest.raises(WrennValidationError) as exc_info: - client.sandboxes.create() + client.capsules.create() assert exc_info.value.code == "invalid_request" assert exc_info.value.status_code == 400 @respx.mock def test_auth_error(self, client): - respx.get("https://api.wrenn.dev/v1/sandboxes").respond( + respx.get("https://api.wrenn.dev/v1/capsules").respond( 401, json={"error": {"code": "unauthorized", "message": "bad key"}}, ) with pytest.raises(WrennAuthenticationError): - client.sandboxes.list() + client.capsules.list() @respx.mock def test_forbidden_error(self, client): @@ -291,66 +291,66 @@ class TestErrorHandling: @respx.mock def test_not_found_error(self, client): - respx.get("https://api.wrenn.dev/v1/sandboxes/nope").respond( + respx.get("https://api.wrenn.dev/v1/capsules/nope").respond( 404, - json={"error": {"code": "not_found", "message": "sandbox not found"}}, + json={"error": {"code": "not_found", "message": "capsule not found"}}, ) with pytest.raises(WrennNotFoundError): - client.sandboxes.get("nope") + client.capsules.get("nope") @respx.mock def test_conflict_error(self, client): - respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond( + respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond( 409, json={"error": {"code": "invalid_state", "message": "not running"}}, ) with pytest.raises(WrennConflictError): - client.sandboxes.get("sb-1") + client.capsules.get("sb-1") @respx.mock - def test_host_has_sandboxes_error(self, client): + def test_host_has_capsules_error(self, client): respx.delete("https://api.wrenn.dev/v1/hosts/h-1").respond( 409, json={ "error": { - "code": "host_has_sandboxes", - "message": "host has running sandboxes", + "code": "host_has_capsules", + "message": "host has running capsules", }, "sandbox_ids": ["sb-1", "sb-2"], }, ) - with pytest.raises(WrennHostHasSandboxesError) as exc_info: + with pytest.raises(WrennHostHasCapsulesError) as exc_info: client.hosts.delete("h-1") - assert exc_info.value.sandbox_ids == ["sb-1", "sb-2"] + assert exc_info.value.capsule_ids == ["sb-1", "sb-2"] @respx.mock def test_agent_error(self, client): - respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + respx.post("https://api.wrenn.dev/v1/capsules").respond( 502, json={"error": {"code": "agent_error", "message": "host agent failed"}}, ) with pytest.raises(WrennAgentError): - client.sandboxes.create() + client.capsules.create() @respx.mock def test_internal_error(self, client): - respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond( + respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond( 500, json={"error": {"code": "internal_error", "message": "oops"}}, ) with pytest.raises(WrennInternalError): - client.sandboxes.get("sb-1") + client.capsules.get("sb-1") @respx.mock def test_unknown_error_code_falls_back(self, client): - respx.get("https://api.wrenn.dev/v1/sandboxes/sb-1").respond( + respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond( 418, json={"error": {"code": "teapot", "message": "I'm a teapot"}}, ) from wrenn.exceptions import WrennError with pytest.raises(WrennError) as exc_info: - client.sandboxes.get("sb-1") + client.capsules.get("sb-1") assert exc_info.value.code == "teapot" @@ -379,22 +379,22 @@ class TestAuthModes: class TestAsyncClient: @pytest.mark.asyncio @respx.mock - async def test_async_sandboxes_create(self, async_client): + async def test_async_capsules_create(self, async_client): async with async_client: - respx.post("https://api.wrenn.dev/v1/sandboxes").respond( + respx.post("https://api.wrenn.dev/v1/capsules").respond( 201, json={"id": "sb-1", "status": "pending"} ) - resp = await async_client.sandboxes.create(template="base-python") + resp = await async_client.capsules.create(template="base-python") assert resp.id == "sb-1" @pytest.mark.asyncio @respx.mock - async def test_async_sandboxes_list(self, async_client): + async def test_async_capsules_list(self, async_client): async with async_client: - respx.get("https://api.wrenn.dev/v1/sandboxes").respond( + respx.get("https://api.wrenn.dev/v1/capsules").respond( 200, json=[{"id": "sb-1"}] ) - boxes = await async_client.sandboxes.list() + boxes = await async_client.capsules.list() assert len(boxes) == 1 @pytest.mark.asyncio @@ -409,9 +409,9 @@ class TestAsyncClient: @respx.mock async def test_async_error_handling(self, async_client): async with async_client: - respx.get("https://api.wrenn.dev/v1/sandboxes/nope").respond( + respx.get("https://api.wrenn.dev/v1/capsules/nope").respond( 404, json={"error": {"code": "not_found", "message": "not found"}}, ) with pytest.raises(WrennNotFoundError): - await async_client.sandboxes.get("nope") + await async_client.capsules.get("nope") diff --git a/tests/test_filesystem_pty.py b/tests/test_filesystem_pty.py index 983daa6..6b494a6 100644 --- a/tests/test_filesystem_pty.py +++ b/tests/test_filesystem_pty.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest import respx +from wrenn.capsule import Capsule from wrenn.client import WrennClient from wrenn.models import FileEntry from wrenn.pty import ( @@ -15,7 +16,6 @@ from wrenn.pty import ( PtySession, _parse_pty_event, ) -from wrenn.sandbox import Sandbox @pytest.fixture @@ -24,18 +24,18 @@ def client(): yield c -def _make_sandbox(client: WrennClient, sb_id: str = "cl-abc") -> Sandbox: - respx.post("https://api.wrenn.dev/v1/sandboxes").respond( - 201, json={"id": sb_id, "status": "running"} +def _make_capsule(client: WrennClient, cap_id: str = "cl-abc") -> Capsule: + respx.post("https://api.wrenn.dev/v1/capsules").respond( + 201, json={"id": cap_id, "status": "running"} ) - return client.sandboxes.create() + return client.capsules.create() class TestListDir: @respx.mock def test_list_dir_returns_entries(self, client): - sb = _make_sandbox(client) - respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list").respond( + cap = _make_capsule(client) + respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond( 200, json={ "entries": [ @@ -66,7 +66,7 @@ class TestListDir: ] }, ) - entries = sb.list_dir("/home/user") + entries = cap.list_dir("/home/user") assert len(entries) == 2 assert isinstance(entries[0], FileEntry) assert entries[0].name == "main.py" @@ -76,27 +76,27 @@ class TestListDir: @respx.mock def test_list_dir_with_depth(self, client): - sb = _make_sandbox(client) + cap = _make_capsule(client) route = respx.post( - "https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list" + "https://api.wrenn.dev/v1/capsules/cl-abc/files/list" ).respond(200, json={"entries": []}) - sb.list_dir("/home/user", depth=3) + cap.list_dir("/home/user", depth=3) body = json.loads(route.calls[0].request.content) assert body["depth"] == 3 @respx.mock def test_list_dir_empty(self, client): - sb = _make_sandbox(client) - respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list").respond( + cap = _make_capsule(client) + respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond( 200, json={"entries": []} ) - entries = sb.list_dir("/empty") + entries = cap.list_dir("/empty") assert entries == [] @respx.mock def test_list_dir_symlink(self, client): - sb = _make_sandbox(client) - respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list").respond( + cap = _make_capsule(client) + respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond( 200, json={ "entries": [ @@ -115,7 +115,7 @@ class TestListDir: ] }, ) - entries = sb.list_dir("/home/user") + entries = cap.list_dir("/home/user") assert len(entries) == 1 assert entries[0].type == "symlink" assert entries[0].symlink_target == "/bin" @@ -124,8 +124,8 @@ class TestListDir: class TestMkdir: @respx.mock def test_mkdir_returns_entry(self, client): - sb = _make_sandbox(client) - respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/mkdir").respond( + cap = _make_capsule(client) + respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/mkdir").respond( 200, json={ "entry": { @@ -142,19 +142,19 @@ class TestMkdir: } }, ) - entry = sb.mkdir("/home/user/data") + entry = cap.mkdir("/home/user/data") assert isinstance(entry, FileEntry) assert entry.name == "data" assert entry.type == "directory" @respx.mock def test_mkdir_existing_returns_gracefully(self, client): - sb = _make_sandbox(client) - respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/mkdir").respond( + cap = _make_capsule(client) + respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/mkdir").respond( 409, json={"error": {"code": "conflict", "message": "already exists"}}, ) - respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/list").respond( + respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond( 200, json={ "entries": [ @@ -173,27 +173,27 @@ class TestMkdir: ] }, ) - entry = sb.mkdir("/home/user/data") + entry = cap.mkdir("/home/user/data") assert entry.name == "data" class TestRemove: @respx.mock def test_remove_succeeds(self, client): - sb = _make_sandbox(client) + cap = _make_capsule(client) route = respx.post( - "https://api.wrenn.dev/v1/sandboxes/cl-abc/files/remove" + "https://api.wrenn.dev/v1/capsules/cl-abc/files/remove" ).respond(204) - sb.remove("/home/user/old_data") + cap.remove("/home/user/old_data") assert route.called @respx.mock def test_remove_sends_path(self, client): - sb = _make_sandbox(client) + cap = _make_capsule(client) route = respx.post( - "https://api.wrenn.dev/v1/sandboxes/cl-abc/files/remove" + "https://api.wrenn.dev/v1/capsules/cl-abc/files/remove" ).respond(204) - sb.remove("/tmp/test.txt") + cap.remove("/tmp/test.txt") body = json.loads(route.calls[0].request.content) assert body["path"] == "/tmp/test.txt" @@ -201,23 +201,23 @@ class TestRemove: class TestUpload: @respx.mock def test_upload_sends_multipart(self, client): - sb = _make_sandbox(client) + cap = _make_capsule(client) route = respx.post( - "https://api.wrenn.dev/v1/sandboxes/cl-abc/files/write" + "https://api.wrenn.dev/v1/capsules/cl-abc/files/write" ).respond(204) - sb.upload("/app/main.py", b"print('hello')") + cap.upload("/app/main.py", b"print('hello')") assert route.called req = route.calls[0].request assert b"multipart/form-data" in req.headers.get("content-type", "").encode() @respx.mock def test_download_returns_bytes(self, client): - sb = _make_sandbox(client) + cap = _make_capsule(client) content = b"file contents here" - respx.post("https://api.wrenn.dev/v1/sandboxes/cl-abc/files/read").respond( + respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/read").respond( 200, content=content ) - data = sb.download("/app/main.py") + data = cap.download("/app/main.py") assert data == content @@ -500,7 +500,8 @@ class TestExports: assert APS is not None def test_pty_event_importable(self): - from wrenn import PtyEvent as PE, PtyEventType as PET + from wrenn import PtyEvent as PE + from wrenn import PtyEventType as PET assert PE is not None assert PET is not None diff --git a/tests/test_integration.py b/tests/test_integration.py index ca99b14..9cba1c8 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -64,74 +64,74 @@ def bearer_client() -> Generator[WrennClient, None, None]: @requires_auth -class TestSandboxLifecycle: +class TestCapsuleLifecycle: def test_create_exec_destroy(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - result = sb.exec("echo", args=["hello"]) + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + result = cap.exec("echo", args=["hello"]) assert result.exit_code == 0 assert "hello" in result.stdout def test_exec_with_args(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - result = sb.exec("echo", args=["hello", "world"]) + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + result = cap.exec("echo", args=["hello", "world"]) assert result.exit_code == 0 assert "hello world" in result.stdout def test_exec_nonzero_exit(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - result = sb.exec("sh", args=["-c", "exit 42"]) + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + result = cap.exec("sh", args=["-c", "exit 42"]) assert result.exit_code == 42 def test_exec_stderr(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - result = sb.exec("sh", args=["-c", "echo err>&2"]) + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + result = cap.exec("sh", args=["-c", "echo err>&2"]) assert result.exit_code == 0 assert "err" in result.stderr def test_context_manager_cleanup(self, client): - sb = client.sandboxes.create(template="minimal", timeout_sec=120) - sb_id = sb.id + cap = client.capsules.create(template="minimal", timeout_sec=120) + cap_id = cap.id - with sb: - sb.wait_ready(timeout=60, interval=1) + with cap: + cap.wait_ready(timeout=60, interval=1) - fetched = client.sandboxes.get(sb_id) + fetched = client.capsules.get(cap_id) assert fetched.status in ("stopped", "destroyed") @requires_auth class TestFileIO: def test_upload_and_download(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) content = b"Hello from integration test!" - sb.upload("/tmp/test_file.txt", content) - downloaded = sb.download("/tmp/test_file.txt") + cap.upload("/tmp/test_file.txt", content) + downloaded = cap.download("/tmp/test_file.txt") assert downloaded == content def test_download_nonexistent_file(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) with pytest.raises(Exception): - sb.download("/tmp/no_such_file_12345") + cap.download("/tmp/no_such_file_12345") @requires_auth class TestPauseResume: def test_pause_and_resume(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - sb.pause() - assert sb.status == "paused" + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.pause() + assert cap.status == "paused" - sb.resume() - sb.wait_ready(timeout=60, interval=1) + cap.resume() + cap.wait_ready(timeout=60, interval=1) - result = sb.exec("echo", args=["resumed"]) + result = cap.exec("echo", args=["resumed"]) assert result.exit_code == 0 assert "resumed" in result.stdout @@ -139,10 +139,10 @@ class TestPauseResume: @requires_auth class TestPing: def test_ping_resets_timer(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - sb.ping() - result = sb.exec("echo", args=["still_alive"]) + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.ping() + result = cap.exec("echo", args=["still_alive"]) assert result.exit_code == 0 assert "still_alive" in result.stdout @@ -150,32 +150,32 @@ class TestPing: @requires_auth class TestProxy: def test_get_url(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - url = sb.get_url(8888) - assert sb.id in url + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + url = cap.get_url(8888) + assert cap.id in url assert "8888" in url @requires_auth class TestListAndGet: - def test_list_sandboxes(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - boxes = client.sandboxes.list() + def test_list_capsules(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + boxes = client.capsules.list() ids = [b.id for b in boxes] - assert sb.id in ids + assert cap.id in ids - def test_get_existing_sandbox(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - fetched = client.sandboxes.get(sb.id) - assert fetched.id == sb.id + def test_get_existing_capsule(self, client): + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + fetched = client.capsules.get(cap.id) + assert fetched.id == cap.id assert fetched.status == "running" - def test_get_nonexistent_sandbox(self, client): + def test_get_nonexistent_capsule(self, client): with pytest.raises((WrennNotFoundError, WrennValidationError)): - client.sandboxes.get("cl-nonexistent00000000000000000") + client.capsules.get("cl-nonexistent00000000000000000") @requires_auth @@ -204,117 +204,117 @@ class TestAPIKeys: @requires_auth class TestRunCode: def test_basic_execution(self, client): - with client.sandboxes.create( + with client.capsules.create( template="python-interpreter-v0-beta", timeout_sec=120 - ) as sb: - sb.wait_ready(timeout=60, interval=1) + ) as cap: + cap.wait_ready(timeout=60, interval=1) - r = sb.run_code("x = 42") + r = cap.run_code("x = 42") assert r.error is None - r = sb.run_code("x * 2") + r = cap.run_code("x * 2") assert r.text == "84" def test_state_persists(self, client): - with client.sandboxes.create( + with client.capsules.create( template="python-interpreter-v0-beta", timeout_sec=120 - ) as sb: - sb.wait_ready(timeout=60, interval=1) + ) as cap: + cap.wait_ready(timeout=60, interval=1) - sb.run_code("def greet(name): return f'hello {name}'") - r = sb.run_code("greet('sandbox')") - assert "hello sandbox" in (r.text or "") + cap.run_code("def greet(name): return f'hello {name}'") + r = cap.run_code("greet('capsule')") + assert "hello capsule" in (r.text or "") def test_error_traceback(self, client): - with client.sandboxes.create( + with client.capsules.create( template="python-interpreter-v0-beta", timeout_sec=120 - ) as sb: - sb.wait_ready(timeout=60, interval=1) + ) as cap: + cap.wait_ready(timeout=60, interval=1) - r = sb.run_code("1/0") + r = cap.run_code("1/0") assert r.error is not None assert "ZeroDivisionError" in r.error def test_stdout_capture(self, client): - with client.sandboxes.create( + with client.capsules.create( template="python-interpreter-v0-beta", timeout_sec=120 - ) as sb: - sb.wait_ready(timeout=60, interval=1) + ) as cap: + cap.wait_ready(timeout=60, interval=1) - r = sb.run_code("print('hello from kernel')") + r = cap.run_code("print('hello from kernel')") assert "hello from kernel" in r.stdout @requires_auth -class TestAsyncSandboxLifecycle: +class TestAsyncCapsuleLifecycle: @pytest.mark.asyncio async def test_async_create_exec_destroy(self, async_client): async with async_client: - sb = await async_client.sandboxes.create( + cap = await async_client.capsules.create( template="minimal", timeout_sec=120 ) try: - await sb.async_wait_ready(timeout=60, interval=1) - result = await sb.async_exec("echo", args=["async_hello"]) + await cap.async_wait_ready(timeout=60, interval=1) + result = await cap.async_exec("echo", args=["async_hello"]) assert result.exit_code == 0 assert "async_hello" in result.stdout finally: - await sb.async_destroy() + await cap.async_destroy() @pytest.mark.asyncio async def test_async_upload_download(self, async_client): async with async_client: - sb = await async_client.sandboxes.create( + cap = await async_client.capsules.create( template="minimal", timeout_sec=120 ) try: - await sb.async_wait_ready(timeout=60, interval=1) + await cap.async_wait_ready(timeout=60, interval=1) content = b"Async upload test" - await sb.async_upload("/tmp/async_test.txt", content) - downloaded = await sb.async_download("/tmp/async_test.txt") + await cap.async_upload("/tmp/async_test.txt", content) + downloaded = await cap.async_download("/tmp/async_test.txt") assert downloaded == content finally: - await sb.async_destroy() + await cap.async_destroy() @pytest.mark.asyncio async def test_async_run_code(self, async_client): async with async_client: - sb = await async_client.sandboxes.create( + cap = await async_client.capsules.create( template="python-interpreter-v0-beta", timeout_sec=120 ) try: - await sb.async_wait_ready(timeout=60, interval=1) - r = await sb.async_run_code("42 * 2") + await cap.async_wait_ready(timeout=60, interval=1) + r = await cap.async_run_code("42 * 2") assert r.text == "84" finally: - await sb.async_destroy() + await cap.async_destroy() @requires_auth class TestFilesystemListDir: def test_list_dir_root(self, client: WrennClient): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - sb.mkdir("/tmp/ls_test_root") - sb.upload("/tmp/ls_test_root/hello.txt", b"hello") - entries = sb.list_dir("/tmp/ls_test_root") + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.mkdir("/tmp/ls_test_root") + cap.upload("/tmp/ls_test_root/hello.txt", b"hello") + entries = cap.list_dir("/tmp/ls_test_root") assert isinstance(entries, list) names = [e.name for e in entries] assert "hello.txt" in names def test_list_dir_after_mkdir(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - sb.mkdir("/tmp/fs_test_dir") - entries = sb.list_dir("/tmp") + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.mkdir("/tmp/fs_test_dir") + entries = cap.list_dir("/tmp") names = [e.name for e in entries] assert "fs_test_dir" in names def test_list_dir_file_metadata(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - sb.upload("/tmp/meta_test.txt", b"hello world") - entries = sb.list_dir("/tmp") + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.upload("/tmp/meta_test.txt", b"hello world") + entries = cap.list_dir("/tmp") match = [e for e in entries if e.name == "meta_test.txt"] assert len(match) == 1 f = match[0] @@ -326,100 +326,100 @@ class TestFilesystemListDir: assert f.modified_at is not None def test_list_dir_depth(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - sb.mkdir("/tmp/depth_a/depth_b") - sb.upload("/tmp/depth_a/depth_b/nested.txt", b"deep") - entries = sb.list_dir("/tmp/depth_a", depth=2) + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.mkdir("/tmp/depth_a/depth_b") + cap.upload("/tmp/depth_a/depth_b/nested.txt", b"deep") + entries = cap.list_dir("/tmp/depth_a", depth=2) paths = [e.path for e in entries] assert any("nested.txt" in p for p in paths) def test_list_dir_empty_directory(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - sb.mkdir("/tmp/empty_dir_test") - entries = sb.list_dir("/tmp/empty_dir_test") + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.mkdir("/tmp/empty_dir_test") + entries = cap.list_dir("/tmp/empty_dir_test") assert entries == [] @requires_auth class TestFilesystemMkdir: def test_mkdir_creates_directory(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - entry = sb.mkdir("/tmp/mkdir_test") + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + entry = cap.mkdir("/tmp/mkdir_test") assert entry.name == "mkdir_test" assert entry.type == "directory" assert entry.path == "/tmp/mkdir_test" def test_mkdir_creates_parents(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - entry = sb.mkdir("/tmp/a/b/c/d") + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + entry = cap.mkdir("/tmp/a/b/c/d") assert entry.type == "directory" def test_mkdir_already_exists(self, client: WrennClient): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - sb.mkdir("/tmp/exist_test") - entry = sb.mkdir("/tmp/exist_test") + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.mkdir("/tmp/exist_test") + entry = cap.mkdir("/tmp/exist_test") assert entry.type == "directory" @requires_auth class TestFilesystemRemove: def test_remove_file(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - sb.upload("/tmp/rm_test.txt", b"delete me") - entries_before = sb.list_dir("/tmp") + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.upload("/tmp/rm_test.txt", b"delete me") + entries_before = cap.list_dir("/tmp") assert any(e.name == "rm_test.txt" for e in entries_before) - sb.remove("/tmp/rm_test.txt") - entries_after = sb.list_dir("/tmp") + cap.remove("/tmp/rm_test.txt") + entries_after = cap.list_dir("/tmp") assert not any(e.name == "rm_test.txt" for e in entries_after) def test_remove_directory(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - sb.mkdir("/tmp/rm_dir_test") - sb.upload("/tmp/rm_dir_test/file.txt", b"inside") - sb.remove("/tmp/rm_dir_test") - entries = sb.list_dir("/tmp") + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + cap.mkdir("/tmp/rm_dir_test") + cap.upload("/tmp/rm_dir_test/file.txt", b"inside") + cap.remove("/tmp/rm_dir_test") + entries = cap.list_dir("/tmp") assert not any(e.name == "rm_dir_test" for e in entries) def test_upload_download_remove_roundtrip(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) content = b"round trip test data " * 100 - sb.upload("/tmp/rt.txt", content) - downloaded = sb.download("/tmp/rt.txt") + cap.upload("/tmp/rt.txt", content) + downloaded = cap.download("/tmp/rt.txt") assert downloaded == content - sb.remove("/tmp/rt.txt") + cap.remove("/tmp/rt.txt") with pytest.raises(Exception): - sb.download("/tmp/rt.txt") + cap.download("/tmp/rt.txt") @requires_auth class TestStreamUploadDownload: def test_stream_upload_and_download(self, client: WrennClient): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) chunks = [b"chunk0_", b"chunk1_", b"chunk2"] def data_gen(): yield from chunks - sb.stream_upload("/tmp/stream_test.bin", data_gen()) - downloaded = sb.download("/tmp/stream_test.bin") + cap.stream_upload("/tmp/stream_test.bin", data_gen()) + downloaded = cap.download("/tmp/stream_test.bin") assert downloaded == b"chunk0_chunk1_chunk2" def test_stream_download_large(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) content = b"x" * 65536 * 3 - sb.upload("/tmp/large.bin", content) + cap.upload("/tmp/large.bin", content) collected = b"" - for chunk in sb.stream_download("/tmp/large.bin"): + for chunk in cap.stream_download("/tmp/large.bin"): collected += chunk assert collected == content @@ -427,9 +427,9 @@ class TestStreamUploadDownload: @requires_auth class TestPty: def test_pty_basic_output(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - with sb.pty(cmd="/bin/sh", cwd="/tmp") as term: + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + with cap.pty(cmd="/bin/sh", cwd="/tmp") as term: term.write(b"echo pty_hello\n") output = b"" for event in term: @@ -442,9 +442,9 @@ class TestPty: assert b"pty_hello" in output def test_pty_tag_and_pid(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - with sb.pty(cmd="/bin/sh") as term: + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + with cap.pty(cmd="/bin/sh") as term: started = False for event in term: if event.type == PtyEventType.started: @@ -459,18 +459,18 @@ class TestPty: assert started def test_pty_exit_on_command_exit(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - with sb.pty(cmd="/bin/echo", args=["immediate"]) as term: + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + with cap.pty(cmd="/bin/echo", args=["immediate"]) as term: events = list(term) types = [e.type for e in events] assert PtyEventType.started in types assert PtyEventType.output in types or PtyEventType.exit in types def test_pty_resize(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - with sb.pty(cmd="/bin/sh", cols=80, rows=24) as term: + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + with cap.pty(cmd="/bin/sh", cols=80, rows=24) as term: for event in term: if event.type == PtyEventType.started: term.resize(120, 40) @@ -479,9 +479,9 @@ class TestPty: break def test_pty_envs(self, client): - with client.sandboxes.create(template="minimal", timeout_sec=120) as sb: - sb.wait_ready(timeout=60, interval=1) - with sb.pty(cmd="/bin/sh", envs={"MY_VAR": "hello_env"}) as term: + with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60, interval=1) + with cap.pty(cmd="/bin/sh", envs={"MY_VAR": "hello_env"}) as term: output = b"" for event in term: if event.type == PtyEventType.started: @@ -500,69 +500,69 @@ class TestAsyncFilesystem: @pytest.mark.asyncio async def test_async_list_dir(self, async_client): async with async_client: - sb = await async_client.sandboxes.create( + cap = await async_client.capsules.create( template="minimal", timeout_sec=120 ) try: - await sb.async_wait_ready(timeout=60, interval=1) - await sb.async_mkdir("/tmp/async_ls_test") - await sb.async_upload("/tmp/async_ls_test/file.txt", b"data") - entries = await sb.async_list_dir("/tmp/async_ls_test") + await cap.async_wait_ready(timeout=60, interval=1) + await cap.async_mkdir("/tmp/async_ls_test") + await cap.async_upload("/tmp/async_ls_test/file.txt", b"data") + entries = await cap.async_list_dir("/tmp/async_ls_test") assert isinstance(entries, list) assert any(e.name == "file.txt" for e in entries) finally: - await sb.async_destroy() + await cap.async_destroy() @pytest.mark.asyncio async def test_async_mkdir(self, async_client): async with async_client: - sb = await async_client.sandboxes.create( + cap = await async_client.capsules.create( template="minimal", timeout_sec=120 ) try: - await sb.async_wait_ready(timeout=60, interval=1) - entry = await sb.async_mkdir("/tmp/async_mkdir_test") + await cap.async_wait_ready(timeout=60, interval=1) + entry = await cap.async_mkdir("/tmp/async_mkdir_test") assert entry.type == "directory" assert entry.name == "async_mkdir_test" finally: - await sb.async_destroy() + await cap.async_destroy() @pytest.mark.asyncio async def test_async_remove(self, async_client): async with async_client: - sb = await async_client.sandboxes.create( + cap = await async_client.capsules.create( template="minimal", timeout_sec=120 ) try: - await sb.async_wait_ready(timeout=60, interval=1) - await sb.async_upload("/tmp/async_rm.txt", b"bye") - entries = await sb.async_list_dir("/tmp") + await cap.async_wait_ready(timeout=60, interval=1) + await cap.async_upload("/tmp/async_rm.txt", b"bye") + entries = await cap.async_list_dir("/tmp") assert any(e.name == "async_rm.txt" for e in entries) - await sb.async_remove("/tmp/async_rm.txt") - entries = await sb.async_list_dir("/tmp") + await cap.async_remove("/tmp/async_rm.txt") + entries = await cap.async_list_dir("/tmp") assert not any(e.name == "async_rm.txt" for e in entries) finally: - await sb.async_destroy() + await cap.async_destroy() @pytest.mark.asyncio async def test_async_full_filesystem_roundtrip(self, async_client): async with async_client: - sb = await async_client.sandboxes.create( + cap = await async_client.capsules.create( template="minimal", timeout_sec=120 ) try: - await sb.async_wait_ready(timeout=60, interval=1) + await cap.async_wait_ready(timeout=60, interval=1) - await sb.async_mkdir("/tmp/async_rt") - await sb.async_upload("/tmp/async_rt/file.txt", b"async content") - entries = await sb.async_list_dir("/tmp/async_rt") + await cap.async_mkdir("/tmp/async_rt") + await cap.async_upload("/tmp/async_rt/file.txt", b"async content") + entries = await cap.async_list_dir("/tmp/async_rt") assert any(e.name == "file.txt" for e in entries) - data = await sb.async_download("/tmp/async_rt/file.txt") + data = await cap.async_download("/tmp/async_rt/file.txt") assert data == b"async content" - await sb.async_remove("/tmp/async_rt/file.txt") - entries = await sb.async_list_dir("/tmp/async_rt") + await cap.async_remove("/tmp/async_rt/file.txt") + entries = await cap.async_list_dir("/tmp/async_rt") assert not any(e.name == "file.txt" for e in entries) finally: - await sb.async_destroy() + await cap.async_destroy() -- 2.49.0 From 0ac9bf79ee0d60d50c2d7122edcf37a65806e4c8 Mon Sep 17 00:00:00 2001 From: Tasnim Kabir Sadik Date: Mon, 13 Apr 2026 03:16:44 +0600 Subject: [PATCH 07/44] feat: created README --- README.md | 371 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 369 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2c39d93..3c4593f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,370 @@ -# python-sdk +# Wrenn Python SDK -Python SDK for wrenn \ No newline at end of file +Python client for the [Wrenn](https://wrenn.dev) microVM code execution platform. Create isolated capsules, execute commands, manage files, run interactive terminals, and execute persistent code — all from Python. + +## Installation + +```bash +pip install wrenn +``` + +Requires Python 3.13+. + +## Quick Start + +```python +from wrenn import WrennClient + +client = WrennClient(api_key="wrn_your_api_key_here") + +# Create a capsule and run a command +with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60) + + result = cap.exec("echo", args=["hello world"]) + print(result.stdout) # "hello world" + print(result.exit_code) # 0 +``` + +## Authentication + +The SDK supports two authentication methods: + +```python +# API key +client = WrennClient(api_key="wrn_...") + +# JWT token +client = WrennClient(token="eyJ...") +``` + +You can obtain an API key via the dashboard or create one programmatically: + +```python +with WrennClient(token="jwt_token") as client: + key = client.api_keys.create(name="my-key") + print(key.key) # wrn_... +``` + +## Capsules + +Capsules are isolated microVM environments. Create, manage, and interact with them: + +```python +# Create +cap = client.capsules.create( + template="base-python", + vcpus=2, + memory_mb=1024, + timeout_sec=300, +) + +# List +for c in client.capsules.list(): + print(c.id, c.status) + +# Get +cap = client.capsules.get("cl-abc123") + +# Destroy +client.capsules.destroy("cl-abc123") +``` + +### Context Manager + +Use capsules as context managers for automatic cleanup: + +```python +with client.capsules.create(template="minimal", timeout_sec=120) as cap: + cap.wait_ready(timeout=60) + cap.exec("python -c 'print(42)'") +# cap.destroy() is called automatically +``` + +## Command Execution + +### `exec()` — One-off Commands + +Starts a fresh process for each call. No state persists between calls. + +```python +result = cap.exec("python", args=["-c", "import os; print(os.getcwd())"]) +print(result.stdout) # "/home/user\n" +print(result.stderr) # "" +print(result.exit_code) # 0 +print(result.duration_ms) # 42 +``` + +### `exec_stream()` — Streaming Output + +Stream real-time output from long-running commands: + +```python +for event in cap.exec_stream("python", args=["-u", "train.py"]): + match event.type: + case "stdout": + print(event.data, end="") + case "stderr": + print(event.data, end="", file=sys.stderr) + case "exit": + print(f"\nExited with code {event.exit_code}") +``` + +### `run_code()` — Stateful Code Execution + +Execute Python code in a persistent Jupyter kernel. Variables, imports, and function definitions survive across calls: + +```python +with client.capsules.create(template="python-interpreter-v0-beta") as cap: + cap.wait_ready(timeout=60) + + cap.run_code("x = 42") + r = cap.run_code("x * 2") + print(r.text) # "84" + + cap.run_code("def greet(name): return f'hello {name}'") + r = cap.run_code("greet('world')") + print(r.text) # "'hello world'" + + r = cap.run_code("1/0") + print(r.error) # "ZeroDivisionError: division by zero\n..." +``` + +**`CodeResult` fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `text` | `str \| None` | Plain text representation | +| `data` | `dict \| None` | Rich MIME bundle (e.g. `{"image/png": "..."}`) | +| `stdout` | `str` | Accumulated stdout | +| `stderr` | `str` | Accumulated stderr | +| `error` | `str \| None` | Error traceback string | + +## Filesystem + +Upload, download, and manage files inside capsules: + +```python +# Upload / Download +cap.upload("/app/main.py", b"print('hello')") +content = cap.download("/app/main.py") + +# Streaming (for large files) +def chunks(): + yield b"chunk1" + yield b"chunk2" + +cap.stream_upload("/data/large.bin", chunks()) +for chunk in cap.stream_download("/data/large.bin"): + process(chunk) + +# Directory operations +entries = cap.list_dir("/home/user", depth=1) +for entry in entries: + print(entry.name, entry.type, entry.size) + +cap.mkdir("/home/user/data") +cap.remove("/home/user/old_data") +``` + +## Interactive Terminal (PTY) + +Open a full interactive terminal session over WebSocket: + +```python +with cap.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term: + term.write(b"ls -la\n") + for event in term: + if event.type == "output": + sys.stdout.buffer.write(event.data) + elif event.type == "exit": + break +``` + +**PtySession methods:** + +| Method | Description | +|--------|-------------| +| `write(data: bytes)` | Send raw bytes to stdin | +| `resize(cols, rows)` | Resize the terminal | +| `kill()` | Send SIGKILL to the process | +| `tag` | Session tag (available after `started` event) | +| `pid` | Process PID (available after `started` event) | + +Reconnect to an existing session using the tag: + +```python +with cap.pty_connect(term.tag) as term: + term.write(b"echo reconnected\n") +``` + +## Lifecycle + +Pause and resume capsules to save resources: + +```python +cap = client.capsules.create(template="minimal") +cap.wait_ready(timeout=60) + +# Pause (snapshots and releases resources) +cap.pause() +print(cap.status) # "paused" + +# Resume (restores from snapshot) +cap.resume() +cap.wait_ready(timeout=60) +``` + +Keep a capsule alive with `ping()`: + +```python +cap.ping() # Resets the inactivity timer +``` + +## Proxy URL + +Access services running inside a capsule through the proxy: + +```python +url = cap.get_url(8888) +# "wss://8888-cl-abc123.api.wrenn.dev" + +# Pre-configured HTTP client targeting port 8888 +resp = cap.http_client.get("/api/kernels") +``` + +## Snapshots + +Create templates from running capsules: + +```python +# Create a snapshot +template = client.snapshots.create( + capsule_id="cl-abc123", + name="my-template", + overwrite=True, +) + +# List templates +for t in client.snapshots.list(): + print(t.name, t.type) + +# Delete +client.snapshots.delete("my-template") +``` + +## Hosts + +Manage host machines: + +```python +host = client.hosts.create(type="regular") +client.hosts.list() +client.hosts.get("h-1") +client.hosts.delete("h-1") +client.hosts.regenerate_token("h-1") +client.hosts.list_tags("h-1") +client.hosts.add_tag("h-1", "gpu") +client.hosts.remove_tag("h-1", "gpu") +``` + +## Async Support + +All operations have async variants. Use `AsyncWrennClient` and prefix capsule methods with `async_`: + +```python +from wrenn import AsyncWrennClient + +async with AsyncWrennClient(api_key="wrn_...") as client: + cap = await client.capsules.create(template="minimal") + await cap.async_wait_ready(timeout=60) + + result = await cap.async_exec("echo", args=["hello"]) + await cap.async_upload("/app/file.txt", b"data") + entries = await cap.async_list_dir("/home/user") + r = await cap.async_run_code("42 * 2") + + await cap.async_destroy() +``` + +**Async method mapping:** + +| Sync | Async | +|------|-------| +| `exec()` | `async_exec()` | +| `upload()` | `async_upload()` | +| `download()` | `async_download()` | +| `stream_upload()` | `async_stream_upload()` | +| `stream_download()` | `async_stream_download()` | +| `list_dir()` | `async_list_dir()` | +| `mkdir()` | `async_mkdir()` | +| `remove()` | `async_remove()` | +| `wait_ready()` | `async_wait_ready()` | +| `pause()` | `async_pause()` | +| `resume()` | `async_resume()` | +| `destroy()` | `async_destroy()` | +| `ping()` | `async_ping()` | +| `run_code()` | `async_run_code()` | + +## Error Handling + +The SDK maps server error codes to typed exceptions: + +```python +from wrenn import ( + WrennError, + WrennValidationError, # 400 + WrennAuthenticationError, # 401 + WrennForbiddenError, # 403 + WrennNotFoundError, # 404 + WrennConflictError, # 409 + WrennHostHasCapsulesError, # 409 — host has running capsules + WrennAgentError, # 502 + WrennInternalError, # 500 + WrennHostUnavailableError, # 503 +) + +try: + client.capsules.get("nonexistent") +except WrennNotFoundError as e: + print(e.code) # "not_found" + print(e.message) # "capsule not found" + print(e.status_code) # 404 +``` + +All exceptions inherit from `WrennError` and expose `.code`, `.message`, and `.status_code`. + +## Development + +This project uses [uv](https://docs.astral.sh/uv/) for dependency management. + +```bash +# Install dependencies +uv sync + +# Run linting +make lint + +# Run unit tests +make test + +# Run all tests (including integration) +make test-integration + +# Regenerate models from OpenAPI spec +make generate +``` + +### Running Integration Tests + +Integration tests require a live Wrenn server. Set environment variables: + +```bash +export WRENN_API_KEY="wrn_..." +export WRENN_BASE_URL="http://localhost:8080" # optional +make test-integration +``` + +## License + +MIT -- 2.49.0 From 3cced768a4304cb80bd5d41f7802c7618ed38a92 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 15 Apr 2026 15:19:23 +0600 Subject: [PATCH 08/44] 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) --- src/wrenn/__init__.py | 33 +- src/wrenn/_config.py | 33 + src/wrenn/async_capsule.py | 269 ++++ src/wrenn/capsule.py | 1323 ++++--------------- src/wrenn/client.py | 348 +---- src/wrenn/code_interpreter/__init__.py | 8 + src/wrenn/code_interpreter/async_capsule.py | 199 +++ src/wrenn/code_interpreter/capsule.py | 244 ++++ src/wrenn/commands.py | 366 +++++ src/wrenn/files.py | 241 ++++ src/wrenn/sandbox.py | 10 +- tests/test_capsule_features.py | 228 ++-- tests/test_client.py | 251 +--- tests/test_filesystem_pty.py | 210 ++- 14 files changed, 1936 insertions(+), 1827 deletions(-) create mode 100644 src/wrenn/_config.py create mode 100644 src/wrenn/async_capsule.py create mode 100644 src/wrenn/code_interpreter/__init__.py create mode 100644 src/wrenn/code_interpreter/async_capsule.py create mode 100644 src/wrenn/code_interpreter/capsule.py create mode 100644 src/wrenn/commands.py create mode 100644 src/wrenn/files.py diff --git a/src/wrenn/__init__.py b/src/wrenn/__init__.py index c25aaf8..55447c6 100644 --- a/src/wrenn/__init__.py +++ b/src/wrenn/__init__.py @@ -1,7 +1,10 @@ -from wrenn.capsule import ( - Capsule, - CodeResult, - ExecResult, +from wrenn.async_capsule import AsyncCapsule +from wrenn.capsule import Capsule +from wrenn.client import AsyncWrennClient, WrennClient +from wrenn.commands import ( + CommandHandle, + CommandResult, + ProcessInfo, StreamErrorEvent, StreamEvent, StreamExitEvent, @@ -9,7 +12,6 @@ from wrenn.capsule import ( StreamStderrEvent, StreamStdoutEvent, ) -from wrenn.client import AsyncWrennClient, WrennClient from wrenn.exceptions import ( WrennAgentError, WrennAuthenticationError, @@ -29,12 +31,14 @@ __version__ = "0.1.0" __all__ = [ "__version__", + "AsyncCapsule", "AsyncPtySession", "AsyncWrennClient", "Capsule", - "CodeResult", - "ExecResult", + "CommandHandle", + "CommandResult", "FileEntry", + "ProcessInfo", "PtyEvent", "PtyEventType", "PtySession", @@ -61,22 +65,25 @@ __all__ = [ def __getattr__(name: str) -> type: - if name == "Sandbox": - import warnings + import sys + import warnings + _module = sys.modules[__name__] + + if name == "Sandbox": warnings.warn( "'Sandbox' is deprecated, use 'Capsule' instead", - DeprecationWarning, + FutureWarning, stacklevel=2, ) + setattr(_module, name, Capsule) return Capsule if name == "WrennHostHasSandboxesError": - import warnings - warnings.warn( "'WrennHostHasSandboxesError' is deprecated, use 'WrennHostHasCapsulesError' instead", - DeprecationWarning, + FutureWarning, stacklevel=2, ) + setattr(_module, name, WrennHostHasCapsulesError) return WrennHostHasCapsulesError raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/wrenn/_config.py b/src/wrenn/_config.py new file mode 100644 index 0000000..a9b57ad --- /dev/null +++ b/src/wrenn/_config.py @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..e99a5b2 --- /dev/null +++ b/src/wrenn/async_capsule.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import asyncio +import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import httpx_ws + +from wrenn.capsule import _DualMethod, _build_proxy_url +from wrenn.client import AsyncWrennClient +from wrenn.commands import AsyncCommands +from wrenn.files import AsyncFiles +from wrenn.models import Capsule as CapsuleModel +from wrenn.models import Status, Template +from wrenn.pty import AsyncPtySession + + +class AsyncCapsule: + """Async Wrenn capsule with e2b-compatible interface. + + Create via classmethod:: + + capsule = await AsyncCapsule.create(template="minimal") + + Use as async context manager:: + + async with await AsyncCapsule.create() as capsule: + await capsule.commands.run("echo hello") + """ + + def __init__( + self, + *, + _capsule_id: str, + _client: AsyncWrennClient, + _info: CapsuleModel | None = None, + ) -> None: + self._id = _capsule_id + self._client = _client + self._info = _info + + self.commands = AsyncCommands(_capsule_id, _client.http) + self.files = AsyncFiles(_capsule_id, _client.http) + + # ── Properties ────────────────────────────────────────────── + + @property + def capsule_id(self) -> str: + return self._id + + @property + def info(self) -> CapsuleModel | None: + return self._info + + # ── Factory classmethods ──────────────────────────────────── + + @classmethod + async def create( + cls, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + api_key: str | None = None, + base_url: str | None = None, + ) -> AsyncCapsule: + """Create a new capsule.""" + client = AsyncWrennClient(api_key=api_key, base_url=base_url) + info = await client.capsules.create( + template=template, + vcpus=vcpus, + memory_mb=memory_mb, + timeout_sec=timeout, + ) + return cls( + _capsule_id=info.id, + _client=client, + _info=info, + ) + + @classmethod + async def connect( + cls, + capsule_id: str, + *, + api_key: str | None = None, + base_url: str | None = None, + ) -> AsyncCapsule: + """Connect to an existing capsule. Resumes it if paused.""" + client = AsyncWrennClient(api_key=api_key, base_url=base_url) + info = await client.capsules.get(capsule_id) + + if info.status == Status.paused: + info = await client.capsules.resume(capsule_id) + + return cls( + _capsule_id=capsule_id, + _client=client, + _info=info, + ) + + # ── Dual instance/static lifecycle ────────────────────────── + + kill = _DualMethod("_instance_kill", "_static_kill") + pause = _DualMethod("_instance_pause", "_static_pause") + resume = _DualMethod("_instance_resume", "_static_resume") + get_info = _DualMethod("_instance_get_info", "_static_get_info") + + async def _instance_kill(self) -> None: + await self._client.capsules.destroy(self._id) + + @classmethod + async def _static_kill( + cls, + capsule_id: str, + *, + api_key: str | None = None, + base_url: str | None = None, + ) -> None: + async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client: + await client.capsules.destroy(capsule_id) + + async def _instance_pause(self) -> CapsuleModel: + self._info = await self._client.capsules.pause(self._id) + return self._info + + @classmethod + async def _static_pause( + cls, + capsule_id: str, + *, + api_key: str | None = None, + base_url: str | None = None, + ) -> CapsuleModel: + async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client: + return await client.capsules.pause(capsule_id) + + async def _instance_resume(self) -> CapsuleModel: + self._info = await self._client.capsules.resume(self._id) + return self._info + + @classmethod + async def _static_resume( + cls, + capsule_id: str, + *, + api_key: str | None = None, + base_url: str | None = None, + ) -> CapsuleModel: + async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client: + return await client.capsules.resume(capsule_id) + + async def _instance_get_info(self) -> CapsuleModel: + self._info = await self._client.capsules.get(self._id) + return self._info + + @classmethod + async def _static_get_info( + cls, + capsule_id: str, + *, + api_key: str | None = None, + base_url: str | None = None, + ) -> CapsuleModel: + async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client: + return await client.capsules.get(capsule_id) + + # ── Instance-only methods ─────────────────────────────────── + + async def ping(self) -> None: + await self._client.capsules.ping(self._id) + + async def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + info = await self._client.capsules.get(self._id) + if info.status == Status.running: + self._info = info + return + if info.status in (Status.error, Status.stopped, Status.paused): + raise RuntimeError( + f"Capsule entered {info.status} state while waiting" + ) + await asyncio.sleep(interval) + raise TimeoutError( + f"Capsule {self._id} did not become ready within {timeout}s" + ) + + async def is_running(self) -> bool: + info = await self._instance_get_info() + return info.status == Status.running + + # ── Static list ───────────────────────────────────────────── + + @classmethod + async def list( + cls, + *, + api_key: str | None = None, + base_url: str | None = None, + ) -> list[CapsuleModel]: + async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client: + return await client.capsules.list() + + # ── PTY ───────────────────────────────────────────────────── + + @asynccontextmanager + async def pty( + self, + cmd: str = "/bin/bash", + args: list[str] | None = None, + cols: int = 80, + rows: int = 24, + envs: dict[str, str] | None = None, + cwd: str | None = None, + ) -> AsyncIterator[AsyncPtySession]: + async with httpx_ws.aconnect_ws( + f"/v1/capsules/{self._id}/pty", client=self._client.http + ) as ws: + session = AsyncPtySession(ws, self._id) + await session._send_start( + cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd + ) + yield session + + @asynccontextmanager + async def pty_connect(self, tag: str) -> AsyncIterator[AsyncPtySession]: + async with httpx_ws.aconnect_ws( + f"/v1/capsules/{self._id}/pty", client=self._client.http + ) as ws: + session = AsyncPtySession(ws, self._id) + await session._send_connect(tag) + yield session + + # ── Proxy helpers ─────────────────────────────────────────── + + def get_url(self, port: int) -> str: + return _build_proxy_url(self._client._base_url, self._id, port) + + # ── Snapshots ─────────────────────────────────────────────── + + async def create_snapshot( + self, name: str | None = None, overwrite: bool = False + ) -> Template: + return await self._client.snapshots.create( + capsule_id=self._id, name=name, overwrite=overwrite + ) + + # ── Context manager ───────────────────────────────────────── + + async def __aenter__(self) -> AsyncCapsule: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object, + ) -> None: + try: + await self._instance_kill() + except Exception: + pass + try: + await self._client.aclose() + except Exception: + pass diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py index 17fec62..ba77e71 100644 --- a/src/wrenn/capsule.py +++ b/src/wrenn/capsule.py @@ -1,151 +1,19 @@ from __future__ import annotations -import asyncio -import base64 -import json -import os import time -import uuid -import warnings -from collections.abc import AsyncIterator, Iterator -from contextlib import asynccontextmanager, contextmanager +from collections.abc import Iterator +from contextlib import contextmanager from typing import Any import httpx import httpx_ws -from wrenn.exceptions import handle_response +from wrenn.client import WrennClient +from wrenn.commands import Commands +from wrenn.files import Files from wrenn.models import Capsule as CapsuleModel -from wrenn.models import ( - ExecResponse, - FileEntry, - ListDirResponse, - MakeDirResponse, - Status, -) -from wrenn.pty import AsyncPtySession, PtySession - - -class ExecResult: - """Typed result from a synchronous exec call.""" - - __slots__ = ("stdout", "stderr", "exit_code", "duration_ms", "encoding") - - def __init__( - self, - stdout: str, - stderr: str, - exit_code: int, - duration_ms: int | None, - encoding: str | None, - ) -> None: - self.stdout = stdout - self.stderr = stderr - self.exit_code = exit_code - self.duration_ms = duration_ms - self.encoding = encoding - - -class CodeResult: - """Typed result from stateful code execution (``run_code``). - - Attributes: - text: text/plain representation of the result. - data: rich MIME bundle (e.g. ``{"image/png": "..."}``). - stdout: accumulated stdout output. - stderr: accumulated stderr output. - error: language-specific error/traceback string. - """ - - __slots__ = ("text", "data", "stdout", "stderr", "error") - - def __init__( - self, - text: str | None = None, - data: dict[str, str] | None = None, - stdout: str = "", - stderr: str = "", - error: str | None = None, - ) -> None: - self.text = text - self.data = data - self.stdout = stdout - self.stderr = stderr - self.error = error - - -class StreamEvent: - """Base class for streaming exec events.""" - - __slots__ = ("type",) - - def __init__(self, type: str) -> None: - self.type = type - - -class StreamStartEvent(StreamEvent): - """Process started.""" - - __slots__ = ("pid",) - - def __init__(self, pid: int) -> None: - super().__init__("start") - self.pid = pid - - -class StreamStdoutEvent(StreamEvent): - """Stdout data received.""" - - __slots__ = ("data",) - - def __init__(self, data: str) -> None: - super().__init__("stdout") - self.data = data - - -class StreamStderrEvent(StreamEvent): - """Stderr data received.""" - - __slots__ = ("data",) - - def __init__(self, data: str) -> None: - super().__init__("stderr") - self.data = data - - -class StreamExitEvent(StreamEvent): - """Process exited.""" - - __slots__ = ("exit_code",) - - def __init__(self, exit_code: int) -> None: - super().__init__("exit") - self.exit_code = exit_code - - -class StreamErrorEvent(StreamEvent): - """Error occurred.""" - - __slots__ = ("data",) - - def __init__(self, data: str) -> None: - super().__init__("error") - self.data = data - - -def _parse_stream_event(raw: dict) -> StreamEvent: - t = raw.get("type") - if t == "start": - return StreamStartEvent(pid=raw.get("pid", 0)) - if t == "stdout": - return StreamStdoutEvent(data=raw.get("data", "")) - if t == "stderr": - return StreamStderrEvent(data=raw.get("data", "")) - if t == "exit": - return StreamExitEvent(exit_code=raw.get("exit_code", -1)) - if t == "error": - return StreamErrorEvent(data=raw.get("data", "")) - return StreamEvent(type=t or "unknown") +from wrenn.models import Status, Template +from wrenn.pty import PtySession def _build_proxy_url(base_url: str, capsule_id: str | None, port: int) -> str: @@ -157,560 +25,243 @@ def _build_proxy_url(base_url: str, capsule_id: str | None, port: int) -> str: return f"{scheme}://{port}-{capsule_id}.{host}" -class Capsule(CapsuleModel): - """Developer-facing capsule interface wrapping the generated Capsule model. +class _DualMethod: + """Descriptor that dispatches to instance method or classmethod depending on call site.""" - Provides data-plane methods (exec, file I/O, lifecycle), capsule proxy - helpers, and context-manager support for automatic cleanup. + def __init__(self, instance_fn_name: str, static_fn_name: str) -> None: + self._ifn = instance_fn_name + self._sfn = static_fn_name + + def __set_name__(self, owner: type, name: str) -> None: + self._name = name + + def __get__(self, obj: Any, cls: type) -> Any: + if obj is None: + return getattr(cls, self._sfn) + return getattr(obj, self._ifn) + + +class Capsule: + """A Wrenn capsule (sandbox) with e2b-compatible interface. + + Create directly:: + + capsule = Capsule(api_key="wrn_...") + capsule = Capsule(template="minimal") # reads WRENN_API_KEY env + + Or via classmethod:: + + capsule = Capsule.create(template="minimal") + + Use as context manager for automatic cleanup:: + + with Capsule() as capsule: + capsule.commands.run("echo hello") """ - _http: httpx.Client | None - _async_http: httpx.AsyncClient | None - _base_url: str - _api_key: str | None - _token: str | None - _proxy_client: httpx.Client | None - _async_proxy_client: httpx.AsyncClient | None - _kernel_id: str | None - _jupyter_ws: Any - _async_jupyter_ws: Any - - def _bind( + def __init__( self, - http: httpx.Client | httpx.AsyncClient, - base_url: str, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, api_key: str | None = None, - token: str | None = None, + base_url: str | None = None, + # Private: used by classmethods to skip creation + _capsule_id: str | None = None, + _client: WrennClient | None = None, + _info: CapsuleModel | None = None, ) -> None: - self._base_url = base_url - self._api_key = api_key - self._token = token - self._proxy_client = None - self._async_proxy_client = None - self._kernel_id = None - self._jupyter_ws = None - self._async_jupyter_ws = None - if isinstance(http, httpx.Client): - self._http = http - self._async_http = None + 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 else: - self._http = None # type: ignore[assignment] - self._async_http = http + # 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 - def _proxy_headers(self) -> dict[str, str]: - headers: dict[str, str] = {} - if self._api_key: - headers["X-API-Key"] = self._api_key - if self._token: - headers["Authorization"] = f"Bearer {self._token}" - return headers + self.commands = Commands(self._id, self._client.http) + self.files = Files(self._id, self._client.http) - def _clear_content_type(self) -> dict[str, str]: - assert self._http is not None - headers = dict(self._http.headers) - headers.pop("Content-Type", None) - return headers - - def _async_clear_content_type(self) -> dict[str, str]: - assert self._async_http is not None - headers = dict(self._async_http.headers) - headers.pop("Content-Type", None) - return headers - - def get_url(self, port: int) -> str: - """Construct the proxy URL for a port inside this capsule. - - Args: - port: Port number of the service running inside the capsule. - - Returns: - A URL string like ``http://8888-cl-abc123.api.wrenn.dev``. - """ - return _build_proxy_url(self._base_url, self.id, port) + # ── Properties ────────────────────────────────────────────── @property - def http_client(self) -> httpx.Client: - """A pre-configured ``httpx.Client`` targeting the capsule proxy on port 8888. + def capsule_id(self) -> str: + return self._id - The client has auth headers set and ``base_url`` pointing to - the proxy URL for port 8888. Closed automatically when the capsule exits. - """ - if self._proxy_client is None: - url = ( - _build_proxy_url(self._base_url, self.id, 8888) - .replace("ws://", "http://") - .replace("wss://", "https://") - ) - self._proxy_client = httpx.Client( - base_url=url, - headers=self._proxy_headers(), - ) - return self._proxy_client + @property + def info(self) -> CapsuleModel | None: + return self._info + + # ── Factory classmethods ──────────────────────────────────── + + @classmethod + def create( + cls, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + api_key: str | None = None, + base_url: str | None = None, + ) -> Capsule: + """Create a new capsule. Alias for ``Capsule(...)``.""" + return cls( + template=template, + vcpus=vcpus, + memory_mb=memory_mb, + timeout=timeout, + api_key=api_key, + base_url=base_url, + ) + + @classmethod + def connect( + cls, + capsule_id: str, + *, + api_key: str | None = None, + base_url: str | None = None, + ) -> Capsule: + """Connect to an existing capsule. Resumes it if paused.""" + client = WrennClient(api_key=api_key, base_url=base_url) + info = client.capsules.get(capsule_id) + + if info.status == Status.paused: + info = client.capsules.resume(capsule_id) + + return cls( + _capsule_id=capsule_id, + _client=client, + _info=info, + ) + + # ── Dual instance/static lifecycle ────────────────────────── + + kill = _DualMethod("_instance_kill", "_static_kill") + pause = _DualMethod("_instance_pause", "_static_pause") + resume = _DualMethod("_instance_resume", "_static_resume") + get_info = _DualMethod("_instance_get_info", "_static_get_info") + + def _instance_kill(self) -> None: + """Destroy this capsule.""" + self._client.capsules.destroy(self._id) + + @classmethod + def _static_kill( + cls, + capsule_id: str, + *, + api_key: str | None = None, + base_url: str | None = None, + ) -> None: + """Destroy a capsule by ID.""" + with WrennClient(api_key=api_key, base_url=base_url) as client: + client.capsules.destroy(capsule_id) + + def _instance_pause(self) -> CapsuleModel: + """Pause this capsule.""" + self._info = self._client.capsules.pause(self._id) + return self._info + + @classmethod + def _static_pause( + cls, + capsule_id: str, + *, + api_key: str | None = None, + base_url: str | None = None, + ) -> CapsuleModel: + """Pause a capsule by ID.""" + with WrennClient(api_key=api_key, base_url=base_url) as client: + return client.capsules.pause(capsule_id) + + def _instance_resume(self) -> CapsuleModel: + """Resume this capsule.""" + self._info = self._client.capsules.resume(self._id) + return self._info + + @classmethod + def _static_resume( + cls, + capsule_id: str, + *, + api_key: str | None = None, + base_url: str | None = None, + ) -> CapsuleModel: + """Resume a capsule by ID.""" + with WrennClient(api_key=api_key, base_url=base_url) as client: + return client.capsules.resume(capsule_id) + + def _instance_get_info(self) -> CapsuleModel: + """Get current info for this capsule.""" + self._info = self._client.capsules.get(self._id) + return self._info + + @classmethod + def _static_get_info( + cls, + capsule_id: str, + *, + api_key: str | None = None, + base_url: str | None = None, + ) -> CapsuleModel: + """Get capsule info by ID.""" + with WrennClient(api_key=api_key, base_url=base_url) as client: + return client.capsules.get(capsule_id) + + # ── Instance-only methods ─────────────────────────────────── + + def ping(self) -> None: + """Reset the capsule inactivity timer.""" + self._client.capsules.ping(self._id) def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None: - """Block until the capsule status is ``running``. - - Args: - timeout: Maximum seconds to wait. - interval: Seconds between polls. - - Raises: - TimeoutError: If the capsule does not become ready in time. - """ - assert self._http is not None + """Block until the capsule status is ``running``.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: - resp = self._http.get(f"/v1/capsules/{self.id}") - data = resp.json() - status = data.get("status") - if status == Status.running: - self.status = Status.running + info = self._client.capsules.get(self._id) + if info.status == Status.running: + self._info = info return - if status in (Status.error, Status.stopped): - raise RuntimeError(f"Capsule entered {status} state while waiting") + if info.status in (Status.error, Status.stopped, Status.paused): + raise RuntimeError( + f"Capsule entered {info.status} state while waiting" + ) time.sleep(interval) - raise TimeoutError(f"Capsule {self.id} did not become ready within {timeout}s") - - async def async_wait_ready( - self, timeout: float = 30, interval: float = 0.5 - ) -> None: - """Async version of ``wait_ready``.""" - assert self._async_http is not None - import asyncio - - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - resp = await self._async_http.get(f"/v1/capsules/{self.id}") - data = resp.json() - status = data.get("status") - if status == Status.running: - self.status = Status.running - return - if status in (Status.error, Status.stopped): - raise RuntimeError(f"Capsule entered {status} state while waiting") - await asyncio.sleep(interval) - raise TimeoutError(f"Capsule {self.id} did not become ready within {timeout}s") - - def exec( - self, - cmd: str, - args: list[str] | None = None, - timeout_sec: int | None = 30, - ) -> ExecResult: - """Execute a command synchronously inside the capsule. - - Args: - cmd: Command to run. - args: Optional positional arguments. - timeout_sec: Execution timeout in seconds. - - Returns: - An ``ExecResult`` with ``stdout``, ``stderr``, ``exit_code``, ``duration_ms``. - """ - assert self._http is not None - payload: dict = {"cmd": cmd} - if args is not None: - payload["args"] = args - if timeout_sec is not None: - payload["timeout_sec"] = timeout_sec - resp = self._http.post(f"/v1/capsules/{self.id}/exec", json=payload) - resp.raise_for_status() - er = ExecResponse.model_validate(resp.json()) - stdout = er.stdout or "" - stderr = er.stderr or "" - if er.encoding == "base64": - stdout = base64.b64decode(stdout).decode("utf-8", errors="replace") - if stderr: - stderr = base64.b64decode(stderr).decode("utf-8", errors="replace") - return ExecResult( - stdout=stdout, - stderr=stderr, - exit_code=er.exit_code if er.exit_code is not None else -1, - duration_ms=er.duration_ms, - encoding=er.encoding, + raise TimeoutError( + f"Capsule {self._id} did not become ready within {timeout}s" ) - async def async_exec( - self, - cmd: str, - args: list[str] | None = None, - timeout_sec: int | None = 30, - ) -> ExecResult: - """Async version of ``exec``.""" - assert self._async_http is not None - payload: dict = {"cmd": cmd} - if args is not None: - payload["args"] = args - if timeout_sec is not None: - payload["timeout_sec"] = timeout_sec - resp = await self._async_http.post(f"/v1/capsules/{self.id}/exec", json=payload) - resp.raise_for_status() - er = ExecResponse.model_validate(resp.json()) - stdout = er.stdout or "" - stderr = er.stderr or "" - if er.encoding == "base64": - stdout = base64.b64decode(stdout).decode("utf-8", errors="replace") - if stderr: - stderr = base64.b64decode(stderr).decode("utf-8", errors="replace") - return ExecResult( - stdout=stdout, - stderr=stderr, - exit_code=er.exit_code if er.exit_code is not None else -1, - duration_ms=er.duration_ms, - encoding=er.encoding, - ) + def is_running(self) -> bool: + info = self._instance_get_info() + return info.status == Status.running - def exec_stream( - self, - cmd: str, - args: list[str] | None = None, - ) -> Iterator[StreamEvent]: - """Execute a command via WebSocket, yielding ``StreamEvent`` objects. + # ── Static list ───────────────────────────────────────────── - Args: - cmd: Command to run. - args: Optional positional arguments. + @classmethod + def list( + cls, + *, + api_key: str | None = None, + base_url: str | None = None, + ) -> list[CapsuleModel]: + """List all capsules for the team.""" + with WrennClient(api_key=api_key, base_url=base_url) as client: + return client.capsules.list() - Yields: - ``StreamStartEvent``, ``StreamStdoutEvent``, ``StreamStderrEvent``, - ``StreamExitEvent``, or ``StreamErrorEvent``. - """ - assert self._http is not None - ws: httpx_ws.WebSocketSession - with httpx_ws.connect_ws( # type: ignore[attr-defined] - f"/v1/capsules/{self.id}/exec/stream", - self._http, - ) as ws: - start_msg: dict = {"type": "start", "cmd": cmd} - if args: - start_msg["args"] = args - ws.send_text(json.dumps(start_msg)) - while True: - try: - raw_data: dict = ws.receive_json() # type: ignore[assignment] - event = _parse_stream_event(raw_data) - yield event - - if event.type in ("exit", "error"): - break - - except httpx_ws.WebSocketDisconnect: - break - - async def async_exec_stream( - self, cmd: str, args: list[str] | None = None - ) -> AsyncIterator[StreamEvent]: - """Async version of ``exec_stream``.""" - assert self._async_http is not None - ws: httpx_ws.AsyncWebSocketSession - async with httpx_ws.aconnect_ws( # type: ignore[attr-defined, var-annotated] - f"/v1/capsules/{self.id}/exec/stream", self._async_http - ) as ws: - start_msg: dict = {"type": "start", "cmd": cmd} - if args: - start_msg["args"] = args - await ws.send_text(json.dumps(start_msg)) - - try: - while True: - raw_data = await ws.receive_json() - event = _parse_stream_event(raw_data) - yield event - - if event.type in ("exit", "error"): - break - except httpx_ws.WebSocketDisconnect: - pass - - def upload(self, path: str, data: bytes) -> None: - """Upload a small file to the capsule. - - Args: - path: Absolute destination path inside the capsule. - data: File contents as bytes. - """ - assert self._http is not None - resp = self._http.post( - f"/v1/capsules/{self.id}/files/write", - files={"file": ("upload", data)}, - data={"path": path}, - ) - - resp.raise_for_status() - - async def async_upload(self, path: str, data: bytes) -> None: - """Async version of ``upload``.""" - assert self._async_http is not None - resp = await self._async_http.post( - f"/v1/capsules/{self.id}/files/write", - files={"file": ("upload", data)}, - data={"path": path}, - ) - resp.raise_for_status() - - def download(self, path: str) -> bytes: - """Download a small file from the capsule. - - Args: - path: Absolute file path inside the capsule. - - Returns: - File contents as bytes. - """ - assert self._http is not None - resp = self._http.post( - f"/v1/capsules/{self.id}/files/read", - json={"path": path}, - ) - resp.raise_for_status() - return resp.content - - async def async_download(self, path: str) -> bytes: - """Async version of ``download``.""" - assert self._async_http is not None - resp = await self._async_http.post( - f"/v1/capsules/{self.id}/files/read", - json={"path": path}, - ) - resp.raise_for_status() - return resp.content - - def stream_upload(self, path: str, stream: Iterator[bytes]) -> None: - """Streaming upload for large files. - - Args: - path: Absolute destination path inside the capsule. - stream: An iterator yielding byte chunks. - """ - assert self._http is not None - - boundary = os.urandom(16).hex().encode("utf-8") - - def _multipart_stream() -> Iterator[bytes]: - yield b"--" + boundary + b"\r\n" - yield b'Content-Disposition: form-data; name="path"\r\n\r\n' - yield path.encode("utf-8") + b"\r\n" - - yield b"--" + boundary + b"\r\n" - yield b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n' - yield b"Content-Type: application/octet-stream\r\n\r\n" - - for chunk in stream: - yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") - - yield b"\r\n--" + boundary + b"--\r\n" - - headers = { - "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}" - } - - resp = self._http.post( - f"/v1/capsules/{self.id}/files/stream/write", - content=_multipart_stream(), - headers=headers, - ) - resp.raise_for_status() - - async def async_stream_upload( - self, path: str, stream: AsyncIterator[bytes] - ) -> None: - """Async version of ``stream_upload``.""" - assert self._async_http is not None - - boundary = os.urandom(16).hex().encode("utf-8") - - async def _async_multipart_stream() -> AsyncIterator[bytes]: - yield b"--" + boundary + b"\r\n" - yield b'Content-Disposition: form-data; name="path"\r\n\r\n' - yield path.encode("utf-8") + b"\r\n" - - yield b"--" + boundary + b"\r\n" - yield b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n' - yield b"Content-Type: application/octet-stream\r\n\r\n" - - async for chunk in stream: - yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") - - yield b"\r\n--" + boundary + b"--\r\n" - - headers = { - "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}" - } - - resp = await self._async_http.post( - f"/v1/capsules/{self.id}/files/stream/write", - content=_async_multipart_stream(), - headers=headers, - ) - resp.raise_for_status() - - def stream_download(self, path: str) -> Iterator[bytes]: - """Streaming download for large files. - - Args: - path: Absolute file path inside the capsule. - - Yields: - Byte chunks. - """ - assert self._http is not None - with self._http.stream( - "POST", - f"/v1/capsules/{self.id}/files/stream/read", - json={"path": path}, - ) as resp: - resp.raise_for_status() - yield from resp.iter_bytes() - - async def async_stream_download(self, path: str) -> AsyncIterator[bytes]: - """Async version of ``stream_download``.""" - assert self._async_http is not None - async with self._async_http.stream( - "POST", - f"/v1/capsules/{self.id}/files/stream/read", - json={"path": path}, - ) as resp: - resp.raise_for_status() - async for chunk in resp.aiter_bytes(): - yield chunk - - def list_dir(self, path: str, depth: int = 1) -> list[FileEntry]: - """List directory contents inside the capsule. - - Args: - path: Absolute directory path. - depth: Recursion depth. 1 = immediate children only. - - Returns: - List of FileEntry objects with full metadata. - - Raises: - WrennValidationError: Invalid path. - WrennNotFoundError: Capsule or directory not found. - WrennConflictError: Capsule is not running. - WrennAgentError: Agent error. - WrennHostUnavailableError: Host agent not reachable. - """ - assert self._http is not None - resp = self._http.post( - f"/v1/capsules/{self.id}/files/list", - json={"path": path, "depth": depth}, - ) - data = handle_response(resp) - parsed = ListDirResponse.model_validate(data) - return parsed.entries or [] - - async def async_list_dir(self, path: str, depth: int = 1) -> list[FileEntry]: - """Async version of ``list_dir``.""" - assert self._async_http is not None - resp = await self._async_http.post( - f"/v1/capsules/{self.id}/files/list", - json={"path": path, "depth": depth}, - ) - data = handle_response(resp) - parsed = ListDirResponse.model_validate(data) - return parsed.entries or [] - - def mkdir(self, path: str) -> FileEntry: - """Create a directory inside the capsule (with parents). - - Args: - path: Absolute directory path to create. - - Returns: - FileEntry for the created directory. - - Raises: - WrennValidationError: Path exists and is not a directory. - WrennConflictError: Directory already exists (returns existing entry). - Capsule is not running. - WrennNotFoundError: Capsule not found. - WrennAgentError: Agent error. - WrennHostUnavailableError: Host agent not reachable. - """ - assert self._http is not None - resp = self._http.post( - f"/v1/capsules/{self.id}/files/mkdir", - json={"path": path}, - ) - if resp.status_code == 409: - try: - body = resp.json() - err = body.get("error", {}) - if err.get("code") == "conflict": - parent_dir = os.path.dirname(path) - dir_name = os.path.basename(path) - - listing = self.list_dir(parent_dir, depth=0) - for entry in listing: - if entry.name == dir_name: - return entry - except Exception: - pass - data = handle_response(resp) - parsed = MakeDirResponse.model_validate(data) - if parsed.entry is None: - raise RuntimeError("mkdir response missing entry") - return parsed.entry - - async def async_mkdir(self, path: str) -> FileEntry: - """Async version of ``mkdir``.""" - assert self._async_http is not None - resp = await self._async_http.post( - f"/v1/capsules/{self.id}/files/mkdir", - json={"path": path}, - ) - if resp.status_code == 409: - try: - body = resp.json() - err = body.get("error", {}) - if err.get("code") == "conflict": - listing = await self.async_list_dir(path, depth=0) - parent_dir = os.path.dirname(path) - dir_name = os.path.basename(path) - - listing = self.list_dir(parent_dir, depth=0) - for entry in listing: - if entry.name == dir_name: - return entry - except Exception: - pass - data = handle_response(resp) - parsed = MakeDirResponse.model_validate(data) - if parsed.entry is None: - raise RuntimeError("mkdir response missing entry") - return parsed.entry - - def remove(self, path: str) -> None: - """Remove a file or directory inside the capsule. - - Removes recursively. No confirmation or dry-run. Equivalent to rm -rf. - - Args: - path: Absolute path to remove. - - Raises: - WrennValidationError: Invalid path. - WrennNotFoundError: Capsule not found. - WrennConflictError: Capsule is not running. - WrennAgentError: Agent error. - WrennHostUnavailableError: Host agent not reachable. - """ - assert self._http is not None - resp = self._http.post( - f"/v1/capsules/{self.id}/files/remove", - json={"path": path}, - ) - handle_response(resp) - - async def async_remove(self, path: str) -> None: - """Async version of ``remove``.""" - assert self._async_http is not None - resp = await self._async_http.post( - f"/v1/capsules/{self.id}/files/remove", - json={"path": path}, - ) - handle_response(resp) + # ── PTY ───────────────────────────────────────────────────── @contextmanager def pty( @@ -722,25 +273,11 @@ class Capsule(CapsuleModel): envs: dict[str, str] | None = None, cwd: str | None = None, ) -> Iterator[PtySession]: - """Open an interactive PTY session. - - Args: - cmd: Command to run. Defaults to /bin/bash. - args: Command arguments. - cols: Terminal columns. Defaults to 80. - rows: Terminal rows. Defaults to 24. - envs: Environment variables. - cwd: Working directory. - - Returns: - A PtySession context manager. Use with a ``with`` statement. - """ - assert self._http is not None - assert self.id is not None - with httpx_ws.connect_ws( # type: ignore[attr-defined] - f"/v1/capsules/{self.id}/pty", client=self._http + """Open an interactive PTY session.""" + with httpx_ws.connect_ws( + f"/v1/capsules/{self._id}/pty", client=self._client.http ) as ws: - session = PtySession(ws, self.id) + session = PtySession(ws, self._id) session._send_start( cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd ) @@ -748,386 +285,31 @@ class Capsule(CapsuleModel): @contextmanager def pty_connect(self, tag: str) -> Iterator[PtySession]: - """Reconnect to an existing PTY session. - - Args: - tag: Session tag from a previous PtySession. - - Returns: - A PtySession context manager. - """ - assert self._http is not None - assert self.id is not None + """Reconnect to an existing PTY session by tag.""" with httpx_ws.connect_ws( - f"/v1/capsules/{self.id}/pty", client=self._http + f"/v1/capsules/{self._id}/pty", client=self._client.http ) as ws: - session = PtySession(ws, self.id) + session = PtySession(ws, self._id) session._send_connect(tag) yield session - @asynccontextmanager - async def async_pty( - self, - cmd: str = "/bin/bash", - args: list[str] | None = None, - cols: int = 80, - rows: int = 24, - envs: dict[str, str] | None = None, - cwd: str | None = None, - ) -> AsyncIterator[AsyncPtySession]: - """Async version of ``pty``.""" - assert self._async_http is not None - assert self.id is not None - async with httpx_ws.aconnect_ws( # type: ignore[attr-defined, misc] - f"/v1/capsules/{self.id}/pty", client=self._async_http - ) as ws: - session = AsyncPtySession(ws, self.id) - await session._send_start( - cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd - ) - yield session + # ── Proxy helpers ─────────────────────────────────────────── - @asynccontextmanager - async def async_pty_connect(self, tag: str) -> AsyncIterator[AsyncPtySession]: - """Async version of ``pty_connect``.""" - assert self._async_http is not None - assert self.id is not None - async with httpx_ws.aconnect_ws( # type: ignore[attr-defined, misc] - f"/v1/capsules/{self.id}/pty", client=self._async_http - ) as ws: - session = AsyncPtySession(ws, self.id) - await session._send_connect(tag) - yield session + def get_url(self, port: int) -> str: + """Get the proxy URL for a port inside this capsule.""" + return _build_proxy_url(self._client._base_url, self._id, port) - def ping(self) -> None: - """Reset the capsule inactivity timer.""" - assert self._http is not None - resp = self._http.post(f"/v1/capsules/{self.id}/ping") - resp.raise_for_status() + # ── Snapshots ─────────────────────────────────────────────── - async def async_ping(self) -> None: - """Async version of ``ping``.""" - assert self._async_http is not None - resp = await self._async_http.post(f"/v1/capsules/{self.id}/ping") - resp.raise_for_status() - - def pause(self) -> Capsule: - """Pause the capsule (snapshot and release resources). - - Returns: - Updated ``Capsule`` with new status. - """ - assert self._http is not None - resp = self._http.post(f"/v1/capsules/{self.id}/pause") - resp.raise_for_status() - updated = Capsule.model_validate(resp.json()) - self.status = updated.status - return self - - async def async_pause(self) -> Capsule: - """Async version of ``pause``.""" - assert self._async_http is not None - resp = await self._async_http.post(f"/v1/capsules/{self.id}/pause") - resp.raise_for_status() - updated = Capsule.model_validate(resp.json()) - self.status = updated.status - return self - - def resume(self) -> Capsule: - """Resume a paused capsule from its snapshot. - - Returns: - Updated ``Capsule`` with new status. - """ - assert self._http is not None - resp = self._http.post(f"/v1/capsules/{self.id}/resume") - resp.raise_for_status() - updated = Capsule.model_validate(resp.json()) - self.status = updated.status - return self - - async def async_resume(self) -> Capsule: - """Async version of ``resume``.""" - assert self._async_http is not None - resp = await self._async_http.post(f"/v1/capsules/{self.id}/resume") - resp.raise_for_status() - updated = Capsule.model_validate(resp.json()) - self.status = updated.status - return self - - def destroy(self) -> None: - """Tear down the capsule.""" - assert self._http is not None - resp = self._http.delete(f"/v1/capsules/{self.id}") - resp.raise_for_status() - - async def async_destroy(self) -> None: - """Async version of ``destroy``.""" - assert self._async_http is not None - resp = await self._async_http.delete(f"/v1/capsules/{self.id}") - resp.raise_for_status() - - def _ensure_kernel(self, jupyter_timeout: float = 30) -> str: - """Ensure a Jupyter kernel is running, creating one if needed. - - Polls the Jupyter server until it responds, then creates a kernel. - - Args: - jupyter_timeout: Maximum seconds to wait for Jupyter to become available. - - Returns: - The kernel ID. - - Raises: - TimeoutError: If Jupyter doesn't respond within the timeout. - """ - current_kernel = self._kernel_id - if current_kernel is not None: - return current_kernel - deadline = time.monotonic() + jupyter_timeout - last_exc: Exception | None = None - while time.monotonic() < deadline: - try: - resp = self.http_client.post("/api/kernels") - if resp.status_code < 500: - resp.raise_for_status() - data = resp.json() - self._kernel_id = data["id"] - return str(self._kernel_id) - last_exc = httpx.HTTPStatusError( - f"Jupyter returned {resp.status_code}", - request=resp.request, - response=resp, - ) - except httpx.HTTPStatusError: - raise - except Exception as exc: - last_exc = exc - time.sleep(0.5) - raise TimeoutError( - f"Jupyter not available within {jupyter_timeout}s: {last_exc}" + def create_snapshot( + self, name: str | None = None, overwrite: bool = False + ) -> Template: + """Create a snapshot template from this capsule.""" + return self._client.snapshots.create( + capsule_id=self._id, name=name, overwrite=overwrite ) - async def _async_ensure_kernel(self, jupyter_timeout: float = 30) -> str: - """Async version of ``_ensure_kernel``.""" - import asyncio - - current_kernel = self._kernel_id - if current_kernel is not None: - return current_kernel - - if self._async_proxy_client is None: - url = ( - _build_proxy_url(self._base_url, self.id, 8888) - .replace("ws://", "http://") - .replace("wss://", "https://") - ) - self._async_proxy_client = httpx.AsyncClient( - base_url=url, - headers=self._proxy_headers(), - ) - - deadline = time.monotonic() + jupyter_timeout - last_exc: Exception | None = None - while time.monotonic() < deadline: - try: - resp = await self._async_proxy_client.post("/api/kernels") - if resp.status_code < 500: - resp.raise_for_status() - data = resp.json() - self._kernel_id = data["id"] - return str(self._kernel_id) - last_exc = httpx.HTTPStatusError( - f"Jupyter returned {resp.status_code}", - request=resp.request, - response=resp, - ) - except httpx.HTTPStatusError: - raise - except Exception as exc: - last_exc = exc - await asyncio.sleep(0.5) - raise TimeoutError( - f"Jupyter not available within {jupyter_timeout}s: {last_exc}" - ) - - def _jupyter_ws_url(self, kernel_id: str) -> str: - proxy = _build_proxy_url(self._base_url, self.id, 8888) - return f"{proxy}/api/kernels/{kernel_id}/channels" - - def _jupyter_execute_request(self, code: str) -> dict: - msg_id = str(uuid.uuid4()) - return { - "header": { - "msg_id": msg_id, - "msg_type": "execute_request", - "username": "wrenn-sdk", - "session": str(uuid.uuid4()), - "date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), - "version": "5.3", - }, - "parent_header": {}, - "metadata": {}, - "content": { - "code": code, - "silent": False, - "store_history": True, - "user_expressions": {}, - "allow_stdin": False, - "stop_on_error": True, - }, - "buffers": [], - "channel": "shell", - "msg_id": msg_id, - "msg_type": "execute_request", - } - - def run_code( - self, - code: str, - language: str = "python", - timeout: float = 30, - jupyter_timeout: float = 30, - ) -> CodeResult: - """Execute code in a persistent kernel inside the capsule. - - Variables, imports, and function definitions survive across calls. - - Args: - code: Code string to execute. - language: Execution backend language. Currently only ``"python"``. - timeout: Maximum seconds to wait for execution to complete. - jupyter_timeout: Maximum seconds to wait for Jupyter to become available. - - Returns: - A ``CodeResult`` with ``.text``, ``.data``, ``.stdout``, ``.stderr``, ``.error``. - """ - assert self._http is not None - kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout) - ws_url = self._jupyter_ws_url(kernel_id) - - msg = self._jupyter_execute_request(code) - msg_id = msg["msg_id"] - - result = CodeResult() - deadline = time.monotonic() + timeout - - headers = self._proxy_headers() - - with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: ignore[attr-defined, var-annotated] - ws.send_text(json.dumps(msg)) - while time.monotonic() < deadline: - time_left = deadline - time.monotonic() - if time_left <= 0: - break - try: - data = ws.receive_json(timeout=time_left) - except (TimeoutError, Exception): - break - if not data: - break - parent = data.get("parent_header", {}).get("msg_id") - if parent != msg_id: - continue - msg_type = data.get("msg_type") or data.get("header", {}).get( - "msg_type" - ) - content = data.get("content", {}) - - if msg_type == "stream": - name = content.get("name", "stdout") - if name == "stderr": - result.stderr += content.get("text", "") - else: - result.stdout += content.get("text", "") - elif msg_type == "execute_result": - bundle = content.get("data", {}) - result.text = bundle.get("text/plain") - result.data = bundle - elif msg_type == "error": - traceback = content.get("traceback", []) - result.error = "\n".join(traceback) - elif msg_type == "status" and content.get("execution_state") == "idle": - break - - return result - - async def async_run_code( - self, - code: str, - language: str = "python", - timeout: float = 30, - jupyter_timeout: float = 30, - ) -> CodeResult: - """Async version of ``run_code``.""" - assert self._async_http is not None - kernel_id = await self._async_ensure_kernel(jupyter_timeout=jupyter_timeout) - ws_url = self._jupyter_ws_url(kernel_id) - - msg = self._jupyter_execute_request(code) - msg_id = msg["msg_id"] - - result = CodeResult() - deadline = time.monotonic() + timeout - - headers = self._proxy_headers() - - async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: ignore[attr-defined, var-annotated] - await ws.send_text(json.dumps(msg)) - while time.monotonic() < deadline: - time_left = deadline - time.monotonic() - if time_left <= 0: - break - - try: - data = await asyncio.wait_for(ws.receive_json(), timeout=time_left) # type: ignore[misc] - except (asyncio.TimeoutError, Exception): - break - - if not data: - break - - parent = data.get("parent_header", {}).get("msg_id") - if parent != msg_id: - continue - msg_type = data.get("msg_type") or data.get("header", {}).get( - "msg_type" - ) - content = data.get("content", {}) - - if msg_type == "stream": - name = content.get("name", "stdout") - if name == "stderr": - result.stderr += content.get("text", "") - else: - result.stdout += content.get("text", "") - elif msg_type == "execute_result": - bundle = content.get("data", {}) - result.text = bundle.get("text/plain") - result.data = bundle - elif msg_type == "error": - traceback = content.get("traceback", []) - result.error = "\n".join(traceback) - elif msg_type == "status" and content.get("execution_state") == "idle": - break - - return result - - def _cleanup(self) -> None: - if self._proxy_client is not None: - try: - self._proxy_client.close() - except Exception: - pass - self._proxy_client = None - - async def _async_cleanup(self) -> None: - if self._async_proxy_client is not None: - try: - await self._async_proxy_client.aclose() - except Exception: - pass - self._async_proxy_client = None + # ── Context manager ───────────────────────────────────────── def __enter__(self) -> Capsule: return self @@ -1139,33 +321,12 @@ class Capsule(CapsuleModel): exc_tb: object, ) -> None: try: - self.destroy() + self._instance_kill() except Exception: pass - self._cleanup() - - async def __aenter__(self) -> Capsule: - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: object, - ) -> None: try: - await self.async_destroy() + self._client.close() except Exception: pass - await self._async_cleanup() -def __getattr__(name: str) -> type: - if name == "Sandbox": - warnings.warn( - "'Sandbox' is deprecated, use 'Capsule' instead", - DeprecationWarning, - stacklevel=2, - ) - return Capsule - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/wrenn/client.py b/src/wrenn/client.py index 4c06b35..ea9e74c 100644 --- a/src/wrenn/client.py +++ b/src/wrenn/client.py @@ -1,132 +1,33 @@ from __future__ import annotations -import builtins -import warnings -from typing import cast +import os import httpx -from wrenn.capsule import Capsule +from wrenn._config import DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL from wrenn.exceptions import handle_response from wrenn.models import ( - APIKeyResponse, - AuthResponse, - CreateHostResponse, - Host, Template, ) from wrenn.models import ( Capsule as CapsuleModel, ) -DEFAULT_BASE_URL = "https://api.wrenn.dev" - -def _build_headers(api_key: str | None, token: str | None) -> dict[str, str]: - headers: dict[str, str] = {} - if api_key: - headers["X-API-Key"] = api_key - if token: - headers["Authorization"] = f"Bearer {token}" - return headers - - -class AuthResource: - """Sync auth operations.""" - - def __init__(self, http: httpx.Client) -> None: - self._http = http - - def signup(self, email: str, password: str) -> AuthResponse: - resp = self._http.post( - "/v1/auth/signup", json={"email": email, "password": password} +def _resolve_api_key(api_key: str | None) -> str: + resolved = api_key or os.environ.get(ENV_API_KEY) + if not resolved: + raise ValueError( + f"No API key provided. Pass api_key= or set the {ENV_API_KEY} environment variable." ) - return AuthResponse.model_validate(handle_response(resp)) - - def login(self, email: str, password: str) -> AuthResponse: - resp = self._http.post( - "/v1/auth/login", json={"email": email, "password": password} - ) - return AuthResponse.model_validate(handle_response(resp)) - - -class AsyncAuthResource: - """Async auth operations.""" - - def __init__(self, http: httpx.AsyncClient) -> None: - self._http = http - - async def signup(self, email: str, password: str) -> AuthResponse: - resp = await self._http.post( - "/v1/auth/signup", json={"email": email, "password": password} - ) - return AuthResponse.model_validate(handle_response(resp)) - - async def login(self, email: str, password: str) -> AuthResponse: - resp = await self._http.post( - "/v1/auth/login", json={"email": email, "password": password} - ) - return AuthResponse.model_validate(handle_response(resp)) - - -class APIKeysResource: - """Sync API key operations.""" - - def __init__(self, http: httpx.Client) -> None: - self._http = http - - def create(self, name: str | None = None) -> APIKeyResponse: - payload: dict = {} - if name is not None: - payload["name"] = name - resp = self._http.post("/v1/api-keys", json=payload) - return APIKeyResponse.model_validate(handle_response(resp)) - - def list(self) -> list[APIKeyResponse]: - resp = self._http.get("/v1/api-keys") - return [APIKeyResponse.model_validate(item) for item in handle_response(resp)] - - def delete(self, id: str) -> None: - resp = self._http.delete(f"/v1/api-keys/{id}") - handle_response(resp) - - -class AsyncAPIKeysResource: - """Async API key operations.""" - - def __init__(self, http: httpx.AsyncClient) -> None: - self._http = http - - async def create(self, name: str | None = None) -> APIKeyResponse: - payload: dict = {} - if name is not None: - payload["name"] = name - resp = await self._http.post("/v1/api-keys", json=payload) - return APIKeyResponse.model_validate(handle_response(resp)) - - async def list(self) -> list[APIKeyResponse]: - resp = await self._http.get("/v1/api-keys") - return [APIKeyResponse.model_validate(item) for item in handle_response(resp)] - - async def delete(self, id: str) -> None: - resp = await self._http.delete(f"/v1/api-keys/{id}") - handle_response(resp) + return resolved class CapsulesResource: """Sync capsule control-plane operations.""" - def __init__( - self, - http: httpx.Client, - base_url: str, - api_key: str | None = None, - token: str | None = None, - ) -> None: + def __init__(self, http: httpx.Client) -> None: self._http = http - self._base_url = base_url - self._api_key = api_key - self._token = token def create( self, @@ -134,7 +35,7 @@ class CapsulesResource: vcpus: int | None = None, memory_mb: int | None = None, timeout_sec: int | None = None, - ) -> Capsule: + ) -> CapsuleModel: payload: dict = {} if template is not None: payload["template"] = template @@ -145,10 +46,7 @@ class CapsulesResource: if timeout_sec is not None: payload["timeout_sec"] = timeout_sec resp = self._http.post("/v1/capsules", json=payload) - model = CapsuleModel.model_validate(handle_response(resp)) - cap = Capsule.model_validate(model.model_dump()) - cap._bind(self._http, self._base_url, self._api_key, self._token) - return cap + return CapsuleModel.model_validate(handle_response(resp)) def list(self) -> list[CapsuleModel]: resp = self._http.get("/v1/capsules") @@ -162,21 +60,24 @@ class CapsulesResource: resp = self._http.delete(f"/v1/capsules/{id}") handle_response(resp) + def pause(self, id: str) -> CapsuleModel: + resp = self._http.post(f"/v1/capsules/{id}/pause") + return CapsuleModel.model_validate(handle_response(resp)) + + def resume(self, id: str) -> CapsuleModel: + resp = self._http.post(f"/v1/capsules/{id}/resume") + return CapsuleModel.model_validate(handle_response(resp)) + + def ping(self, id: str) -> None: + resp = self._http.post(f"/v1/capsules/{id}/ping") + handle_response(resp) + class AsyncCapsulesResource: """Async capsule control-plane operations.""" - def __init__( - self, - http: httpx.AsyncClient, - base_url: str, - api_key: str | None = None, - token: str | None = None, - ) -> None: + def __init__(self, http: httpx.AsyncClient) -> None: self._http = http - self._base_url = base_url - self._api_key = api_key - self._token = token async def create( self, @@ -184,7 +85,7 @@ class AsyncCapsulesResource: vcpus: int | None = None, memory_mb: int | None = None, timeout_sec: int | None = None, - ) -> Capsule: + ) -> CapsuleModel: payload: dict = {} if template is not None: payload["template"] = template @@ -195,10 +96,7 @@ class AsyncCapsulesResource: if timeout_sec is not None: payload["timeout_sec"] = timeout_sec resp = await self._http.post("/v1/capsules", json=payload) - model = CapsuleModel.model_validate(handle_response(resp)) - cap = Capsule.model_validate(model.model_dump()) - cap._bind(self._http, self._base_url, self._api_key, self._token) - return cap + return CapsuleModel.model_validate(handle_response(resp)) async def list(self) -> list[CapsuleModel]: resp = await self._http.get("/v1/capsules") @@ -212,6 +110,18 @@ class AsyncCapsulesResource: resp = await self._http.delete(f"/v1/capsules/{id}") handle_response(resp) + async def pause(self, id: str) -> CapsuleModel: + resp = await self._http.post(f"/v1/capsules/{id}/pause") + return CapsuleModel.model_validate(handle_response(resp)) + + async def resume(self, id: str) -> CapsuleModel: + resp = await self._http.post(f"/v1/capsules/{id}/resume") + return CapsuleModel.model_validate(handle_response(resp)) + + async def ping(self, id: str) -> None: + resp = await self._http.post(f"/v1/capsules/{id}/ping") + handle_response(resp) + class SnapshotsResource: """Sync snapshot operations.""" @@ -279,150 +189,35 @@ class AsyncSnapshotsResource: handle_response(resp) -class HostsResource: - """Sync host operations.""" - - def __init__(self, http: httpx.Client) -> None: - self._http = http - - def create( - self, - type: str, - team_id: str | None = None, - provider: str | None = None, - availability_zone: str | None = None, - ) -> CreateHostResponse: - payload: dict = {"type": type} - if team_id is not None: - payload["team_id"] = team_id - if provider is not None: - payload["provider"] = provider - if availability_zone is not None: - payload["availability_zone"] = availability_zone - resp = self._http.post("/v1/hosts", json=payload) - return CreateHostResponse.model_validate(handle_response(resp)) - - def list(self) -> list[Host]: - resp = self._http.get("/v1/hosts") - return [Host.model_validate(item) for item in handle_response(resp)] - - def get(self, id: str) -> Host: - resp = self._http.get(f"/v1/hosts/{id}") - return Host.model_validate(handle_response(resp)) - - def delete(self, id: str) -> None: - resp = self._http.delete(f"/v1/hosts/{id}") - handle_response(resp) - - def regenerate_token(self, id: str) -> CreateHostResponse: - resp = self._http.post(f"/v1/hosts/{id}/token") - return CreateHostResponse.model_validate(handle_response(resp)) - - def list_tags(self, id: str) -> builtins.list[str]: - resp = self._http.get(f"/v1/hosts/{id}/tags") - return cast(builtins.list[str], handle_response(resp)) - - def add_tag(self, id: str, tag: str) -> None: - resp = self._http.post(f"/v1/hosts/{id}/tags", json={"tag": tag}) - handle_response(resp) - - def remove_tag(self, id: str, tag: str) -> None: - resp = self._http.delete(f"/v1/hosts/{id}/tags/{tag}") - handle_response(resp) - - -class AsyncHostsResource: - """Async host operations.""" - - def __init__(self, http: httpx.AsyncClient) -> None: - self._http = http - - async def create( - self, - type: str, - team_id: str | None = None, - provider: str | None = None, - availability_zone: str | None = None, - ) -> CreateHostResponse: - payload: dict = {"type": type} - if team_id is not None: - payload["team_id"] = team_id - if provider is not None: - payload["provider"] = provider - if availability_zone is not None: - payload["availability_zone"] = availability_zone - resp = await self._http.post("/v1/hosts", json=payload) - return CreateHostResponse.model_validate(handle_response(resp)) - - async def list(self) -> list[Host]: - resp = await self._http.get("/v1/hosts") - return [Host.model_validate(item) for item in handle_response(resp)] - - async def get(self, id: str) -> Host: - resp = await self._http.get(f"/v1/hosts/{id}") - return Host.model_validate(handle_response(resp)) - - async def delete(self, id: str) -> None: - resp = await self._http.delete(f"/v1/hosts/{id}") - handle_response(resp) - - async def regenerate_token(self, id: str) -> CreateHostResponse: - resp = await self._http.post(f"/v1/hosts/{id}/token") - return CreateHostResponse.model_validate(handle_response(resp)) - - async def list_tags(self, id: str) -> builtins.list[str]: - resp = await self._http.get(f"/v1/hosts/{id}/tags") - return cast(builtins.list[str], handle_response(resp)) - - async def add_tag(self, id: str, tag: str) -> None: - resp = await self._http.post(f"/v1/hosts/{id}/tags", json={"tag": tag}) - handle_response(resp) - - async def remove_tag(self, id: str, tag: str) -> None: - resp = await self._http.delete(f"/v1/hosts/{id}/tags/{tag}") - handle_response(resp) - - class WrennClient: """Synchronous client for the Wrenn API. - Authenticate with either an API key or a JWT token. + Authenticates with an API key. Args: - api_key: API key (``wrn_...``). Sent as ``X-API-Key`` header. - token: JWT token. Sent as ``Authorization: Bearer`` header. - base_url: Wrenn Control Plane URL. + api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var. + base_url: Wrenn API base URL. """ def __init__( self, api_key: str | None = None, - token: str | None = None, - base_url: str = DEFAULT_BASE_URL, + base_url: str | None = None, ) -> None: - if not api_key and not token: - raise ValueError("Either api_key or token must be provided") + self._api_key = _resolve_api_key(api_key) + self._base_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL) + self._http = httpx.Client( + base_url=self._base_url, + headers={"X-API-Key": self._api_key}, + ) - headers = _build_headers(api_key, token) - self._http = httpx.Client(base_url=base_url, headers=headers) - self._api_key = api_key - self._token = token - self._base_url = base_url - - self.auth = AuthResource(self._http) - self.api_keys = APIKeysResource(self._http) - self.capsules = CapsulesResource(self._http, base_url, api_key, token) + self.capsules = CapsulesResource(self._http) self.snapshots = SnapshotsResource(self._http) - self.hosts = HostsResource(self._http) @property - def sandboxes(self) -> CapsulesResource: - warnings.warn( - "'client.sandboxes' is deprecated, use 'client.capsules' instead", - DeprecationWarning, - stacklevel=2, - ) - return self.capsules + def http(self) -> httpx.Client: + """The underlying httpx.Client (for sub-objects that need direct access).""" + return self._http def close(self) -> None: """Close the underlying HTTP connection pool.""" @@ -443,43 +238,32 @@ class WrennClient: class AsyncWrennClient: """Asynchronous client for the Wrenn API. - Authenticate with either an API key or a JWT token. + Authenticates with an API key. Args: - api_key: API key (``wrn_...``). Sent as ``X-API-Key`` header. - token: JWT token. Sent as ``Authorization: Bearer`` header. - base_url: Wrenn Control Plane URL. + api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var. + base_url: Wrenn API base URL. Falls back to ``WRENN_BASE_URL`` env var. """ def __init__( self, api_key: str | None = None, - token: str | None = None, - base_url: str = DEFAULT_BASE_URL, + base_url: str | None = None, ) -> None: - if not api_key and not token: - raise ValueError("Either api_key or token must be provided") + self._api_key = _resolve_api_key(api_key) + self._base_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL) + self._http = httpx.AsyncClient( + base_url=self._base_url, + headers={"X-API-Key": self._api_key}, + ) - headers = _build_headers(api_key, token) - self._http = httpx.AsyncClient(base_url=base_url, headers=headers) - self._api_key = api_key - self._token = token - self._base_url = base_url - - self.auth = AsyncAuthResource(self._http) - self.api_keys = AsyncAPIKeysResource(self._http) - self.capsules = AsyncCapsulesResource(self._http, base_url, api_key, token) + self.capsules = AsyncCapsulesResource(self._http) self.snapshots = AsyncSnapshotsResource(self._http) - self.hosts = AsyncHostsResource(self._http) @property - def sandboxes(self) -> AsyncCapsulesResource: - warnings.warn( - "'client.sandboxes' is deprecated, use 'client.capsules' instead", - DeprecationWarning, - stacklevel=2, - ) - return self.capsules + def http(self) -> httpx.AsyncClient: + """The underlying httpx.AsyncClient.""" + return self._http async def aclose(self) -> None: """Close the underlying async HTTP connection pool.""" diff --git a/src/wrenn/code_interpreter/__init__.py b/src/wrenn/code_interpreter/__init__.py new file mode 100644 index 0000000..cb08537 --- /dev/null +++ b/src/wrenn/code_interpreter/__init__.py @@ -0,0 +1,8 @@ +from wrenn.code_interpreter.capsule import Capsule, CodeResult +from wrenn.code_interpreter.async_capsule import AsyncCapsule + +__all__ = [ + "AsyncCapsule", + "Capsule", + "CodeResult", +] diff --git a/src/wrenn/code_interpreter/async_capsule.py b/src/wrenn/code_interpreter/async_capsule.py new file mode 100644 index 0000000..715980f --- /dev/null +++ b/src/wrenn/code_interpreter/async_capsule.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import asyncio +import json +import time +import uuid + +import httpx +import httpx_ws + +from wrenn.async_capsule import AsyncCapsule as BaseAsyncCapsule +from wrenn.capsule import _build_proxy_url +from wrenn.client import AsyncWrennClient +from wrenn.code_interpreter.capsule import CodeResult, DEFAULT_TEMPLATE + + +class AsyncCapsule(BaseAsyncCapsule): + """Async code interpreter capsule with ``run_code`` support. + + Uses ``code-runner-beta`` template by default:: + + from wrenn.code_interpreter import AsyncCapsule + + capsule = await AsyncCapsule.create() + result = await capsule.run_code("print('hello')") + """ + + _kernel_id: str | None + _proxy_client: httpx.AsyncClient | None + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._kernel_id = None + self._proxy_client = None + + @classmethod + async def create( + cls, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + api_key: str | None = None, + base_url: str | None = None, + ) -> AsyncCapsule: + client = AsyncWrennClient(api_key=api_key, base_url=base_url) + info = await client.capsules.create( + template=template or DEFAULT_TEMPLATE, + vcpus=vcpus, + memory_mb=memory_mb, + timeout_sec=timeout, + ) + return cls( + _capsule_id=info.id, + _client=client, + _info=info, + ) + + def _get_proxy_client(self) -> httpx.AsyncClient: + if self._proxy_client is None: + url = ( + _build_proxy_url(self._client._base_url, self._id, 8888) + .replace("ws://", "http://") + .replace("wss://", "https://") + ) + self._proxy_client = httpx.AsyncClient( + base_url=url, + headers={"X-API-Key": self._client._api_key}, + ) + return self._proxy_client + + async def _ensure_kernel(self, jupyter_timeout: float = 30) -> str: + if self._kernel_id is not None: + return self._kernel_id + + client = self._get_proxy_client() + deadline = time.monotonic() + jupyter_timeout + last_exc: Exception | None = None + + while time.monotonic() < deadline: + try: + resp = await client.post("/api/kernels") + if resp.status_code < 500: + resp.raise_for_status() + self._kernel_id = resp.json()["id"] + return self._kernel_id + last_exc = httpx.HTTPStatusError( + f"Jupyter returned {resp.status_code}", + request=resp.request, + response=resp, + ) + except httpx.HTTPStatusError: + raise + except Exception as exc: + last_exc = exc + await asyncio.sleep(0.5) + + raise TimeoutError( + f"Jupyter not available within {jupyter_timeout}s: {last_exc}" + ) + + def _jupyter_ws_url(self, kernel_id: str) -> str: + proxy = _build_proxy_url(self._client._base_url, self._id, 8888) + return f"{proxy}/api/kernels/{kernel_id}/channels" + + @staticmethod + def _jupyter_execute_request(code: str) -> dict: + msg_id = str(uuid.uuid4()) + return { + "header": { + "msg_id": msg_id, + "msg_type": "execute_request", + "username": "wrenn-sdk", + "session": str(uuid.uuid4()), + "date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), + "version": "5.3", + }, + "parent_header": {}, + "metadata": {}, + "content": { + "code": code, + "silent": False, + "store_history": True, + "user_expressions": {}, + "allow_stdin": False, + "stop_on_error": True, + }, + "buffers": [], + "channel": "shell", + "msg_id": msg_id, + "msg_type": "execute_request", + } + + async def run_code( + self, + code: str, + language: str = "python", + timeout: float = 30, + jupyter_timeout: float = 30, + ) -> CodeResult: + """Execute code in a persistent Jupyter kernel (async).""" + kernel_id = await self._ensure_kernel(jupyter_timeout=jupyter_timeout) + ws_url = self._jupyter_ws_url(kernel_id) + + msg = self._jupyter_execute_request(code) + msg_id = msg["msg_id"] + + result = CodeResult() + deadline = time.monotonic() + timeout + headers = {"X-API-Key": self._client._api_key} + + async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: + await ws.send_text(json.dumps(msg)) + while time.monotonic() < deadline: + time_left = deadline - time.monotonic() + if time_left <= 0: + break + try: + data = await asyncio.wait_for( + ws.receive_json(), timeout=time_left + ) + except (asyncio.TimeoutError, Exception): + break + if not data: + break + parent = data.get("parent_header", {}).get("msg_id") + if parent != msg_id: + continue + msg_type = data.get("msg_type") or data.get("header", {}).get( + "msg_type" + ) + content = data.get("content", {}) + + if msg_type == "stream": + name = content.get("name", "stdout") + if name == "stderr": + result.stderr += content.get("text", "") + else: + result.stdout += content.get("text", "") + elif msg_type == "execute_result": + bundle = content.get("data", {}) + result.text = bundle.get("text/plain") + result.data = bundle + elif msg_type == "error": + traceback = content.get("traceback", []) + result.error = "\n".join(traceback) + elif msg_type == "status" and content.get("execution_state") == "idle": + break + + return result + + async def __aexit__(self, *args) -> None: + if self._proxy_client is not None: + try: + await self._proxy_client.aclose() + except Exception: + pass + await super().__aexit__(*args) diff --git a/src/wrenn/code_interpreter/capsule.py b/src/wrenn/code_interpreter/capsule.py new file mode 100644 index 0000000..d92f1c3 --- /dev/null +++ b/src/wrenn/code_interpreter/capsule.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import json +import time +import uuid +from dataclasses import dataclass + +import httpx +import httpx_ws + +from wrenn.capsule import Capsule as BaseCapsule +from wrenn.capsule import _build_proxy_url + + +DEFAULT_TEMPLATE = "code-runner-beta" + + +@dataclass +class CodeResult: + """Result from stateful code execution. + + Attributes: + text: text/plain representation of the result. + data: rich MIME bundle (e.g. ``{"image/png": "..."}``). + stdout: accumulated stdout output. + stderr: accumulated stderr output. + error: language-specific error/traceback string. + """ + + text: str | None = None + data: dict[str, str] | None = None + stdout: str = "" + stderr: str = "" + error: str | None = None + + +class Capsule(BaseCapsule): + """Code interpreter capsule with ``run_code`` support. + + Uses ``code-runner-beta`` template by default:: + + from wrenn.code_interpreter import Capsule + + capsule = Capsule() + result = capsule.run_code("print('hello')") + print(result.stdout) # "hello\\n" + """ + + _kernel_id: str | None + _proxy_client: httpx.Client | None + + def __init__( + self, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + api_key: str | None = None, + base_url: str | None = None, + **kwargs, + ) -> None: + super().__init__( + template=template or DEFAULT_TEMPLATE, + vcpus=vcpus, + memory_mb=memory_mb, + timeout=timeout, + api_key=api_key, + base_url=base_url, + **kwargs, + ) + self._kernel_id = None + self._proxy_client = None + + @classmethod + def create( + cls, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + api_key: str | None = None, + base_url: str | None = None, + ) -> Capsule: + return cls( + template=template or DEFAULT_TEMPLATE, + vcpus=vcpus, + memory_mb=memory_mb, + timeout=timeout, + api_key=api_key, + base_url=base_url, + ) + + def _get_proxy_client(self) -> httpx.Client: + if self._proxy_client is None: + url = ( + _build_proxy_url(self._client._base_url, self._id, 8888) + .replace("ws://", "http://") + .replace("wss://", "https://") + ) + self._proxy_client = httpx.Client( + base_url=url, + headers={"X-API-Key": self._client._api_key}, + ) + return self._proxy_client + + def _ensure_kernel(self, jupyter_timeout: float = 30) -> str: + if self._kernel_id is not None: + return self._kernel_id + + client = self._get_proxy_client() + deadline = time.monotonic() + jupyter_timeout + last_exc: Exception | None = None + + while time.monotonic() < deadline: + try: + resp = client.post("/api/kernels") + if resp.status_code < 500: + resp.raise_for_status() + self._kernel_id = resp.json()["id"] + return self._kernel_id + last_exc = httpx.HTTPStatusError( + f"Jupyter returned {resp.status_code}", + request=resp.request, + response=resp, + ) + except httpx.HTTPStatusError: + raise + except Exception as exc: + last_exc = exc + time.sleep(0.5) + + raise TimeoutError( + f"Jupyter not available within {jupyter_timeout}s: {last_exc}" + ) + + def _jupyter_ws_url(self, kernel_id: str) -> str: + proxy = _build_proxy_url(self._client._base_url, self._id, 8888) + return f"{proxy}/api/kernels/{kernel_id}/channels" + + @staticmethod + def _jupyter_execute_request(code: str) -> dict: + msg_id = str(uuid.uuid4()) + return { + "header": { + "msg_id": msg_id, + "msg_type": "execute_request", + "username": "wrenn-sdk", + "session": str(uuid.uuid4()), + "date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), + "version": "5.3", + }, + "parent_header": {}, + "metadata": {}, + "content": { + "code": code, + "silent": False, + "store_history": True, + "user_expressions": {}, + "allow_stdin": False, + "stop_on_error": True, + }, + "buffers": [], + "channel": "shell", + "msg_id": msg_id, + "msg_type": "execute_request", + } + + def run_code( + self, + code: str, + language: str = "python", + timeout: float = 30, + jupyter_timeout: float = 30, + ) -> CodeResult: + """Execute code in a persistent Jupyter kernel. + + Variables, imports, and function definitions survive across calls. + + Args: + code: Code string to execute. + language: Execution backend language. Currently only ``"python"``. + timeout: Maximum seconds to wait for execution to complete. + jupyter_timeout: Maximum seconds to wait for Jupyter to become available. + + Returns: + A ``CodeResult`` with ``.text``, ``.data``, ``.stdout``, ``.stderr``, ``.error``. + """ + kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout) + ws_url = self._jupyter_ws_url(kernel_id) + + msg = self._jupyter_execute_request(code) + msg_id = msg["msg_id"] + + result = CodeResult() + deadline = time.monotonic() + timeout + headers = {"X-API-Key": self._client._api_key} + + with httpx_ws.connect_ws(ws_url, headers=headers) as ws: + ws.send_text(json.dumps(msg)) + while time.monotonic() < deadline: + time_left = deadline - time.monotonic() + if time_left <= 0: + break + try: + data = ws.receive_json(timeout=time_left) + except (TimeoutError, Exception): + break + if not data: + break + parent = data.get("parent_header", {}).get("msg_id") + if parent != msg_id: + continue + msg_type = data.get("msg_type") or data.get("header", {}).get( + "msg_type" + ) + content = data.get("content", {}) + + if msg_type == "stream": + name = content.get("name", "stdout") + if name == "stderr": + result.stderr += content.get("text", "") + else: + result.stdout += content.get("text", "") + elif msg_type == "execute_result": + bundle = content.get("data", {}) + result.text = bundle.get("text/plain") + result.data = bundle + elif msg_type == "error": + traceback = content.get("traceback", []) + result.error = "\n".join(traceback) + elif msg_type == "status" and content.get("execution_state") == "idle": + break + + return result + + def __exit__(self, *args) -> None: + if self._proxy_client is not None: + try: + self._proxy_client.close() + except Exception: + pass + super().__exit__(*args) diff --git a/src/wrenn/commands.py b/src/wrenn/commands.py new file mode 100644 index 0000000..13d97a2 --- /dev/null +++ b/src/wrenn/commands.py @@ -0,0 +1,366 @@ +from __future__ import annotations + +import base64 +import json +from collections.abc import AsyncIterator, Iterator +from dataclasses import dataclass +from typing import overload, Literal + +import httpx +import httpx_ws + +from wrenn.exceptions import handle_response + + +@dataclass +class CommandResult: + """Result from a foreground command execution.""" + + stdout: str + stderr: str + exit_code: int + duration_ms: int | None = None + + +@dataclass +class CommandHandle: + """Handle for a background process.""" + + pid: int + tag: str + capsule_id: str + + +@dataclass +class ProcessInfo: + """Information about a running process.""" + + pid: int + tag: str | None = None + cmd: str | None = None + args: list[str] | None = None + + +class StreamEvent: + """Base class for streaming exec events.""" + + __slots__ = ("type",) + + def __init__(self, type: str) -> None: + self.type = type + + +class StreamStartEvent(StreamEvent): + __slots__ = ("pid",) + + def __init__(self, pid: int) -> None: + super().__init__("start") + self.pid = pid + + +class StreamStdoutEvent(StreamEvent): + __slots__ = ("data",) + + def __init__(self, data: str) -> None: + super().__init__("stdout") + self.data = data + + +class StreamStderrEvent(StreamEvent): + __slots__ = ("data",) + + def __init__(self, data: str) -> None: + super().__init__("stderr") + self.data = data + + +class StreamExitEvent(StreamEvent): + __slots__ = ("exit_code",) + + def __init__(self, exit_code: int) -> None: + super().__init__("exit") + self.exit_code = exit_code + + +class StreamErrorEvent(StreamEvent): + __slots__ = ("data",) + + def __init__(self, data: str) -> None: + super().__init__("error") + self.data = data + + +def _parse_stream_event(raw: dict) -> StreamEvent: + t = raw.get("type") + if t == "start": + return StreamStartEvent(pid=raw.get("pid", 0)) + if t == "stdout": + return StreamStdoutEvent(data=raw.get("data", "")) + if t == "stderr": + return StreamStderrEvent(data=raw.get("data", "")) + if t == "exit": + return StreamExitEvent(exit_code=raw.get("exit_code", -1)) + if t == "error": + return StreamErrorEvent(data=raw.get("data", "")) + return StreamEvent(type=t or "unknown") + + +def _decode_exec_response(data: dict) -> CommandResult: + stdout = data.get("stdout") or "" + stderr = data.get("stderr") or "" + if data.get("encoding") == "base64": + stdout = base64.b64decode(stdout).decode("utf-8", errors="replace") + if stderr: + stderr = base64.b64decode(stderr).decode("utf-8", errors="replace") + return CommandResult( + stdout=stdout, + stderr=stderr, + exit_code=data.get("exit_code", -1), + duration_ms=data.get("duration_ms"), + ) + + +class Commands: + """Sync command execution interface. Accessed via ``capsule.commands``.""" + + def __init__(self, capsule_id: str, http: httpx.Client) -> None: + self._capsule_id = capsule_id + self._http = http + + @overload + def run( + self, + cmd: str, + *, + background: Literal[False] = ..., + timeout: int | None = 30, + envs: dict[str, str] | None = None, + cwd: str | None = None, + tag: str | None = None, + ) -> CommandResult: ... + + @overload + def run( + self, + cmd: str, + *, + background: Literal[True], + timeout: int | None = 30, + envs: dict[str, str] | None = None, + cwd: str | None = None, + tag: str | None = None, + ) -> CommandHandle: ... + + def run( + self, + cmd: str, + *, + background: bool = False, + timeout: int | None = 30, + envs: dict[str, str] | None = None, + cwd: str | None = None, + tag: str | None = None, + ) -> CommandResult | CommandHandle: + payload: dict = {"cmd": cmd, "background": background} + if timeout is not None and not background: + payload["timeout_sec"] = timeout + if envs is not None: + payload["envs"] = envs + if cwd is not None: + payload["cwd"] = cwd + if tag is not None: + payload["tag"] = tag + + resp = self._http.post( + f"/v1/capsules/{self._capsule_id}/exec", json=payload + ) + data = handle_response(resp) + + if background: + return CommandHandle( + pid=data.get("pid", 0), + tag=data.get("tag", ""), + capsule_id=self._capsule_id, + ) + return _decode_exec_response(data) + + def list(self) -> list[ProcessInfo]: + resp = self._http.get(f"/v1/capsules/{self._capsule_id}/processes") + data = handle_response(resp) + return [ + ProcessInfo( + pid=p.get("pid", 0), + tag=p.get("tag"), + cmd=p.get("cmd"), + args=p.get("args"), + ) + for p in data.get("processes", []) + ] + + def kill(self, pid: int) -> None: + resp = self._http.delete( + f"/v1/capsules/{self._capsule_id}/processes/{pid}" + ) + handle_response(resp) + + def connect(self, pid: int) -> Iterator[StreamEvent]: + """Connect to a running background process and stream its output.""" + with httpx_ws.connect_ws( + f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream", + self._http, + ) as ws: + while True: + try: + raw = ws.receive_json() + event = _parse_stream_event(raw) + yield event + if event.type in ("exit", "error"): + break + except httpx_ws.WebSocketDisconnect: + break + + def stream( + self, cmd: str, args: list[str] | None = None + ) -> Iterator[StreamEvent]: + """Execute a command via WebSocket, yielding ``StreamEvent`` objects.""" + with httpx_ws.connect_ws( + f"/v1/capsules/{self._capsule_id}/exec/stream", + self._http, + ) as ws: + start_msg: dict = {"type": "start", "cmd": cmd} + if args: + start_msg["args"] = args + ws.send_text(json.dumps(start_msg)) + while True: + try: + raw = ws.receive_json() + event = _parse_stream_event(raw) + yield event + if event.type in ("exit", "error"): + break + except httpx_ws.WebSocketDisconnect: + break + + +class AsyncCommands: + """Async command execution interface. Accessed via ``capsule.commands``.""" + + def __init__(self, capsule_id: str, http: httpx.AsyncClient) -> None: + self._capsule_id = capsule_id + self._http = http + + @overload + async def run( + self, + cmd: str, + *, + background: Literal[False] = ..., + timeout: int | None = 30, + envs: dict[str, str] | None = None, + cwd: str | None = None, + tag: str | None = None, + ) -> CommandResult: ... + + @overload + async def run( + self, + cmd: str, + *, + background: Literal[True], + timeout: int | None = 30, + envs: dict[str, str] | None = None, + cwd: str | None = None, + tag: str | None = None, + ) -> CommandHandle: ... + + async def run( + self, + cmd: str, + *, + background: bool = False, + timeout: int | None = 30, + envs: dict[str, str] | None = None, + cwd: str | None = None, + tag: str | None = None, + ) -> CommandResult | CommandHandle: + payload: dict = {"cmd": cmd, "background": background} + if timeout is not None and not background: + payload["timeout_sec"] = timeout + if envs is not None: + payload["envs"] = envs + if cwd is not None: + payload["cwd"] = cwd + if tag is not None: + payload["tag"] = tag + + resp = await self._http.post( + f"/v1/capsules/{self._capsule_id}/exec", json=payload + ) + data = handle_response(resp) + + if background: + return CommandHandle( + pid=data.get("pid", 0), + tag=data.get("tag", ""), + capsule_id=self._capsule_id, + ) + return _decode_exec_response(data) + + async def list(self) -> list[ProcessInfo]: + resp = await self._http.get( + f"/v1/capsules/{self._capsule_id}/processes" + ) + data = handle_response(resp) + return [ + ProcessInfo( + pid=p.get("pid", 0), + tag=p.get("tag"), + cmd=p.get("cmd"), + args=p.get("args"), + ) + for p in data.get("processes", []) + ] + + async def kill(self, pid: int) -> None: + resp = await self._http.delete( + f"/v1/capsules/{self._capsule_id}/processes/{pid}" + ) + handle_response(resp) + + async def connect(self, pid: int) -> AsyncIterator[StreamEvent]: + """Connect to a running background process and stream its output.""" + async with httpx_ws.aconnect_ws( + f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream", + self._http, + ) as ws: + try: + while True: + raw = await ws.receive_json() + event = _parse_stream_event(raw) + yield event + if event.type in ("exit", "error"): + break + except httpx_ws.WebSocketDisconnect: + pass + + async def stream( + self, cmd: str, args: list[str] | None = None + ) -> AsyncIterator[StreamEvent]: + """Execute a command via WebSocket, yielding ``StreamEvent`` objects.""" + async with httpx_ws.aconnect_ws( + f"/v1/capsules/{self._capsule_id}/exec/stream", + self._http, + ) as ws: + start_msg: dict = {"type": "start", "cmd": cmd} + if args: + start_msg["args"] = args + await ws.send_text(json.dumps(start_msg)) + try: + while True: + raw = await ws.receive_json() + event = _parse_stream_event(raw) + yield event + if event.type in ("exit", "error"): + break + except httpx_ws.WebSocketDisconnect: + pass diff --git a/src/wrenn/files.py b/src/wrenn/files.py new file mode 100644 index 0000000..837aa2f --- /dev/null +++ b/src/wrenn/files.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import os +from collections.abc import AsyncIterator, Iterator + +import httpx + +from wrenn.exceptions import WrennNotFoundError, handle_response +from wrenn.models import FileEntry, ListDirResponse, MakeDirResponse + + +class Files: + """Sync filesystem interface. Accessed via ``capsule.files``.""" + + def __init__(self, capsule_id: str, http: httpx.Client) -> None: + self._capsule_id = capsule_id + self._http = http + + def read(self, path: str) -> str: + """Read a file as a UTF-8 string.""" + return self.read_bytes(path).decode("utf-8", errors="replace") + + def read_bytes(self, path: str) -> bytes: + """Read a file as raw bytes.""" + resp = self._http.post( + f"/v1/capsules/{self._capsule_id}/files/read", + json={"path": path}, + ) + resp.raise_for_status() + return resp.content + + def write(self, path: str, data: str | bytes) -> None: + """Write data to a file inside the capsule.""" + if isinstance(data, str): + data = data.encode("utf-8") + resp = self._http.post( + f"/v1/capsules/{self._capsule_id}/files/write", + files={"file": ("upload", data)}, + data={"path": path}, + ) + resp.raise_for_status() + + def list(self, path: str, depth: int = 1) -> list[FileEntry]: + """List directory contents.""" + resp = self._http.post( + f"/v1/capsules/{self._capsule_id}/files/list", + json={"path": path, "depth": depth}, + ) + parsed = ListDirResponse.model_validate(handle_response(resp)) + return parsed.entries or [] + + def exists(self, path: str) -> bool: + """Check whether a path exists inside the capsule.""" + parent = os.path.dirname(path) + name = os.path.basename(path) + try: + entries = self.list(parent, depth=1) + except WrennNotFoundError: + return False + return any(e.name == name for e in entries) + + def make_dir(self, path: str) -> FileEntry: + """Create a directory (with parents). Idempotent.""" + resp = self._http.post( + f"/v1/capsules/{self._capsule_id}/files/mkdir", + json={"path": path}, + ) + if resp.status_code == 409: + try: + body = resp.json() + if body.get("error", {}).get("code") == "conflict": + parent = os.path.dirname(path) + name = os.path.basename(path) + for entry in self.list(parent, depth=1): + if entry.name == name: + return entry + except Exception: + pass + parsed = MakeDirResponse.model_validate(handle_response(resp)) + if parsed.entry is None: + raise RuntimeError("mkdir response missing entry") + return parsed.entry + + def remove(self, path: str) -> None: + """Remove a file or directory recursively.""" + resp = self._http.post( + f"/v1/capsules/{self._capsule_id}/files/remove", + json={"path": path}, + ) + handle_response(resp) + + def upload_stream(self, path: str, stream: Iterator[bytes]) -> None: + """Streaming upload for large files.""" + boundary = os.urandom(16).hex().encode("utf-8") + + def _multipart() -> Iterator[bytes]: + yield b"--" + boundary + b"\r\n" + yield b'Content-Disposition: form-data; name="path"\r\n\r\n' + yield path.encode("utf-8") + b"\r\n" + yield b"--" + boundary + b"\r\n" + yield b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n' + yield b"Content-Type: application/octet-stream\r\n\r\n" + for chunk in stream: + yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") + yield b"\r\n--" + boundary + b"--\r\n" + + resp = self._http.post( + f"/v1/capsules/{self._capsule_id}/files/stream/write", + content=_multipart(), + headers={ + "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}" + }, + ) + resp.raise_for_status() + + def download_stream(self, path: str) -> Iterator[bytes]: + """Streaming download for large files.""" + with self._http.stream( + "POST", + f"/v1/capsules/{self._capsule_id}/files/stream/read", + json={"path": path}, + ) as resp: + resp.raise_for_status() + yield from resp.iter_bytes() + + +class AsyncFiles: + """Async filesystem interface. Accessed via ``capsule.files``.""" + + def __init__(self, capsule_id: str, http: httpx.AsyncClient) -> None: + self._capsule_id = capsule_id + self._http = http + + async def read(self, path: str) -> str: + """Read a file as a UTF-8 string.""" + data = await self.read_bytes(path) + return data.decode("utf-8", errors="replace") + + async def read_bytes(self, path: str) -> bytes: + """Read a file as raw bytes.""" + resp = await self._http.post( + f"/v1/capsules/{self._capsule_id}/files/read", + json={"path": path}, + ) + resp.raise_for_status() + return resp.content + + async def write(self, path: str, data: str | bytes) -> None: + """Write data to a file inside the capsule.""" + if isinstance(data, str): + data = data.encode("utf-8") + resp = await self._http.post( + f"/v1/capsules/{self._capsule_id}/files/write", + files={"file": ("upload", data)}, + data={"path": path}, + ) + resp.raise_for_status() + + async def list(self, path: str, depth: int = 1) -> list[FileEntry]: + """List directory contents.""" + resp = await self._http.post( + f"/v1/capsules/{self._capsule_id}/files/list", + json={"path": path, "depth": depth}, + ) + parsed = ListDirResponse.model_validate(handle_response(resp)) + return parsed.entries or [] + + async def exists(self, path: str) -> bool: + """Check whether a path exists inside the capsule.""" + parent = os.path.dirname(path) + name = os.path.basename(path) + try: + entries = await self.list(parent, depth=1) + except WrennNotFoundError: + return False + return any(e.name == name for e in entries) + + async def make_dir(self, path: str) -> FileEntry: + """Create a directory (with parents). Idempotent.""" + resp = await self._http.post( + f"/v1/capsules/{self._capsule_id}/files/mkdir", + json={"path": path}, + ) + if resp.status_code == 409: + try: + body = resp.json() + if body.get("error", {}).get("code") == "conflict": + parent = os.path.dirname(path) + name = os.path.basename(path) + for entry in await self.list(parent, depth=1): + if entry.name == name: + return entry + except Exception: + pass + parsed = MakeDirResponse.model_validate(handle_response(resp)) + if parsed.entry is None: + raise RuntimeError("mkdir response missing entry") + return parsed.entry + + async def remove(self, path: str) -> None: + """Remove a file or directory recursively.""" + resp = await self._http.post( + f"/v1/capsules/{self._capsule_id}/files/remove", + json={"path": path}, + ) + handle_response(resp) + + async def upload_stream(self, path: str, stream: AsyncIterator[bytes]) -> None: + """Streaming upload for large files.""" + boundary = os.urandom(16).hex().encode("utf-8") + + async def _multipart() -> AsyncIterator[bytes]: + yield b"--" + boundary + b"\r\n" + yield b'Content-Disposition: form-data; name="path"\r\n\r\n' + yield path.encode("utf-8") + b"\r\n" + yield b"--" + boundary + b"\r\n" + yield b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n' + yield b"Content-Type: application/octet-stream\r\n\r\n" + async for chunk in stream: + yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") + yield b"\r\n--" + boundary + b"--\r\n" + + resp = await self._http.post( + f"/v1/capsules/{self._capsule_id}/files/stream/write", + content=_multipart(), + headers={ + "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}" + }, + ) + resp.raise_for_status() + + async def download_stream(self, path: str) -> AsyncIterator[bytes]: + """Streaming download for large files.""" + async with self._http.stream( + "POST", + f"/v1/capsules/{self._capsule_id}/files/stream/read", + json={"path": path}, + ) as resp: + resp.raise_for_status() + async for chunk in resp.aiter_bytes(): + yield chunk diff --git a/src/wrenn/sandbox.py b/src/wrenn/sandbox.py index 09126f8..1b2499c 100644 --- a/src/wrenn/sandbox.py +++ b/src/wrenn/sandbox.py @@ -1,25 +1,21 @@ import warnings as _warnings -from wrenn.capsule import ( # noqa: F401 - CodeResult, - ExecResult, +from wrenn.capsule import Capsule # noqa: F401 +from wrenn.commands import ( # noqa: F401 StreamErrorEvent, StreamEvent, StreamExitEvent, StreamStartEvent, StreamStderrEvent, StreamStdoutEvent, - _build_proxy_url, - _parse_stream_event, ) -from wrenn.capsule import Capsule def __getattr__(name: str) -> type: if name == "Sandbox": _warnings.warn( "'Sandbox' is deprecated, use 'Capsule' instead", - DeprecationWarning, + FutureWarning, stacklevel=2, ) return Capsule diff --git a/tests/test_capsule_features.py b/tests/test_capsule_features.py index 594a378..136b824 100644 --- a/tests/test_capsule_features.py +++ b/tests/test_capsule_features.py @@ -3,20 +3,16 @@ from __future__ import annotations import pytest import respx -from wrenn.capsule import Capsule, CodeResult, _build_proxy_url -from wrenn.client import WrennClient +from wrenn.capsule import Capsule, _build_proxy_url +from wrenn.code_interpreter.capsule import CodeResult - -@pytest.fixture -def client(): - with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: - yield c +BASE = "https://app.wrenn.dev/api" class TestBuildProxyUrl: def test_https_production(self): - url = _build_proxy_url("https://api.wrenn.dev", "cl-abc123", 8888) - assert url == "wss://8888-cl-abc123.api.wrenn.dev" + url = _build_proxy_url("https://app.wrenn.dev/api", "cl-abc123", 8888) + assert url == "wss://8888-cl-abc123.app.wrenn.dev" def test_http_localhost(self): url = _build_proxy_url("http://localhost:8080", "cl-abc123", 3000) @@ -31,92 +27,98 @@ class TestBuildProxyUrl: assert url == "ws://5000-sb-2.192.168.1.1" -class TestCapsuleGetUrl: +class TestCapsuleCreate: @respx.mock - def test_get_url_returns_proxy_url(self, client): - respx.post("https://api.wrenn.dev/v1/capsules").respond( - 201, json={"id": "cl-abc", "status": "pending"} - ) - cap = client.capsules.create(template="minimal") - url = cap.get_url(8888) - assert url == "wss://8888-cl-abc.api.wrenn.dev" - - @respx.mock - def test_get_url_localhost(self): - with WrennClient( - api_key="wrn_test1234567890abcdef12345678", - base_url="http://localhost:8080", - ) as c: - respx.post("http://localhost:8080/v1/capsules").respond( - 201, json={"id": "cl-xyz", "status": "pending"} - ) - cap = c.capsules.create() - url = cap.get_url(3000) - assert url == "ws://3000-cl-xyz.localhost:8080" - - -class TestCapsuleHttpClient: - @respx.mock - def test_http_client_has_api_key_header(self, client): - respx.post("https://api.wrenn.dev/v1/capsules").respond( - 201, json={"id": "cl-abc", "status": "pending"} - ) - cap = client.capsules.create() - hc = cap.http_client - assert hc.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678" - - @respx.mock - def test_http_client_sends_to_proxy(self, client): - route = respx.get("https://8888-cl-abc.api.wrenn.dev/api/kernels").respond( - 200, json=[] - ) - respx.post("https://api.wrenn.dev/v1/capsules").respond( - 201, json={"id": "cl-abc", "status": "pending"} - ) - cap = client.capsules.create() - resp = cap.http_client.get("/api/kernels") - assert resp.status_code == 200 - assert route.called - - def test_jwt_only_get_url_works(self): - with WrennClient(token="jwt-abc") as c: - cap = Capsule(id="cl-abc") - cap._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") - url = cap.get_url(8888) - assert "8888-cl-abc" in url - - def test_jwt_only_http_client_has_bearer_header(self): - with WrennClient(token="jwt-abc") as c: - cap = Capsule(id="cl-abc") - cap._bind(c._http, str(c._http.base_url), api_key=None, token="jwt-abc") - hc = cap.http_client - assert hc.headers["Authorization"] == "Bearer jwt-abc" - - -class TestCreateReturnsBoundCapsule: - @respx.mock - def test_create_returns_capsule_subclass(self, client): - respx.post("https://api.wrenn.dev/v1/capsules").respond( + def test_capsule_constructor_creates(self): + respx.post(f"{BASE}/v1/capsules").respond( 201, json={"id": "cl-1", "status": "pending", "template": "minimal"} ) - cap = client.capsules.create(template="minimal") - assert isinstance(cap, Capsule) - assert cap.id == "cl-1" - assert hasattr(cap, "exec") - assert hasattr(cap, "run_code") - assert hasattr(cap, "get_url") + cap = Capsule(template="minimal", api_key="wrn_test1234567890abcdef12345678") + assert cap.capsule_id == "cl-1" + assert hasattr(cap, "commands") + assert hasattr(cap, "files") @respx.mock - def test_create_context_manager(self, client): - route = respx.delete("https://api.wrenn.dev/v1/capsules/cl-1").respond(204) - respx.post("https://api.wrenn.dev/v1/capsules").respond( + def test_capsule_create_classmethod(self): + respx.post(f"{BASE}/v1/capsules").respond( + 201, json={"id": "cl-2", "status": "pending"} + ) + cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678") + assert cap.capsule_id == "cl-2" + + @respx.mock + def test_capsule_context_manager_kills(self): + respx.post(f"{BASE}/v1/capsules").respond( 201, json={"id": "cl-1", "status": "pending"} ) - cap = client.capsules.create() - with cap: - assert cap.id == "cl-1" + kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204) + with Capsule(api_key="wrn_test1234567890abcdef12345678") as cap: + assert cap.capsule_id == "cl-1" + assert kill_route.called + + @respx.mock + def test_capsule_env_var(self, monkeypatch): + monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env_key") + respx.post(f"{BASE}/v1/capsules").respond( + 201, json={"id": "cl-3", "status": "pending"} + ) + cap = Capsule() + assert cap.capsule_id == "cl-3" + + +class TestCapsuleStaticMethods: + @respx.mock + def test_static_kill(self): + route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204) + Capsule._static_kill("cl-1", api_key="wrn_test1234567890abcdef12345678") assert route.called + @respx.mock + def test_static_pause(self): + 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") + assert info.status.value == "paused" + + @respx.mock + def test_static_list(self): + respx.get(f"{BASE}/v1/capsules").respond( + 200, json=[{"id": "cl-1", "status": "running"}] + ) + items = Capsule.list(api_key="wrn_test1234567890abcdef12345678") + assert len(items) == 1 + assert items[0].id == "cl-1" + + @respx.mock + def test_static_get_info(self): + respx.get(f"{BASE}/v1/capsules/cl-1").respond( + 200, json={"id": "cl-1", "status": "running"} + ) + info = Capsule._static_get_info("cl-1", api_key="wrn_test1234567890abcdef12345678") + assert info.id == "cl-1" + + +class TestCapsuleConnect: + @respx.mock + def test_connect_running(self): + 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") + assert cap.capsule_id == "cl-1" + + @respx.mock + def test_connect_paused_resumes(self): + respx.get(f"{BASE}/v1/capsules/cl-1").respond( + 200, json={"id": "cl-1", "status": "paused"} + ) + 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") + assert cap.capsule_id == "cl-1" + class TestCodeResult: def test_defaults(self): @@ -144,57 +146,21 @@ class TestCodeResult: assert "ZeroDivisionError" in r.error -class TestJupyterMessageFormat: - def test_execute_request_structure(self): - cap = Capsule(id="test") - msg = cap._jupyter_execute_request("x = 42") - assert msg["msg_type"] == "execute_request" - assert msg["content"]["code"] == "x = 42" - assert msg["content"]["silent"] is False - assert "msg_id" in msg - assert "header" in msg - assert msg["header"]["msg_type"] == "execute_request" - - def test_execute_request_unique_ids(self): - cap = Capsule(id="test") - m1 = cap._jupyter_execute_request("a") - m2 = cap._jupyter_execute_request("b") - assert m1["msg_id"] != m2["msg_id"] - - class TestDeprecationWarnings: - def test_import_sandbox_from_capsule_warns(self): - import importlib - import warnings - - import wrenn.capsule as capsule_mod - - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - klass = capsule_mod.Sandbox - assert klass is Capsule - assert len(w) == 1 - assert issubclass(w[0].category, DeprecationWarning) - assert "Sandbox" in str(w[0].message) - def test_import_sandbox_from_wrenn_warns(self): + import importlib + import sys import warnings + # Clear cached attribute + if "Sandbox" in dir(sys.modules.get("wrenn", object())): + delattr(sys.modules["wrenn"], "Sandbox") + with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") from wrenn import Sandbox assert Sandbox is Capsule - assert any(issubclass(x.category, DeprecationWarning) for x in w) - - def test_client_sandboxes_property_warns(self): - import warnings - - with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - resource = c.sandboxes - assert resource is c.capsules - assert len(w) == 1 - assert issubclass(w[0].category, DeprecationWarning) - assert "sandboxes" in str(w[0].message) + fw = [x for x in w if issubclass(x.category, FutureWarning)] + assert len(fw) >= 1 + assert "Sandbox" in str(fw[0].message) diff --git a/tests/test_client.py b/tests/test_client.py index 17c3586..00ba03b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,22 +8,18 @@ from wrenn.exceptions import ( WrennAgentError, WrennAuthenticationError, WrennConflictError, - WrennForbiddenError, - WrennHostHasCapsulesError, WrennInternalError, WrennNotFoundError, WrennValidationError, ) from wrenn.models import ( - APIKeyResponse, - AuthResponse, Capsule, - CreateHostResponse, - Host, Status, Template, ) +BASE = "https://app.wrenn.dev/api" + @pytest.fixture def client(): @@ -36,71 +32,10 @@ def async_client(): return AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678") -class TestAuth: - @respx.mock - def test_signup(self, client): - respx.post("https://api.wrenn.dev/v1/auth/signup").respond( - 201, - json={ - "token": "jwt-token", - "user_id": "u-1", - "team_id": "t-1", - "email": "a@b.com", - }, - ) - resp = client.auth.signup("a@b.com", "password123") - assert isinstance(resp, AuthResponse) - assert resp.token == "jwt-token" - assert resp.user_id == "u-1" - - @respx.mock - def test_login(self, client): - respx.post("https://api.wrenn.dev/v1/auth/login").respond( - 200, - json={"token": "jwt-token", "email": "a@b.com"}, - ) - resp = client.auth.login("a@b.com", "password123") - assert resp.token == "jwt-token" - - -class TestAPIKeys: - @respx.mock - def test_create(self, client): - respx.post("https://api.wrenn.dev/v1/api-keys").respond( - 201, - json={ - "id": "key-1", - "name": "my-key", - "key_prefix": "wrn_ab12cd34", - "key": "wrn_ab12cd34fullkey", - }, - ) - resp = client.api_keys.create(name="my-key") - assert isinstance(resp, APIKeyResponse) - assert resp.name == "my-key" - assert resp.key == "wrn_ab12cd34fullkey" - - @respx.mock - def test_list(self, client): - respx.get("https://api.wrenn.dev/v1/api-keys").respond( - 200, - json=[{"id": "key-1", "name": "k1"}, {"id": "key-2", "name": "k2"}], - ) - keys = client.api_keys.list() - assert len(keys) == 2 - assert keys[0].id == "key-1" - - @respx.mock - def test_delete(self, client): - route = respx.delete("https://api.wrenn.dev/v1/api-keys/key-1").respond(204) - client.api_keys.delete("key-1") - assert route.called - - class TestCapsules: @respx.mock def test_create(self, client): - respx.post("https://api.wrenn.dev/v1/capsules").respond( + respx.post(f"{BASE}/v1/capsules").respond( 201, json={ "id": "sb-1", @@ -117,7 +52,7 @@ class TestCapsules: @respx.mock def test_create_defaults(self, client): - respx.post("https://api.wrenn.dev/v1/capsules").respond( + respx.post(f"{BASE}/v1/capsules").respond( 201, json={"id": "sb-2", "status": "pending"} ) resp = client.capsules.create() @@ -125,7 +60,7 @@ class TestCapsules: @respx.mock def test_list(self, client): - respx.get("https://api.wrenn.dev/v1/capsules").respond( + respx.get(f"{BASE}/v1/capsules").respond( 200, json=[{"id": "sb-1", "status": "running"}] ) boxes = client.capsules.list() @@ -134,7 +69,7 @@ class TestCapsules: @respx.mock def test_get(self, client): - respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond( + respx.get(f"{BASE}/v1/capsules/sb-1").respond( 200, json={"id": "sb-1", "status": "running"} ) resp = client.capsules.get("sb-1") @@ -142,15 +77,37 @@ class TestCapsules: @respx.mock def test_destroy(self, client): - route = respx.delete("https://api.wrenn.dev/v1/capsules/sb-1").respond(204) + route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(204) client.capsules.destroy("sb-1") assert route.called + @respx.mock + def test_pause(self, client): + respx.post(f"{BASE}/v1/capsules/sb-1/pause").respond( + 200, json={"id": "sb-1", "status": "paused"} + ) + resp = client.capsules.pause("sb-1") + assert resp.status == Status.paused + + @respx.mock + def test_resume(self, client): + respx.post(f"{BASE}/v1/capsules/sb-1/resume").respond( + 200, json={"id": "sb-1", "status": "running"} + ) + resp = client.capsules.resume("sb-1") + assert resp.status == Status.running + + @respx.mock + def test_ping(self, client): + route = respx.post(f"{BASE}/v1/capsules/sb-1/ping").respond(204) + client.capsules.ping("sb-1") + assert route.called + class TestSnapshots: @respx.mock def test_create(self, client): - respx.post("https://api.wrenn.dev/v1/snapshots").respond( + respx.post(f"{BASE}/v1/snapshots").respond( 201, json={"name": "snap-1", "type": "snapshot", "vcpus": 1}, ) @@ -160,7 +117,7 @@ class TestSnapshots: @respx.mock def test_create_with_overwrite(self, client): - route = respx.post("https://api.wrenn.dev/v1/snapshots").respond( + route = respx.post(f"{BASE}/v1/snapshots").respond( 201, json={"name": "snap-1", "type": "snapshot"} ) client.snapshots.create(capsule_id="sb-1", overwrite=True) @@ -169,7 +126,7 @@ class TestSnapshots: @respx.mock def test_list(self, client): - respx.get("https://api.wrenn.dev/v1/snapshots").respond( + respx.get(f"{BASE}/v1/snapshots").respond( 200, json=[{"name": "base-python", "type": "base"}] ) snaps = client.snapshots.list() @@ -177,92 +134,22 @@ class TestSnapshots: @respx.mock def test_list_with_filter(self, client): - route = respx.get("https://api.wrenn.dev/v1/snapshots").respond(200, json=[]) + route = respx.get(f"{BASE}/v1/snapshots").respond(200, json=[]) client.snapshots.list(type="snapshot") req = route.calls[0].request assert "type=snapshot" in str(req.url) @respx.mock def test_delete(self, client): - route = respx.delete("https://api.wrenn.dev/v1/snapshots/snap-1").respond(204) + route = respx.delete(f"{BASE}/v1/snapshots/snap-1").respond(204) client.snapshots.delete("snap-1") assert route.called -class TestHosts: - @respx.mock - def test_create(self, client): - respx.post("https://api.wrenn.dev/v1/hosts").respond( - 201, - json={ - "host": {"id": "h-1", "type": "regular", "status": "pending"}, - "registration_token": "reg-tok-123", - }, - ) - resp = client.hosts.create(type="regular") - assert isinstance(resp, CreateHostResponse) - assert resp.registration_token == "reg-tok-123" - - @respx.mock - def test_list(self, client): - respx.get("https://api.wrenn.dev/v1/hosts").respond( - 200, json=[{"id": "h-1", "status": "online"}] - ) - hosts = client.hosts.list() - assert len(hosts) == 1 - assert isinstance(hosts[0], Host) - - @respx.mock - def test_get(self, client): - respx.get("https://api.wrenn.dev/v1/hosts/h-1").respond( - 200, json={"id": "h-1", "status": "online"} - ) - resp = client.hosts.get("h-1") - assert resp.id == "h-1" - - @respx.mock - def test_delete(self, client): - route = respx.delete("https://api.wrenn.dev/v1/hosts/h-1").respond(204) - client.hosts.delete("h-1") - assert route.called - - @respx.mock - def test_regenerate_token(self, client): - respx.post("https://api.wrenn.dev/v1/hosts/h-1/token").respond( - 201, - json={ - "host": {"id": "h-1"}, - "registration_token": "new-tok", - }, - ) - resp = client.hosts.regenerate_token("h-1") - assert resp.registration_token == "new-tok" - - @respx.mock - def test_list_tags(self, client): - respx.get("https://api.wrenn.dev/v1/hosts/h-1/tags").respond( - 200, json=["gpu", "high-mem"] - ) - tags = client.hosts.list_tags("h-1") - assert tags == ["gpu", "high-mem"] - - @respx.mock - def test_add_tag(self, client): - route = respx.post("https://api.wrenn.dev/v1/hosts/h-1/tags").respond(204) - client.hosts.add_tag("h-1", "gpu") - assert route.called - - @respx.mock - def test_remove_tag(self, client): - route = respx.delete("https://api.wrenn.dev/v1/hosts/h-1/tags/gpu").respond(204) - client.hosts.remove_tag("h-1", "gpu") - assert route.called - - class TestErrorHandling: @respx.mock def test_validation_error(self, client): - respx.post("https://api.wrenn.dev/v1/capsules").respond( + respx.post(f"{BASE}/v1/capsules").respond( 400, json={"error": {"code": "invalid_request", "message": "bad input"}}, ) @@ -273,25 +160,16 @@ class TestErrorHandling: @respx.mock def test_auth_error(self, client): - respx.get("https://api.wrenn.dev/v1/capsules").respond( + respx.get(f"{BASE}/v1/capsules").respond( 401, json={"error": {"code": "unauthorized", "message": "bad key"}}, ) with pytest.raises(WrennAuthenticationError): client.capsules.list() - @respx.mock - def test_forbidden_error(self, client): - respx.post("https://api.wrenn.dev/v1/hosts").respond( - 403, - json={"error": {"code": "forbidden", "message": "nope"}}, - ) - with pytest.raises(WrennForbiddenError): - client.hosts.create(type="regular") - @respx.mock def test_not_found_error(self, client): - respx.get("https://api.wrenn.dev/v1/capsules/nope").respond( + respx.get(f"{BASE}/v1/capsules/nope").respond( 404, json={"error": {"code": "not_found", "message": "capsule not found"}}, ) @@ -300,32 +178,16 @@ class TestErrorHandling: @respx.mock def test_conflict_error(self, client): - respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond( + respx.get(f"{BASE}/v1/capsules/sb-1").respond( 409, json={"error": {"code": "invalid_state", "message": "not running"}}, ) with pytest.raises(WrennConflictError): client.capsules.get("sb-1") - @respx.mock - def test_host_has_capsules_error(self, client): - respx.delete("https://api.wrenn.dev/v1/hosts/h-1").respond( - 409, - json={ - "error": { - "code": "host_has_capsules", - "message": "host has running capsules", - }, - "sandbox_ids": ["sb-1", "sb-2"], - }, - ) - with pytest.raises(WrennHostHasCapsulesError) as exc_info: - client.hosts.delete("h-1") - assert exc_info.value.capsule_ids == ["sb-1", "sb-2"] - @respx.mock def test_agent_error(self, client): - respx.post("https://api.wrenn.dev/v1/capsules").respond( + respx.post(f"{BASE}/v1/capsules").respond( 502, json={"error": {"code": "agent_error", "message": "host agent failed"}}, ) @@ -334,7 +196,7 @@ class TestErrorHandling: @respx.mock def test_internal_error(self, client): - respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond( + respx.get(f"{BASE}/v1/capsules/sb-1").respond( 500, json={"error": {"code": "internal_error", "message": "oops"}}, ) @@ -343,7 +205,7 @@ class TestErrorHandling: @respx.mock def test_unknown_error_code_falls_back(self, client): - respx.get("https://api.wrenn.dev/v1/capsules/sb-1").respond( + respx.get(f"{BASE}/v1/capsules/sb-1").respond( 418, json={"error": {"code": "teapot", "message": "I'm a teapot"}}, ) @@ -359,21 +221,14 @@ class TestAuthModes: with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: assert c._http.headers["X-API-Key"] == "wrn_test1234567890abcdef12345678" - def test_token_header(self): - with WrennClient(token="jwt-token-abc") as c: - assert c._http.headers["Authorization"] == "Bearer jwt-token-abc" - def test_no_auth_raises(self): - with pytest.raises(ValueError, match="Either api_key or token"): + with pytest.raises(ValueError, match="No API key"): WrennClient() - @respx.mock - def test_jwt_auth_on_api_keys(self): - route = respx.get("https://api.wrenn.dev/v1/api-keys").respond(200, json=[]) - with WrennClient(token="jwt-abc") as c: - c.api_keys.list() - req = route.calls[0].request - assert req.headers["Authorization"] == "Bearer jwt-abc" + def test_env_var_fallback(self, monkeypatch): + monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env") + with WrennClient() as c: + assert c._http.headers["X-API-Key"] == "wrn_from_env" class TestAsyncClient: @@ -381,7 +236,7 @@ class TestAsyncClient: @respx.mock async def test_async_capsules_create(self, async_client): async with async_client: - respx.post("https://api.wrenn.dev/v1/capsules").respond( + respx.post(f"{BASE}/v1/capsules").respond( 201, json={"id": "sb-1", "status": "pending"} ) resp = await async_client.capsules.create(template="base-python") @@ -391,25 +246,17 @@ class TestAsyncClient: @respx.mock async def test_async_capsules_list(self, async_client): async with async_client: - respx.get("https://api.wrenn.dev/v1/capsules").respond( + respx.get(f"{BASE}/v1/capsules").respond( 200, json=[{"id": "sb-1"}] ) boxes = await async_client.capsules.list() assert len(boxes) == 1 - @pytest.mark.asyncio - @respx.mock - async def test_async_hosts_list(self, async_client): - async with async_client: - respx.get("https://api.wrenn.dev/v1/hosts").respond(200, json=[]) - hosts = await async_client.hosts.list() - assert hosts == [] - @pytest.mark.asyncio @respx.mock async def test_async_error_handling(self, async_client): async with async_client: - respx.get("https://api.wrenn.dev/v1/capsules/nope").respond( + respx.get(f"{BASE}/v1/capsules/nope").respond( 404, json={"error": {"code": "not_found", "message": "not found"}}, ) diff --git a/tests/test_filesystem_pty.py b/tests/test_filesystem_pty.py index 6b494a6..2ed5c51 100644 --- a/tests/test_filesystem_pty.py +++ b/tests/test_filesystem_pty.py @@ -8,7 +8,6 @@ import pytest import respx from wrenn.capsule import Capsule -from wrenn.client import WrennClient from wrenn.models import FileEntry from wrenn.pty import ( AsyncPtySession, @@ -17,25 +16,59 @@ from wrenn.pty import ( _parse_pty_event, ) - -@pytest.fixture -def client(): - with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: - yield c +BASE = "https://app.wrenn.dev/api" -def _make_capsule(client: WrennClient, cap_id: str = "cl-abc") -> Capsule: - respx.post("https://api.wrenn.dev/v1/capsules").respond( +def _make_capsule(cap_id: str = "cl-abc") -> Capsule: + respx.post(f"{BASE}/v1/capsules").respond( 201, json={"id": cap_id, "status": "running"} ) - return client.capsules.create() + return Capsule(api_key="wrn_test1234567890abcdef12345678") -class TestListDir: +class TestFilesRead: @respx.mock - def test_list_dir_returns_entries(self, client): - cap = _make_capsule(client) - respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond( + def test_read_returns_string(self): + cap = _make_capsule() + content = b"file contents here" + respx.post(f"{BASE}/v1/capsules/cl-abc/files/read").respond( + 200, content=content + ) + data = cap.files.read("/app/main.py") + assert data == "file contents here" + + @respx.mock + def test_read_bytes(self): + cap = _make_capsule() + content = b"\x00\x01\x02" + respx.post(f"{BASE}/v1/capsules/cl-abc/files/read").respond( + 200, content=content + ) + data = cap.files.read_bytes("/bin/binary") + assert data == b"\x00\x01\x02" + + +class TestFilesWrite: + @respx.mock + def test_write_string(self): + cap = _make_capsule() + route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/write").respond(204) + cap.files.write("/app/main.py", "print('hello')") + assert route.called + + @respx.mock + def test_write_bytes(self): + cap = _make_capsule() + route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/write").respond(204) + cap.files.write("/app/data.bin", b"\x00\x01\x02") + assert route.called + + +class TestFilesList: + @respx.mock + def test_list_returns_entries(self): + cap = _make_capsule() + respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond( 200, json={ "entries": [ @@ -66,7 +99,7 @@ class TestListDir: ] }, ) - entries = cap.list_dir("/home/user") + entries = cap.files.list("/home/user") assert len(entries) == 2 assert isinstance(entries[0], FileEntry) assert entries[0].name == "main.py" @@ -75,57 +108,30 @@ class TestListDir: assert entries[1].type == "directory" @respx.mock - def test_list_dir_with_depth(self, client): - cap = _make_capsule(client) - route = respx.post( - "https://api.wrenn.dev/v1/capsules/cl-abc/files/list" - ).respond(200, json={"entries": []}) - cap.list_dir("/home/user", depth=3) + def test_list_with_depth(self): + cap = _make_capsule() + route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond( + 200, json={"entries": []} + ) + cap.files.list("/home/user", depth=3) body = json.loads(route.calls[0].request.content) assert body["depth"] == 3 @respx.mock - def test_list_dir_empty(self, client): - cap = _make_capsule(client) - respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond( + def test_list_empty(self): + cap = _make_capsule() + respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond( 200, json={"entries": []} ) - entries = cap.list_dir("/empty") + entries = cap.files.list("/empty") assert entries == [] - @respx.mock - def test_list_dir_symlink(self, client): - cap = _make_capsule(client) - respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond( - 200, - json={ - "entries": [ - { - "name": "link", - "path": "/home/user/link", - "type": "symlink", - "size": 4, - "mode": 41471, - "permissions": "lrwxrwxrwx", - "owner": "root", - "group": "root", - "modified_at": 1712899000, - "symlink_target": "/bin", - } - ] - }, - ) - entries = cap.list_dir("/home/user") - assert len(entries) == 1 - assert entries[0].type == "symlink" - assert entries[0].symlink_target == "/bin" - -class TestMkdir: +class TestFilesMakeDir: @respx.mock - def test_mkdir_returns_entry(self, client): - cap = _make_capsule(client) - respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/mkdir").respond( + def test_make_dir_returns_entry(self): + cap = _make_capsule() + respx.post(f"{BASE}/v1/capsules/cl-abc/files/mkdir").respond( 200, json={ "entry": { @@ -142,19 +148,19 @@ class TestMkdir: } }, ) - entry = cap.mkdir("/home/user/data") + entry = cap.files.make_dir("/home/user/data") assert isinstance(entry, FileEntry) assert entry.name == "data" assert entry.type == "directory" @respx.mock - def test_mkdir_existing_returns_gracefully(self, client): - cap = _make_capsule(client) - respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/mkdir").respond( + def test_make_dir_existing_returns_gracefully(self): + cap = _make_capsule() + respx.post(f"{BASE}/v1/capsules/cl-abc/files/mkdir").respond( 409, json={"error": {"code": "conflict", "message": "already exists"}}, ) - respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/list").respond( + respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond( 200, json={ "entries": [ @@ -173,52 +179,48 @@ class TestMkdir: ] }, ) - entry = cap.mkdir("/home/user/data") + entry = cap.files.make_dir("/home/user/data") assert entry.name == "data" -class TestRemove: +class TestFilesRemove: @respx.mock - def test_remove_succeeds(self, client): - cap = _make_capsule(client) - route = respx.post( - "https://api.wrenn.dev/v1/capsules/cl-abc/files/remove" - ).respond(204) - cap.remove("/home/user/old_data") + def test_remove_succeeds(self): + cap = _make_capsule() + route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204) + cap.files.remove("/home/user/old_data") assert route.called @respx.mock - def test_remove_sends_path(self, client): - cap = _make_capsule(client) - route = respx.post( - "https://api.wrenn.dev/v1/capsules/cl-abc/files/remove" - ).respond(204) - cap.remove("/tmp/test.txt") + def test_remove_sends_path(self): + cap = _make_capsule() + route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204) + cap.files.remove("/tmp/test.txt") body = json.loads(route.calls[0].request.content) assert body["path"] == "/tmp/test.txt" -class TestUpload: +class TestFilesExists: @respx.mock - def test_upload_sends_multipart(self, client): - cap = _make_capsule(client) - route = respx.post( - "https://api.wrenn.dev/v1/capsules/cl-abc/files/write" - ).respond(204) - cap.upload("/app/main.py", b"print('hello')") - assert route.called - req = route.calls[0].request - assert b"multipart/form-data" in req.headers.get("content-type", "").encode() + def test_exists_true(self): + cap = _make_capsule() + respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond( + 200, + json={ + "entries": [ + {"name": "hello.txt", "path": "/tmp/hello.txt", "type": "file"} + ] + }, + ) + assert cap.files.exists("/tmp/hello.txt") is True @respx.mock - def test_download_returns_bytes(self, client): - cap = _make_capsule(client) - content = b"file contents here" - respx.post("https://api.wrenn.dev/v1/capsules/cl-abc/files/read").respond( - 200, content=content + def test_exists_false(self): + cap = _make_capsule() + respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond( + 200, json={"entries": []} ) - data = cap.download("/app/main.py") - assert data == content + assert cap.files.exists("/tmp/nope.txt") is False class TestPtyEventParsing: @@ -254,11 +256,6 @@ class TestPtyEventParsing: assert event.data == "process not found" assert event.fatal is True - def test_error_event_non_fatal(self): - raw = {"type": "error", "data": "something", "fatal": False} - event = _parse_pty_event(raw) - assert event.fatal is False - def test_ping_event(self): raw = {"type": "ping"} event = _parse_pty_event(raw) @@ -308,7 +305,9 @@ class TestPtySessionIteration: ws = MagicMock() messages = [ json.dumps({"type": "started", "tag": "pty-abc12345", "pid": 1}), - json.dumps({"type": "output", "data": base64.b64encode(b"hello").decode()}), + json.dumps( + {"type": "output", "data": base64.b64encode(b"hello").decode()} + ), json.dumps({"type": "exit", "exit_code": 0}), ] ws.receive_text.side_effect = messages @@ -385,9 +384,6 @@ class TestPtySessionSendStart: assert sent["cmd"] == "/bin/zsh" assert sent["args"] == ["-l"] assert sent["cols"] == 120 - assert sent["rows"] == 40 - assert sent["envs"] == {"TERM": "xterm-256color"} - assert sent["cwd"] == "/home/user" class TestPtySessionSendConnect: @@ -453,23 +449,15 @@ class TestAsyncPtySession: assert sent["type"] == "start" assert sent["cmd"] == "/bin/zsh" assert sent["cols"] == 100 - assert sent["rows"] == 30 - - @pytest.mark.asyncio - async def test_async_send_connect(self): - ws = AsyncMock() - session = AsyncPtySession(ws, "cl-abc") - await session._send_connect("pty-abc12345") - sent = json.loads(ws.send_text.call_args[0][0]) - assert sent["type"] == "connect" - assert sent["tag"] == "pty-abc12345" @pytest.mark.asyncio async def test_async_iteration(self): ws = AsyncMock() messages = [ json.dumps({"type": "started", "tag": "pty-xyz", "pid": 5}), - json.dumps({"type": "output", "data": base64.b64encode(b"hi").decode()}), + json.dumps( + {"type": "output", "data": base64.b64encode(b"hi").decode()} + ), json.dumps({"type": "exit", "exit_code": 0}), ] ws.receive_text.side_effect = messages -- 2.49.0 From eecf1dc65b3b9e237c35f83ae35158098b0b05ff Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 15 Apr 2026 15:31:07 +0600 Subject: [PATCH 09/44] chore: update OpenAPI schema, generated models, and build config Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 4 +- api/openapi.yaml | 174 +++++++++++++++++++++++- pyproject.toml | 2 +- src/wrenn/models/_generated.py | 237 +++++++++++++++++++-------------- uv.lock | 11 +- 5 files changed, 324 insertions(+), 104 deletions(-) diff --git a/Makefile b/Makefile index a4a57ba..7720026 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,9 @@ generate: --use-schema-description \ --target-python-version 3.13 \ --use-annotated \ - --openapi-scopes schemas + --openapi-scopes schemas \ + --formatters ruff-format ruff-check \ + --input-file-type openapi lint: uv run ruff check src/ diff --git a/api/openapi.yaml b/api/openapi.yaml index b6bd643..031cefd 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -699,11 +699,17 @@ paths: $ref: "#/components/schemas/ExecRequest" responses: "200": - description: Command output + description: Command output (foreground exec) content: application/json: schema: $ref: "#/components/schemas/ExecResponse" + "202": + description: Background process started + content: + application/json: + schema: + $ref: "#/components/schemas/BackgroundExecResponse" "404": description: Capsule not found content: @@ -717,6 +723,122 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/capsules/{id}/processes: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: List running processes + operationId: listProcesses + tags: [capsules] + security: + - apiKeyAuth: [] + description: | + Returns all running processes inside the capsule, including background + processes and any processes started by templates or init scripts. + responses: + "200": + description: Process list + content: + application/json: + schema: + $ref: "#/components/schemas/ProcessListResponse" + "404": + description: Capsule not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Capsule not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/capsules/{id}/processes/{selector}: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: selector + in: path + required: true + description: Process PID (numeric) or tag (string) + schema: + type: string + + delete: + summary: Kill a process + operationId: killProcess + tags: [capsules] + security: + - apiKeyAuth: [] + parameters: + - name: signal + in: query + required: false + description: Signal to send (SIGKILL or SIGTERM, default SIGKILL) + schema: + type: string + enum: [SIGKILL, SIGTERM] + default: SIGKILL + responses: + "204": + description: Process killed + "404": + description: Capsule or process not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Capsule not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/capsules/{id}/processes/{selector}/stream: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: selector + in: path + required: true + description: Process PID (numeric) or tag (string) + schema: + type: string + + get: + summary: Stream process output via WebSocket + operationId: connectProcess + tags: [capsules] + security: + - apiKeyAuth: [] + description: | + Opens a WebSocket connection to stream stdout/stderr from a running + background process. The selector can be a numeric PID or a string tag. + + Server sends JSON messages: + - `{"type": "start", "pid": 42}` — connected to process + - `{"type": "stdout", "data": "..."}` — stdout output + - `{"type": "stderr", "data": "..."}` — stderr output + - `{"type": "exit", "exit_code": 0}` — process exited + - `{"type": "error", "data": "..."}` — error message + responses: + "101": + description: WebSocket upgrade + /v1/capsules/{id}/ping: parameters: - name: id @@ -2153,6 +2275,56 @@ components: timeout_sec: type: integer default: 30 + description: Timeout in seconds (foreground exec only, default 30) + background: + type: boolean + default: false + description: If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202) + tag: + type: string + description: Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true. + envs: + type: object + additionalProperties: + type: string + description: Environment variables for the process (background exec only) + cwd: + type: string + description: Working directory for the process (background exec only) + + BackgroundExecResponse: + type: object + properties: + sandbox_id: + type: string + cmd: + type: string + pid: + type: integer + tag: + type: string + + ProcessEntry: + type: object + properties: + pid: + type: integer + tag: + type: string + cmd: + type: string + args: + type: array + items: + type: string + + ProcessListResponse: + type: object + properties: + processes: + type: array + items: + $ref: "#/components/schemas/ProcessEntry" ExecResponse: type: object diff --git a/pyproject.toml b/pyproject.toml index d7dbaff..839941f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ build-backend = "hatchling.build" [dependency-groups] dev = [ - "datamodel-code-generator>=0.56.0", + "datamodel-code-generator[ruff]>=0.56.0", "mypy>=1.20.0", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py index 55a5742..4ebdc74 100644 --- a/src/wrenn/models/_generated.py +++ b/src/wrenn/models/_generated.py @@ -1,13 +1,11 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-04-12T20:56:29+00:00 +# timestamp: 2026-04-15T08:37:41+00:00 from __future__ import annotations - -from enum import StrEnum -from typing import Annotated - from pydantic import AwareDatetime, BaseModel, EmailStr, Field +from typing import Annotated +from enum import StrEnum class SignupRequest(BaseModel): @@ -22,7 +20,7 @@ class LoginRequest(BaseModel): class AuthResponse(BaseModel): - token: Annotated[str | None, Field(description='JWT token (valid for 6 hours)')] = ( + token: Annotated[str | None, Field(description="JWT token (valid for 6 hours)")] = ( None ) user_id: str | None = None @@ -32,7 +30,7 @@ class AuthResponse(BaseModel): class CreateAPIKeyRequest(BaseModel): - name: str | None = 'Unnamed API Key' + name: str | None = "Unnamed API Key" class APIKeyResponse(BaseModel): @@ -47,29 +45,29 @@ class APIKeyResponse(BaseModel): key: Annotated[ str | None, Field( - description='Full plaintext key. Only returned on creation, never again.' + description="Full plaintext key. Only returned on creation, never again." ), ] = None class CreateCapsuleRequest(BaseModel): - template: str | None = 'minimal' + template: str | None = "minimal" vcpus: int | None = 1 memory_mb: int | None = 512 timeout_sec: Annotated[ int | None, Field( - description='Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause.\n' + description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause.\n" ), ] = 0 class Range(StrEnum): - field_5m = '5m' - field_1h = '1h' - field_6h = '6h' - field_24h = '24h' - field_30d = '30d' + field_5m = "5m" + field_1h = "1h" + field_6h = "6h" + field_24h = "24h" + field_30d = "30d" class Current(BaseModel): @@ -104,22 +102,22 @@ class CapsuleStats(BaseModel): range: Range | None = None current: Current | None = None peaks: Annotated[ - Peaks | None, Field(description='Maximum values over the last 30 days.') + Peaks | None, Field(description="Maximum values over the last 30 days.") ] = None series: Annotated[ - Series | None, Field(description='Parallel arrays for chart rendering.') + Series | None, Field(description="Parallel arrays for chart rendering.") ] = None class Status(StrEnum): - pending = 'pending' - starting = 'starting' - running = 'running' - paused = 'paused' - hibernated = 'hibernated' - stopped = 'stopped' - missing = 'missing' - error = 'error' + pending = "pending" + starting = "starting" + running = "running" + paused = "paused" + hibernated = "hibernated" + stopped = "stopped" + missing = "missing" + error = "error" class Capsule(BaseModel): @@ -139,17 +137,17 @@ class Capsule(BaseModel): class CreateSnapshotRequest(BaseModel): sandbox_id: Annotated[ - str, Field(description='ID of the running capsule to snapshot.') + str, Field(description="ID of the running capsule to snapshot.") ] name: Annotated[ str | None, - Field(description='Name for the snapshot template. Auto-generated if omitted.'), + Field(description="Name for the snapshot template. Auto-generated if omitted."), ] = None class Type(StrEnum): - base = 'base' - snapshot = 'snapshot' + base = "base" + snapshot = "snapshot" class Template(BaseModel): @@ -164,7 +162,50 @@ class Template(BaseModel): class ExecRequest(BaseModel): cmd: str args: list[str] | None = None - timeout_sec: int | None = 30 + timeout_sec: Annotated[ + int | None, + Field(description="Timeout in seconds (foreground exec only, default 30)"), + ] = 30 + background: Annotated[ + bool | None, + Field( + description="If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202)" + ), + ] = False + tag: Annotated[ + str | None, + Field( + description="Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true." + ), + ] = None + envs: Annotated[ + dict[str, str] | None, + Field( + description="Environment variables for the process (background exec only)" + ), + ] = None + cwd: Annotated[ + str | None, + Field(description="Working directory for the process (background exec only)"), + ] = None + + +class BackgroundExecResponse(BaseModel): + sandbox_id: str | None = None + cmd: str | None = None + pid: int | None = None + tag: str | None = None + + +class ProcessEntry(BaseModel): + pid: int | None = None + tag: str | None = None + cmd: str | None = None + args: list[str] | None = None + + +class ProcessListResponse(BaseModel): + processes: list[ProcessEntry] | None = None class Encoding(StrEnum): @@ -172,8 +213,8 @@ class Encoding(StrEnum): Output encoding. "base64" when stdout/stderr contain binary data. """ - utf_8 = 'utf-8' - base64 = 'base64' + utf_8 = "utf-8" + base64 = "base64" class ExecResponse(BaseModel): @@ -192,23 +233,23 @@ class ExecResponse(BaseModel): class ReadFileRequest(BaseModel): - path: Annotated[str, Field(description='Absolute file path inside the capsule')] + path: Annotated[str, Field(description="Absolute file path inside the capsule")] class ListDirRequest(BaseModel): - path: Annotated[str, Field(description='Directory path inside the capsule')] + path: Annotated[str, Field(description="Directory path inside the capsule")] depth: Annotated[ int | None, Field( - description='Recursion depth (0 = non-recursive, 1 = immediate children)' + description="Recursion depth (0 = non-recursive, 1 = immediate children)" ), ] = 1 class Type1(StrEnum): - file = 'file' - directory = 'directory' - symlink = 'symlink' + file = "file" + directory = "directory" + symlink = "symlink" class FileEntry(BaseModel): @@ -223,14 +264,14 @@ class FileEntry(BaseModel): owner: str | None = None group: str | None = None modified_at: Annotated[ - int | None, Field(description='Unix timestamp (seconds)') + int | None, Field(description="Unix timestamp (seconds)") ] = None symlink_target: str | None = None class MakeDirRequest(BaseModel): path: Annotated[ - str, Field(description='Directory path to create inside the capsule') + str, Field(description="Directory path to create inside the capsule") ] @@ -239,7 +280,7 @@ class MakeDirResponse(BaseModel): class RemoveRequest(BaseModel): - path: Annotated[str, Field(description='Path to remove inside the capsule')] + path: Annotated[str, Field(description="Path to remove inside the capsule")] class Type2(StrEnum): @@ -247,51 +288,51 @@ class Type2(StrEnum): Host type. Regular hosts are shared; BYOC hosts belong to a team. """ - regular = 'regular' - byoc = 'byoc' + regular = "regular" + byoc = "byoc" class CreateHostRequest(BaseModel): type: Annotated[ Type2, Field( - description='Host type. Regular hosts are shared; BYOC hosts belong to a team.' + description="Host type. Regular hosts are shared; BYOC hosts belong to a team." ), ] - team_id: Annotated[str | None, Field(description='Required for BYOC hosts.')] = None + team_id: Annotated[str | None, Field(description="Required for BYOC hosts.")] = None provider: Annotated[ str | None, - Field(description='Cloud provider (e.g. aws, gcp, hetzner, bare-metal).'), + Field(description="Cloud provider (e.g. aws, gcp, hetzner, bare-metal)."), ] = None availability_zone: Annotated[ - str | None, Field(description='Availability zone (e.g. us-east, eu-west).') + str | None, Field(description="Availability zone (e.g. us-east, eu-west).") ] = None class RegisterHostRequest(BaseModel): token: Annotated[ - str, Field(description='One-time registration token from POST /v1/hosts.') + str, Field(description="One-time registration token from POST /v1/hosts.") ] arch: Annotated[ - str | None, Field(description='CPU architecture (e.g. x86_64, aarch64).') + str | None, Field(description="CPU architecture (e.g. x86_64, aarch64).") ] = None cpu_cores: int | None = None memory_mb: int | None = None disk_gb: int | None = None - address: Annotated[str, Field(description='Host agent address (ip:port).')] + address: Annotated[str, Field(description="Host agent address (ip:port).")] class Type3(StrEnum): - regular = 'regular' - byoc = 'byoc' + regular = "regular" + byoc = "byoc" class Status1(StrEnum): - pending = 'pending' - online = 'online' - offline = 'offline' - draining = 'draining' - unreachable = 'unreachable' + pending = "pending" + online = "online" + offline = "offline" + draining = "draining" + unreachable = "unreachable" class Host(BaseModel): @@ -316,7 +357,7 @@ class RefreshHostTokenRequest(BaseModel): refresh_token: Annotated[ str, Field( - description='Refresh token obtained from registration or a previous refresh.' + description="Refresh token obtained from registration or a previous refresh." ), ] @@ -324,12 +365,12 @@ class RefreshHostTokenRequest(BaseModel): class RefreshHostTokenResponse(BaseModel): host: Host | None = None token: Annotated[ - str | None, Field(description='New host JWT. Valid for 7 days.') + str | None, Field(description="New host JWT. Valid for 7 days.") ] = None refresh_token: Annotated[ str | None, Field( - description='New refresh token. Valid for 60 days; old token is revoked.' + description="New refresh token. Valid for 60 days; old token is revoked." ), ] = None @@ -338,16 +379,16 @@ class HostDeletePreview(BaseModel): host: Host | None = None sandbox_ids: Annotated[ list[str] | None, - Field(description='IDs of capsulees that would be destroyed on force-delete.'), + Field(description="IDs of capsulees that would be destroyed on force-delete."), ] = None class Error(BaseModel): - code: Annotated[str | None, Field(examples=['host_has_sandboxes'])] = None + code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None message: str | None = None sandbox_ids: Annotated[ list[str] | None, - Field(description='IDs of active capsulees blocking deletion.'), + Field(description="IDs of active capsulees blocking deletion."), ] = None @@ -368,15 +409,15 @@ class Team(BaseModel): id: str | None = None name: str | None = None slug: Annotated[ - str | None, Field(description='Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)') + str | None, Field(description="Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)") ] = None created_at: AwareDatetime | None = None class Role(StrEnum): - owner = 'owner' - admin = 'admin' - member = 'member' + owner = "owner" + admin = "admin" + member = "member" class TeamWithRole(Team): @@ -396,13 +437,13 @@ class TeamDetail(BaseModel): class Range1(StrEnum): - field_5m = '5m' - field_10m = '10m' - field_1h = '1h' - field_2h = '2h' - field_6h = '6h' - field_12h = '12h' - field_24h = '24h' + field_5m = "5m" + field_10m = "10m" + field_1h = "1h" + field_2h = "2h" + field_6h = "6h" + field_12h = "12h" + field_24h = "24h" class MetricPoint(BaseModel): @@ -410,41 +451,41 @@ class MetricPoint(BaseModel): cpu_pct: Annotated[ float | None, Field( - description='CPU utilization percentage (0-100), normalized to vCPU count' + description="CPU utilization percentage (0-100), normalized to vCPU count" ), ] = None mem_bytes: Annotated[ int | None, - Field(description='Resident memory in bytes (VmRSS of Firecracker process)'), + Field(description="Resident memory in bytes (VmRSS of Firecracker process)"), ] = None disk_bytes: Annotated[ - int | None, Field(description='Allocated disk bytes for the CoW sparse file') + int | None, Field(description="Allocated disk bytes for the CoW sparse file") ] = None class Provider(StrEnum): - discord = 'discord' - slack = 'slack' - teams = 'teams' - googlechat = 'googlechat' - telegram = 'telegram' - matrix = 'matrix' - webhook = 'webhook' + discord = "discord" + slack = "slack" + teams = "teams" + googlechat = "googlechat" + telegram = "telegram" + matrix = "matrix" + webhook = "webhook" class Event(StrEnum): - capsule_created = 'capsule.created' - capsule_running = 'capsule.running' - capsule_paused = 'capsule.paused' - capsule_destroyed = 'capsule.destroyed' - template_snapshot_created = 'template.snapshot.created' - template_snapshot_deleted = 'template.snapshot.deleted' - host_up = 'host.up' - host_down = 'host.down' + capsule_created = "capsule.created" + capsule_running = "capsule.running" + capsule_paused = "capsule.paused" + capsule_destroyed = "capsule.destroyed" + template_snapshot_created = "template.snapshot.created" + template_snapshot_deleted = "template.snapshot.deleted" + host_up = "host.up" + host_down = "host.down" class CreateChannelRequest(BaseModel): - name: Annotated[str, Field(description='Unique channel name within the team.')] + name: Annotated[str, Field(description="Unique channel name within the team.")] provider: Provider config: Annotated[ dict[str, str], @@ -460,7 +501,7 @@ class TestChannelRequest(BaseModel): config: Annotated[ dict[str, str], Field( - description='Provider-specific configuration fields (same as CreateChannelRequest.config).' + description="Provider-specific configuration fields (same as CreateChannelRequest.config)." ), ] @@ -489,7 +530,7 @@ class ChannelResponse(BaseModel): updated_at: AwareDatetime | None = None secret: Annotated[ str | None, - Field(description='Webhook secret. Only returned on creation, never again.'), + Field(description="Webhook secret. Only returned on creation, never again."), ] = None @@ -511,7 +552,7 @@ class CreateHostResponse(BaseModel): registration_token: Annotated[ str | None, Field( - description='One-time registration token for the host agent. Expires in 1 hour.' + description="One-time registration token for the host agent. Expires in 1 hour." ), ] = None @@ -520,12 +561,12 @@ class RegisterHostResponse(BaseModel): host: Host | None = None token: Annotated[ str | None, - Field(description='Host JWT for X-Host-Token header. Valid for 7 days.'), + Field(description="Host JWT for X-Host-Token header. Valid for 7 days."), ] = None refresh_token: Annotated[ str | None, Field( - description='Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use.' + description="Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use." ), ] = None diff --git a/uv.lock b/uv.lock index 22123d3..985de91 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.14'", @@ -112,6 +112,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/3a/7f169ffc7a2d69a4f9158b1ac083f685b7f4a1a8a1db5d1e4abbb4e741b7/datamodel_code_generator-0.56.0-py3-none-any.whl", hash = "sha256:a0559683fbe90cdf2ce9b6637e3adae3e3a8056a8d0516df581d486e2834ead2", size = 256545, upload-time = "2026-04-04T09:46:17.582Z" }, ] +[package.optional-dependencies] +ruff = [ + { name = "ruff" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -684,7 +689,7 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "datamodel-code-generator" }, + { name = "datamodel-code-generator", extra = ["ruff"] }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -702,7 +707,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "datamodel-code-generator", specifier = ">=0.56.0" }, + { name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" }, { name = "mypy", specifier = ">=1.20.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, -- 2.49.0 From 3d0eda5c6049029a39c37eee063835319b2264a0 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 15 Apr 2026 18:58:59 +0600 Subject: [PATCH 10/44] 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) --- AGENTS.md | 80 --- README.md | 542 +++++++++++++------- src/wrenn/async_capsule.py | 14 +- src/wrenn/capsule.py | 14 +- src/wrenn/code_interpreter/__init__.py | 20 +- src/wrenn/code_interpreter/async_capsule.py | 32 +- src/wrenn/code_interpreter/capsule.py | 28 +- tests/test_capsule_features.py | 4 +- 8 files changed, 440 insertions(+), 294 deletions(-) delete mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 030df8d..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,80 +0,0 @@ -# AGENTS.md - -## What this repo is - -Python SDK for **Wrenn** (microVM code execution platform). Communicates with the Control Plane via REST + WebSockets only — no gRPC. The `envd` and `HostAgentService` are internal to the Go backend and never reachable from this SDK. - -## Build & dev commands - -All commands go through `uv` and the `Makefile`. Never use raw `pip`, `venv`, or `python -m venv`. - -```bash -make generate # Fetch openapi.yaml → src/wrenn/models/_generated.py -make lint # ruff check + ruff format --check on src/ -make test # runs ONLY tests/test_client.py -make test-integration # runs ALL tests (unit + integration, needs live server) -make check # lint + test (test_client.py only) -``` - -To run all unit tests (not just test_client.py): - -```bash -uv run pytest tests/test_client.py tests/test_sandbox_features.py tests/test_filesystem_pty.py -v -``` - -To run a single test: - -```bash -uv run pytest tests/test_client.py::TestAuth::test_signup -v -``` - -## Code generation (CRITICAL) - -Models in `src/wrenn/models/_generated.py` are generated by `datamodel-codegen` from `api/openapi.yaml`. - -1. **Never edit `_generated.py`** — overwritten on next `make generate`. -2. All user-facing models must be re-exported in `src/wrenn/models/__init__.py` via `__all__`. -3. To extend a generated model with custom methods, subclass it (e.g. `Sandbox` in `sandbox.py` subclasses the generated `SandboxModel`). - -## Dependency management - -```bash -uv add # runtime dep -uv add --dev # dev dep -uv run # run in managed .venv -``` - -## Implemented resource namespaces - -Only these are currently implemented in `client.py`: - -- **`client.auth`** — `signup`, `login` -- **`client.api_keys`** — `create`, `list`, `delete` -- **`client.sandboxes`** — `create`, `list`, `get`, `destroy` -- **`client.snapshots`** — `create`, `list`, `delete` -- **`client.hosts`** — `create`, `list`, `get`, `delete`, `regenerate_token`, `list_tags`, `add_tag`, `remove_tag` - -Both sync and async variants exist for every resource. - -## Architecture notes - -- **Sync/async parity**: `WrennClient` + `AsyncWrennClient` in `client.py`, using `httpx.Client`/`httpx.AsyncClient`. Async methods on `Sandbox` are prefixed `async_` (e.g. `async_exec`, `async_upload`). -- **WebSocket library**: `httpx-ws` (not `websockets`). Used for `exec_stream`, `pty`, and `run_code`. -- **Sandbox proxy URL**: `get_url(port)` returns `ws://` or `wss://` scheme. The `http_client` property converts to `http://`/`https://` automatically. -- **`Sandbox`** (in `sandbox.py`) is the main developer-facing class — subclasses generated model, adds lifecycle methods (`exec`, `upload`, `download`, `list_dir`, `mkdir`, `remove`, `pty`, `run_code`, `wait_ready`, `pause`, `resume`, `destroy`, `ping`, `metrics`), context manager support, and proxy helpers. -- **Error handling**: `handle_response()` in `exceptions.py` maps server error `code` field to typed exceptions (not just HTTP status). All inherit from `WrennError` with `.code`, `.message`, `.status_code`. - -## Testing - -- **HTTP mocking**: `respx` library (not `responses` or `pytest-httpx`). Mock routes with `@respx.mock` decorator or `respx.mock` context manager. -- **Async tests**: use `@pytest.mark.asyncio` (backed by `pytest-asyncio`). -- **Integration tests**: in `test_integration.py`, require env vars `WRENN_API_KEY` or `WRENN_TOKEN` (plus optional `WRENN_BASE_URL`, `WRENN_TEST_EMAIL`, `WRENN_TEST_PASSWORD`). They are skipped via `@requires_auth` if credentials are absent. -- **Fixtures**: test fixtures create `WrennClient(api_key="wrn_test1234567890abcdef12345678")` with context manager cleanup. - -## Coding conventions - -- **Python 3.13+** with modern syntax (`|` unions, `list[str]` generics). -- **Strict typing** throughout. `pyright`/`mypy` available but not in CI. -- **`ruff`** is the sole linter and formatter. Do not use `black`, `isort`, or `flake8`. -- **Google-style docstrings** on all public APIs. -- **No comments** unless explicitly asked. diff --git a/README.md b/README.md index 3c4593f..d7d8758 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Wrenn Python SDK -Python client for the [Wrenn](https://wrenn.dev) microVM code execution platform. Create isolated capsules, execute commands, manage files, run interactive terminals, and execute persistent code — all from Python. +Python client for the [Wrenn](https://wrenn.dev) microVM platform. Create isolated capsules, execute commands, manage files, run interactive terminals, and execute persistent code -- all from Python. + +Designed as a drop-in replacement for [e2b](https://e2b.dev). If you're migrating, just swap your imports. ## Installation @@ -10,97 +12,144 @@ pip install wrenn Requires Python 3.13+. -## Quick Start - -```python -from wrenn import WrennClient - -client = WrennClient(api_key="wrn_your_api_key_here") - -# Create a capsule and run a command -with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60) - - result = cap.exec("echo", args=["hello world"]) - print(result.stdout) # "hello world" - print(result.exit_code) # 0 -``` - ## Authentication -The SDK supports two authentication methods: +Set the `WRENN_API_KEY` environment variable: -```python -# API key -client = WrennClient(api_key="wrn_...") - -# JWT token -client = WrennClient(token="eyJ...") +```bash +export WRENN_API_KEY="wrn_your_api_key_here" ``` -You can obtain an API key via the dashboard or create one programmatically: +Optionally override the API base URL: -```python -with WrennClient(token="jwt_token") as client: - key = client.api_keys.create(name="my-key") - print(key.key) # wrn_... +```bash +export WRENN_BASE_URL="https://app.wrenn.dev/api" # default ``` -## Capsules - -Capsules are isolated microVM environments. Create, manage, and interact with them: +You can also pass credentials directly: ```python -# Create -cap = client.capsules.create( - template="base-python", - vcpus=2, - memory_mb=1024, - timeout_sec=300, -) +from wrenn import Capsule -# List -for c in client.capsules.list(): - print(c.id, c.status) +capsule = Capsule(api_key="wrn_...", base_url="https://...") +``` -# Get -cap = client.capsules.get("cl-abc123") +--- -# Destroy -client.capsules.destroy("cl-abc123") +## Wrenn Capsules + +### Quick Start + +```python +from wrenn import Capsule + +# Create a capsule (reads WRENN_API_KEY from env) +with Capsule(template="minimal") as capsule: + result = capsule.commands.run("echo hello") + print(result.stdout) # "hello\n" +``` + +### Creating Capsules + +```python +from wrenn import Capsule + +# Direct construction (creates immediately) +capsule = Capsule() +capsule = Capsule(template="base-python", vcpus=2, memory_mb=1024, timeout=300) + +# With auto-wait (blocks until capsule is running) +capsule = Capsule(template="minimal", wait=True) + +# Via factory classmethod +capsule = Capsule.create(template="minimal", wait=True) ``` ### Context Manager -Use capsules as context managers for automatic cleanup: +Use capsules as context managers for automatic cleanup (destroys capsule on exit): ```python -with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60) - cap.exec("python -c 'print(42)'") -# cap.destroy() is called automatically +with Capsule(template="minimal", wait=True) as capsule: + capsule.commands.run("echo hello") +# capsule is automatically destroyed ``` -## Command Execution +### Connecting to Existing Capsules -### `exec()` — One-off Commands - -Starts a fresh process for each call. No state persists between calls. +Attach to a running capsule by ID. If it's paused, it will be resumed automatically: ```python -result = cap.exec("python", args=["-c", "import os; print(os.getcwd())"]) -print(result.stdout) # "/home/user\n" -print(result.stderr) # "" -print(result.exit_code) # 0 -print(result.duration_ms) # 42 +capsule = Capsule.connect("cl-abc123") +result = capsule.commands.run("echo still running") ``` -### `exec_stream()` — Streaming Output - -Stream real-time output from long-running commands: +For code interpreter capsules: ```python -for event in cap.exec_stream("python", args=["-u", "train.py"]): +from wrenn.code_interpreter import Capsule as CodeCapsule + +capsule = CodeCapsule.connect("cl-abc123") +result = capsule.run_code("print('reconnected')") +``` + +### Lifecycle Management + +```python +# Instance methods +capsule.pause() +capsule.resume() +capsule.destroy() +capsule.ping() # reset inactivity timer +capsule.wait_ready() # block until running + +info = capsule.get_info() +print(info.status) # "running" +print(capsule.is_running()) # True + +# Static methods (no instance needed) +Capsule.destroy("cl-abc123", api_key="wrn_...") +Capsule.pause("cl-abc123") +Capsule.resume("cl-abc123") +info = Capsule.get_info("cl-abc123") + +# List all capsules +capsules = Capsule.list() +``` + +### Command Execution + +Commands are accessed via `capsule.commands`: + +```python +# Foreground (blocks until complete) +result = capsule.commands.run("python -c 'print(42)'") +print(result.stdout) # "42\n" +print(result.stderr) # "" +print(result.exit_code) # 0 +print(result.duration_ms) # 35 + +# With options +result = capsule.commands.run( + "python train.py", + timeout=120, + envs={"CUDA_VISIBLE_DEVICES": "0"}, + cwd="/app", +) + +# Background process +handle = capsule.commands.run("python server.py", background=True) +print(handle.pid) # 1234 +print(handle.tag) # "exec-abc123" +``` + +#### Streaming Output + +```python +import sys + +# Stream a new command +for event in capsule.commands.stream("python", args=["-u", "train.py"]): match event.type: case "stdout": print(event.data, end="") @@ -108,77 +157,80 @@ for event in cap.exec_stream("python", args=["-u", "train.py"]): print(event.data, end="", file=sys.stderr) case "exit": print(f"\nExited with code {event.exit_code}") + +# Connect to a running background process +for event in capsule.commands.connect(handle.pid): + if event.type == "stdout": + print(event.data, end="") ``` -### `run_code()` — Stateful Code Execution - -Execute Python code in a persistent Jupyter kernel. Variables, imports, and function definitions survive across calls: +#### Process Management ```python -with client.capsules.create(template="python-interpreter-v0-beta") as cap: - cap.wait_ready(timeout=60) +# List running processes +for proc in capsule.commands.list(): + print(proc.pid, proc.cmd, proc.tag) - cap.run_code("x = 42") - r = cap.run_code("x * 2") - print(r.text) # "84" - - cap.run_code("def greet(name): return f'hello {name}'") - r = cap.run_code("greet('world')") - print(r.text) # "'hello world'" - - r = cap.run_code("1/0") - print(r.error) # "ZeroDivisionError: division by zero\n..." +# Kill a process +capsule.commands.kill(pid=1234) ``` -**`CodeResult` fields:** +### Filesystem -| Field | Type | Description | -|-------|------|-------------| -| `text` | `str \| None` | Plain text representation | -| `data` | `dict \| None` | Rich MIME bundle (e.g. `{"image/png": "..."}`) | -| `stdout` | `str` | Accumulated stdout | -| `stderr` | `str` | Accumulated stderr | -| `error` | `str \| None` | Error traceback string | - -## Filesystem - -Upload, download, and manage files inside capsules: +Files are accessed via `capsule.files`: ```python -# Upload / Download -cap.upload("/app/main.py", b"print('hello')") -content = cap.download("/app/main.py") +# Write and read files +capsule.files.write("/app/main.py", "print('hello')") +content = capsule.files.read("/app/main.py") # str +raw = capsule.files.read_bytes("/app/main.py") # bytes -# Streaming (for large files) +# Check existence +capsule.files.exists("/app/main.py") # True + +# List directory +entries = capsule.files.list("/home/user", depth=1) +for entry in entries: + print(entry.name, entry.type, entry.size) + +# Create directory +capsule.files.make_dir("/app/data") + +# Remove file or directory +capsule.files.remove("/app/old_data") +``` + +#### Streaming (Large Files) + +```python +# Streaming upload def chunks(): yield b"chunk1" yield b"chunk2" -cap.stream_upload("/data/large.bin", chunks()) -for chunk in cap.stream_download("/data/large.bin"): +capsule.files.upload_stream("/data/large.bin", chunks()) + +# Streaming download +for chunk in capsule.files.download_stream("/data/large.bin"): process(chunk) - -# Directory operations -entries = cap.list_dir("/home/user", depth=1) -for entry in entries: - print(entry.name, entry.type, entry.size) - -cap.mkdir("/home/user/data") -cap.remove("/home/user/old_data") ``` -## Interactive Terminal (PTY) - -Open a full interactive terminal session over WebSocket: +### Interactive Terminal (PTY) ```python -with cap.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term: +import sys + +with capsule.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term: term.write(b"ls -la\n") for event in term: if event.type == "output": sys.stdout.buffer.write(event.data) elif event.type == "exit": break + +# Reconnect to an existing session +with capsule.pty_connect(term.tag) as term: + term.write(b"echo reconnected\n") ``` **PtySession methods:** @@ -188,123 +240,169 @@ with cap.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term: | `write(data: bytes)` | Send raw bytes to stdin | | `resize(cols, rows)` | Resize the terminal | | `kill()` | Send SIGKILL to the process | -| `tag` | Session tag (available after `started` event) | -| `pid` | Process PID (available after `started` event) | +| `tag` | Session tag (after `started` event) | +| `pid` | Process PID (after `started` event) | -Reconnect to an existing session using the tag: +### Proxy URL + +Access services running inside a capsule: ```python -with cap.pty_connect(term.tag) as term: - term.write(b"echo reconnected\n") +url = capsule.get_url(8080) +# "wss://8080-cl-abc123.app.wrenn.dev" ``` -## Lifecycle +### Snapshots -Pause and resume capsules to save resources: +Create reusable templates from running capsules: ```python -cap = client.capsules.create(template="minimal") -cap.wait_ready(timeout=60) - -# Pause (snapshots and releases resources) -cap.pause() -print(cap.status) # "paused" - -# Resume (restores from snapshot) -cap.resume() -cap.wait_ready(timeout=60) +template = capsule.create_snapshot(name="my-template", overwrite=True) ``` -Keep a capsule alive with `ping()`: +--- + +## Code Interpreter + +The `wrenn.code_interpreter` module provides a specialized capsule for stateful code execution via a persistent Jupyter kernel. + +### Quick Start ```python -cap.ping() # Resets the inactivity timer +from wrenn.code_interpreter import Capsule + +with Capsule(wait=True) as capsule: + result = capsule.run_code("print('hello')") + print(result.text) # "hello" ``` -## Proxy URL +### Stateful Execution -Access services running inside a capsule through the proxy: +Variables, imports, and function definitions persist across `run_code` calls: ```python -url = cap.get_url(8888) -# "wss://8888-cl-abc123.api.wrenn.dev" +from wrenn.code_interpreter import Capsule -# Pre-configured HTTP client targeting port 8888 -resp = cap.http_client.get("/api/kernels") +with Capsule(wait=True) as capsule: + capsule.run_code("x = 42") + result = capsule.run_code("x * 2") + print(result.text) # "84" + + capsule.run_code("import math") + result = capsule.run_code("math.pi") + print(result.text) # "3.141592653589793" + + capsule.run_code("def greet(name): return f'hello {name}'") + result = capsule.run_code("greet('world')") + print(result.text) # "hello world" ``` -## Snapshots +The `text` field returns the expression result when available. For `print()` calls (which produce no expression result), it falls back to the stripped stdout output. -Create templates from running capsules: +### Error Handling in Code ```python -# Create a snapshot -template = client.snapshots.create( - capsule_id="cl-abc123", - name="my-template", - overwrite=True, -) - -# List templates -for t in client.snapshots.list(): - print(t.name, t.type) - -# Delete -client.snapshots.delete("my-template") +result = capsule.run_code("1 / 0") +print(result.error) # "ZeroDivisionError: division by zero\n..." ``` -## Hosts - -Manage host machines: +### Rich Output ```python -host = client.hosts.create(type="regular") -client.hosts.list() -client.hosts.get("h-1") -client.hosts.delete("h-1") -client.hosts.regenerate_token("h-1") -client.hosts.list_tags("h-1") -client.hosts.add_tag("h-1", "gpu") -client.hosts.remove_tag("h-1", "gpu") +result = capsule.run_code(""" +import matplotlib.pyplot as plt +plt.plot([1, 2, 3]) +plt.savefig('/tmp/plot.png') +plt.show() +""") +print(result.data) # {"image/png": "base64...", "text/plain": "..."} ``` +### Custom Templates + +By default, `code-runner-beta` template is used. You can specify a custom template: + +```python +capsule = Capsule(template="my-custom-jupyter-template", wait=True) +result = capsule.run_code("print('running on custom template')") +``` + +### CodeResult Fields + +| Field | Type | Description | +|-------|------|-------------| +| `text` | `str \| None` | Expression result, or stripped stdout if no expression result | +| `data` | `dict \| None` | Rich MIME bundle (e.g. `{"image/png": "..."}`) | +| `stdout` | `str` | Raw accumulated stdout output | +| `stderr` | `str` | Raw accumulated stderr output | +| `error` | `str \| None` | Error traceback string | + +String expression results have quotes stripped automatically (e.g. `'hello'` becomes `hello`). + +### Code Interpreter + Commands/Files + +The code interpreter capsule inherits all standard capsule features: + +```python +from wrenn.code_interpreter import Capsule + +with Capsule(wait=True) as capsule: + # Use run_code for Jupyter execution + capsule.run_code("import pandas as pd; df = pd.DataFrame({'a': [1,2,3]})") + capsule.run_code("df.to_csv('/tmp/data.csv', index=False)") + + # Use standard file operations + content = capsule.files.read("/tmp/data.csv") + print(content) + + # Use standard command execution + result = capsule.commands.run("wc -l /tmp/data.csv") + print(result.stdout) +``` + +--- + ## Async Support -All operations have async variants. Use `AsyncWrennClient` and prefix capsule methods with `async_`: +All operations have async variants via `AsyncCapsule`: + +### Async Capsule ```python -from wrenn import AsyncWrennClient +from wrenn import AsyncCapsule -async with AsyncWrennClient(api_key="wrn_...") as client: - cap = await client.capsules.create(template="minimal") - await cap.async_wait_ready(timeout=60) +async with await AsyncCapsule.create(template="minimal", wait=True) as capsule: + result = await capsule.commands.run("echo hello") + print(result.stdout) - result = await cap.async_exec("echo", args=["hello"]) - await cap.async_upload("/app/file.txt", b"data") - entries = await cap.async_list_dir("/home/user") - r = await cap.async_run_code("42 * 2") + await capsule.files.write("/app/file.txt", "data") + entries = await capsule.files.list("/app") - await cap.async_destroy() + await capsule.pause() + await capsule.resume() ``` -**Async method mapping:** +### Async Code Interpreter -| Sync | Async | -|------|-------| -| `exec()` | `async_exec()` | -| `upload()` | `async_upload()` | -| `download()` | `async_download()` | -| `stream_upload()` | `async_stream_upload()` | -| `stream_download()` | `async_stream_download()` | -| `list_dir()` | `async_list_dir()` | -| `mkdir()` | `async_mkdir()` | -| `remove()` | `async_remove()` | -| `wait_ready()` | `async_wait_ready()` | -| `pause()` | `async_pause()` | -| `resume()` | `async_resume()` | -| `destroy()` | `async_destroy()` | -| `ping()` | `async_ping()` | -| `run_code()` | `async_run_code()` | +```python +from wrenn.code_interpreter import AsyncCapsule + +async with await AsyncCapsule.create(wait=True) as capsule: + result = await capsule.run_code("2 + 2") + print(result.text) # "4" +``` + +### Async PTY + +```python +async with capsule.pty(cmd="/bin/bash") as term: + await term.write(b"ls -la\n") + async for event in term: + if event.type == "output": + sys.stdout.buffer.write(event.data) +``` + +--- ## Error Handling @@ -318,14 +416,14 @@ from wrenn import ( WrennForbiddenError, # 403 WrennNotFoundError, # 404 WrennConflictError, # 409 - WrennHostHasCapsulesError, # 409 — host has running capsules + WrennHostHasCapsulesError, # 409 (host has running capsules) WrennAgentError, # 502 WrennInternalError, # 500 WrennHostUnavailableError, # 503 ) try: - client.capsules.get("nonexistent") + Capsule.get_info("nonexistent") except WrennNotFoundError as e: print(e.code) # "not_found" print(e.message) # "capsule not found" @@ -334,6 +432,67 @@ except WrennNotFoundError as e: All exceptions inherit from `WrennError` and expose `.code`, `.message`, and `.status_code`. +--- + +## Migrating from e2b + +Replace your imports: + +```python +# Before +from e2b import Sandbox +sandbox = Sandbox() + +# After +from wrenn import Capsule +capsule = Capsule() +``` + +For code interpreter: + +```python +# Before +from e2b_code_interpreter import Sandbox +sandbox = Sandbox() +result = sandbox.run_code("print('hello')") + +# After +from wrenn.code_interpreter import Capsule +capsule = Capsule() +result = capsule.run_code("print('hello')") +``` + +The `Sandbox` name is available as a deprecated alias in both modules: + +```python +from wrenn import Sandbox # works, emits FutureWarning +from wrenn.code_interpreter import Sandbox # works, emits FutureWarning +``` + +--- + +## Low-Level Client + +For direct API access, use `WrennClient` / `AsyncWrennClient`: + +```python +from wrenn import WrennClient + +with WrennClient(api_key="wrn_...") as client: + capsule = client.capsules.create(template="minimal") + client.capsules.pause(capsule.id) + client.capsules.resume(capsule.id) + client.capsules.ping(capsule.id) + client.capsules.destroy(capsule.id) + + # Snapshots + template = client.snapshots.create(capsule_id="cl-abc", name="my-snap") + templates = client.snapshots.list() + client.snapshots.delete("my-snap") +``` + +--- + ## Development This project uses [uv](https://docs.astral.sh/uv/) for dependency management. @@ -350,14 +509,11 @@ make test # Run all tests (including integration) make test-integration - -# Regenerate models from OpenAPI spec -make generate ``` ### Running Integration Tests -Integration tests require a live Wrenn server. Set environment variables: +Integration tests require a live Wrenn server: ```bash export WRENN_API_KEY="wrn_..." diff --git a/src/wrenn/async_capsule.py b/src/wrenn/async_capsule.py index e99a5b2..d4bfb4b 100644 --- a/src/wrenn/async_capsule.py +++ b/src/wrenn/async_capsule.py @@ -63,6 +63,7 @@ class AsyncCapsule: memory_mb: int | None = None, timeout: int | None = None, *, + wait: bool = False, api_key: str | None = None, base_url: str | None = None, ) -> AsyncCapsule: @@ -74,11 +75,14 @@ class AsyncCapsule: memory_mb=memory_mb, timeout_sec=timeout, ) - return cls( + capsule = cls( _capsule_id=info.id, _client=client, _info=info, ) + if wait: + await capsule.wait_ready() + return capsule @classmethod async def connect( @@ -103,16 +107,16 @@ class AsyncCapsule: # ── Dual instance/static lifecycle ────────────────────────── - kill = _DualMethod("_instance_kill", "_static_kill") + destroy = _DualMethod("_instance_destroy", "_static_destroy") pause = _DualMethod("_instance_pause", "_static_pause") resume = _DualMethod("_instance_resume", "_static_resume") get_info = _DualMethod("_instance_get_info", "_static_get_info") - async def _instance_kill(self) -> None: + async def _instance_destroy(self) -> None: await self._client.capsules.destroy(self._id) @classmethod - async def _static_kill( + async def _static_destroy( cls, capsule_id: str, *, @@ -260,7 +264,7 @@ class AsyncCapsule: exc_tb: object, ) -> None: try: - await self._instance_kill() + await self._instance_destroy() except Exception: pass try: diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py index ba77e71..62eddd1 100644 --- a/src/wrenn/capsule.py +++ b/src/wrenn/capsule.py @@ -66,6 +66,7 @@ class Capsule: memory_mb: int | None = None, timeout: int | None = None, *, + wait: bool = False, api_key: str | None = None, base_url: str | None = None, # Private: used by classmethods to skip creation @@ -93,6 +94,9 @@ class Capsule: self.commands = Commands(self._id, self._client.http) self.files = Files(self._id, self._client.http) + if wait: + self.wait_ready() + # ── Properties ────────────────────────────────────────────── @property @@ -113,6 +117,7 @@ class Capsule: memory_mb: int | None = None, timeout: int | None = None, *, + wait: bool = False, api_key: str | None = None, base_url: str | None = None, ) -> Capsule: @@ -122,6 +127,7 @@ class Capsule: vcpus=vcpus, memory_mb=memory_mb, timeout=timeout, + wait=wait, api_key=api_key, base_url=base_url, ) @@ -149,17 +155,17 @@ class Capsule: # ── Dual instance/static lifecycle ────────────────────────── - kill = _DualMethod("_instance_kill", "_static_kill") + destroy = _DualMethod("_instance_destroy", "_static_destroy") pause = _DualMethod("_instance_pause", "_static_pause") resume = _DualMethod("_instance_resume", "_static_resume") get_info = _DualMethod("_instance_get_info", "_static_get_info") - def _instance_kill(self) -> None: + def _instance_destroy(self) -> None: """Destroy this capsule.""" self._client.capsules.destroy(self._id) @classmethod - def _static_kill( + def _static_destroy( cls, capsule_id: str, *, @@ -321,7 +327,7 @@ class Capsule: exc_tb: object, ) -> None: try: - self._instance_kill() + self._instance_destroy() except Exception: pass try: diff --git a/src/wrenn/code_interpreter/__init__.py b/src/wrenn/code_interpreter/__init__.py index cb08537..137dc17 100644 --- a/src/wrenn/code_interpreter/__init__.py +++ b/src/wrenn/code_interpreter/__init__.py @@ -1,8 +1,26 @@ -from wrenn.code_interpreter.capsule import Capsule, CodeResult from wrenn.code_interpreter.async_capsule import AsyncCapsule +from wrenn.code_interpreter.capsule import Capsule, CodeResult __all__ = [ "AsyncCapsule", "Capsule", "CodeResult", + "Sandbox", ] + + +def __getattr__(name: str) -> type: + import sys + import warnings + + _module = sys.modules[__name__] + + if name == "Sandbox": + warnings.warn( + "'Sandbox' is deprecated, use 'Capsule' instead", + FutureWarning, + stacklevel=2, + ) + setattr(_module, name, Capsule) + return Capsule + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/wrenn/code_interpreter/async_capsule.py b/src/wrenn/code_interpreter/async_capsule.py index 715980f..090b21c 100644 --- a/src/wrenn/code_interpreter/async_capsule.py +++ b/src/wrenn/code_interpreter/async_capsule.py @@ -41,6 +41,7 @@ class AsyncCapsule(BaseAsyncCapsule): memory_mb: int | None = None, timeout: int | None = None, *, + wait: bool = False, api_key: str | None = None, base_url: str | None = None, ) -> AsyncCapsule: @@ -51,11 +52,14 @@ class AsyncCapsule(BaseAsyncCapsule): memory_mb=memory_mb, timeout_sec=timeout, ) - return cls( + capsule = cls( _capsule_id=info.id, _client=client, _info=info, ) + if wait: + await capsule.wait_ready() + return capsule def _get_proxy_client(self) -> httpx.AsyncClient: if self._proxy_client is None: @@ -80,11 +84,20 @@ class AsyncCapsule(BaseAsyncCapsule): while time.monotonic() < deadline: try: - resp = await client.post("/api/kernels") + # Try to reuse an existing kernel + resp = await client.get("/api/kernels") if resp.status_code < 500: resp.raise_for_status() - self._kernel_id = resp.json()["id"] - return self._kernel_id + kernels = resp.json() + if kernels: + self._kernel_id = kernels[0]["id"] + return self._kernel_id + # No existing kernels, create a new one + resp = await client.post("/api/kernels") + if resp.status_code < 500: + resp.raise_for_status() + self._kernel_id = resp.json()["id"] + return self._kernel_id last_exc = httpx.HTTPStatusError( f"Jupyter returned {resp.status_code}", request=resp.request, @@ -180,7 +193,13 @@ class AsyncCapsule(BaseAsyncCapsule): result.stdout += content.get("text", "") elif msg_type == "execute_result": bundle = content.get("data", {}) - result.text = bundle.get("text/plain") + text = bundle.get("text/plain") + if text and ( + (text.startswith("'") and text.endswith("'")) + or (text.startswith('"') and text.endswith('"')) + ): + text = text[1:-1] + result.text = text result.data = bundle elif msg_type == "error": traceback = content.get("traceback", []) @@ -188,6 +207,9 @@ class AsyncCapsule(BaseAsyncCapsule): elif msg_type == "status" and content.get("execution_state") == "idle": break + if result.text is None and result.stdout: + result.text = result.stdout.strip() + return result async def __aexit__(self, *args) -> None: diff --git a/src/wrenn/code_interpreter/capsule.py b/src/wrenn/code_interpreter/capsule.py index d92f1c3..e92f72a 100644 --- a/src/wrenn/code_interpreter/capsule.py +++ b/src/wrenn/code_interpreter/capsule.py @@ -80,6 +80,7 @@ class Capsule(BaseCapsule): memory_mb: int | None = None, timeout: int | None = None, *, + wait: bool = False, api_key: str | None = None, base_url: str | None = None, ) -> Capsule: @@ -88,6 +89,7 @@ class Capsule(BaseCapsule): vcpus=vcpus, memory_mb=memory_mb, timeout=timeout, + wait=wait, api_key=api_key, base_url=base_url, ) @@ -115,11 +117,20 @@ class Capsule(BaseCapsule): while time.monotonic() < deadline: try: - resp = client.post("/api/kernels") + # Try to reuse an existing kernel + resp = client.get("/api/kernels") if resp.status_code < 500: resp.raise_for_status() - self._kernel_id = resp.json()["id"] - return self._kernel_id + kernels = resp.json() + if kernels: + self._kernel_id = kernels[0]["id"] + return self._kernel_id + # No existing kernels, create a new one + resp = client.post("/api/kernels") + if resp.status_code < 500: + resp.raise_for_status() + self._kernel_id = resp.json()["id"] + return self._kernel_id last_exc = httpx.HTTPStatusError( f"Jupyter returned {resp.status_code}", request=resp.request, @@ -225,7 +236,13 @@ class Capsule(BaseCapsule): result.stdout += content.get("text", "") elif msg_type == "execute_result": bundle = content.get("data", {}) - result.text = bundle.get("text/plain") + text = bundle.get("text/plain") + if text and ( + (text.startswith("'") and text.endswith("'")) + or (text.startswith('"') and text.endswith('"')) + ): + text = text[1:-1] + result.text = text result.data = bundle elif msg_type == "error": traceback = content.get("traceback", []) @@ -233,6 +250,9 @@ class Capsule(BaseCapsule): elif msg_type == "status" and content.get("execution_state") == "idle": break + if result.text is None and result.stdout: + result.text = result.stdout.strip() + return result def __exit__(self, *args) -> None: diff --git a/tests/test_capsule_features.py b/tests/test_capsule_features.py index 136b824..54f280f 100644 --- a/tests/test_capsule_features.py +++ b/tests/test_capsule_features.py @@ -68,9 +68,9 @@ class TestCapsuleCreate: class TestCapsuleStaticMethods: @respx.mock - def test_static_kill(self): + def test_static_destroy(self): route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204) - Capsule._static_kill("cl-1", api_key="wrn_test1234567890abcdef12345678") + Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678") assert route.called @respx.mock -- 2.49.0 From 7b9a06d1b506fdd5de0f491fe9e7fdfb941225ab Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 15 Apr 2026 21:33:53 +0600 Subject: [PATCH 11/44] chore: add python-dotenv dependency Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 1 + uv.lock | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 839941f..0f51113 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "httpx>=0.28.1", "httpx-ws>=0.9.0", "pydantic>=2.12.5", + "python-dotenv>=1.2.2", ] [build-system] diff --git a/uv.lock b/uv.lock index 985de91..36827e6 100644 --- a/uv.lock +++ b/uv.lock @@ -546,6 +546,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "pytokens" version = "0.4.1" @@ -685,6 +694,7 @@ dependencies = [ { name = "httpx" }, { name = "httpx-ws" }, { name = "pydantic" }, + { name = "python-dotenv" }, ] [package.dev-dependencies] @@ -703,6 +713,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "httpx-ws", specifier = ">=0.9.0" }, { name = "pydantic", specifier = ">=2.12.5" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, ] [package.metadata.requires-dev] -- 2.49.0 From 3f97c73b2f303a7b77c91920eea8f7617ce18709 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Fri, 17 Apr 2026 03:13:16 +0600 Subject: [PATCH 12/44] feat: redesign code interpreter with structured Execution model 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 --- .gitignore | 2 + CLAUDE.md | 132 ++++++++ Makefile | 2 +- README.md | 44 ++- api/openapi.yaml | 327 +++++++++++++++++++- src/wrenn/code_interpreter/__init__.py | 13 +- src/wrenn/code_interpreter/async_capsule.py | 85 +++-- src/wrenn/code_interpreter/capsule.py | 98 +++--- src/wrenn/code_interpreter/models.py | 156 ++++++++++ src/wrenn/models/_generated.py | 37 ++- tests/test_capsule_features.py | 75 +++-- 11 files changed, 863 insertions(+), 108 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/wrenn/code_interpreter/models.py diff --git a/.gitignore b/.gitignore index 23b2ad4..a8714f9 100644 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,5 @@ cython_debug/ .pypirc CODE_EXECUTION.md + +docs/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cc00331 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,132 @@ +## 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. \ No newline at end of file diff --git a/Makefile b/Makefile index 7720026..51535c3 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ .PHONY: generate lint test check test-integration # Variables -SPEC_URL = "https://git.omukk.dev/wrenn/wrenn/raw/branch/dev/internal/api/openapi.yaml" +SPEC_URL = "https://git.omukk.dev/wrenn/wrenn/raw/branch/main/internal/api/openapi.yaml" SPEC_PATH = "api/openapi.yaml" generate: diff --git a/README.md b/README.md index d7d8758..8fc6cf6 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ from wrenn.code_interpreter import Capsule with Capsule(wait=True) as capsule: result = capsule.run_code("print('hello')") - print(result.text) # "hello" + print("".join(result.logs.stdout)) # "hello\n" ``` ### Stateful Execution @@ -297,25 +297,43 @@ with Capsule(wait=True) as capsule: print(result.text) # "hello world" ``` -The `text` field returns the expression result when available. For `print()` calls (which produce no expression result), it falls back to the stripped stdout output. +The `text` property returns the `text/plain` value of the main `execute_result` (the last expression in the cell). Printed output goes to `result.logs.stdout` instead. ### Error Handling in Code ```python result = capsule.run_code("1 / 0") -print(result.error) # "ZeroDivisionError: division by zero\n..." +print(result.error.name) # "ZeroDivisionError" +print(result.error.value) # "division by zero" +print(result.error.traceback) # full traceback string ``` ### Rich Output +Each call to `display()`, `plt.show()`, or similar produces a `Result` in `execution.results`. Known MIME types are unpacked into named fields: + ```python result = capsule.run_code(""" import matplotlib.pyplot as plt plt.plot([1, 2, 3]) -plt.savefig('/tmp/plot.png') plt.show() """) -print(result.data) # {"image/png": "base64...", "text/plain": "..."} +for r in result.results: + if r.png: + print(f"Got PNG image ({len(r.png)} bytes base64)") + print(r.formats()) # e.g. ["text", "png"] +``` + +### Streaming Callbacks + +```python +capsule.run_code( + code, + on_result=lambda r: print("result:", r.formats()), + on_stdout=lambda text: print("stdout:", text), + on_stderr=lambda text: print("stderr:", text), + on_error=lambda err: print(f"error: {err.name}: {err.value}"), +) ``` ### Custom Templates @@ -327,17 +345,19 @@ capsule = Capsule(template="my-custom-jupyter-template", wait=True) result = capsule.run_code("print('running on custom template')") ``` -### CodeResult Fields +### Execution Model + +`run_code()` returns an `Execution` object: | Field | Type | Description | |-------|------|-------------| -| `text` | `str \| None` | Expression result, or stripped stdout if no expression result | -| `data` | `dict \| None` | Rich MIME bundle (e.g. `{"image/png": "..."}`) | -| `stdout` | `str` | Raw accumulated stdout output | -| `stderr` | `str` | Raw accumulated stderr output | -| `error` | `str \| None` | Error traceback string | +| `results` | `list[Result]` | All rich outputs (charts, images, expression values) | +| `logs` | `Logs` | `.stdout: list[str]` and `.stderr: list[str]` chunks | +| `error` | `ExecutionError \| None` | `.name`, `.value`, `.traceback` | +| `execution_count` | `int \| None` | Jupyter cell execution counter | +| `text` | `str \| None` | (property) `text/plain` of the main `execute_result` | -String expression results have quotes stripped automatically (e.g. `'hello'` becomes `hello`). +Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `pdf`, `latex`, `json`, `javascript`, plus `extra` for unknown types. String expression results have quotes stripped automatically. ### Code Interpreter + Commands/Files diff --git a/api/openapi.yaml b/api/openapi.yaml index 031cefd..f4c369d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -16,6 +16,10 @@ paths: summary: Create a new account operationId: signup tags: [auth] + description: | + Creates an inactive user account and sends an activation email. + The user must activate their account within 30 minutes. + Does not return a JWT — the user must activate first, then sign in. requestBody: required: true content: @@ -24,11 +28,11 @@ paths: $ref: "#/components/schemas/SignupRequest" responses: "201": - description: Account created + description: Account created, activation email sent content: application/json: schema: - $ref: "#/components/schemas/AuthResponse" + $ref: "#/components/schemas/SignupResponse" "400": description: Invalid request (bad email, short password) content: @@ -36,7 +40,39 @@ paths: schema: $ref: "#/components/schemas/Error" "409": - description: Email already registered + description: Email already registered or signup cooldown active + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/auth/activate: + post: + summary: Activate account via email token + operationId: activate + tags: [auth] + description: | + Consumes the activation token sent via email and activates the user account. + Creates a default team and returns a JWT to log the user in. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: + type: string + responses: + "200": + description: Account activated, JWT issued + content: + application/json: + schema: + $ref: "#/components/schemas/AuthResponse" + "400": + description: Invalid or expired token content: application/json: schema: @@ -175,6 +211,252 @@ paths: "302": description: Redirect to frontend with token or error + /v1/me: + get: + summary: Get current user profile + operationId: getMe + tags: [account] + security: + - bearerAuth: [] + responses: + "200": + description: User profile + content: + application/json: + schema: + $ref: "#/components/schemas/MeResponse" + + patch: + summary: Update display name + operationId: updateName + tags: [account] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + minLength: 1 + maxLength: 100 + responses: + "200": + description: Name updated, new JWT issued + content: + application/json: + schema: + $ref: "#/components/schemas/AuthResponse" + "400": + description: Invalid name + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + delete: + summary: Delete current account + operationId: deleteAccount + tags: [account] + security: + - bearerAuth: [] + description: | + Soft-deletes the account (sets status=deleted, deleted_at=now). + The account is permanently removed after 15 days. Blocked if the user + owns any team that has other members. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [confirmation] + properties: + confirmation: + type: string + description: Must match the user's email address (case-insensitive) + responses: + "204": + description: Account scheduled for deletion + "400": + description: Confirmation does not match email + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: User owns teams with other members + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/me/password: + post: + summary: Change or add password + operationId: changePassword + tags: [account] + security: + - bearerAuth: [] + description: | + For users with an existing password: requires `current_password` and `new_password`. + For OAuth-only users adding a password: requires `new_password` and `confirm_password`. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ChangePasswordRequest" + responses: + "204": + description: Password updated + "400": + description: Invalid request (short password, mismatch, etc.) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Current password is incorrect + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/me/password/reset: + post: + summary: Request a password reset email + operationId: requestPasswordReset + tags: [account] + description: | + Sends a password reset link to the given email. Always returns 200 + regardless of whether the email exists, to prevent account enumeration. + The reset token expires in 15 minutes. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: + type: string + format: email + responses: + "204": + description: Request accepted (email sent if account exists) + + /v1/me/password/reset/confirm: + post: + summary: Confirm password reset + operationId: confirmPasswordReset + tags: [account] + description: | + Consumes a password reset token and sets a new password. The token is + single-use and expires after 15 minutes. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token, new_password] + properties: + token: + type: string + description: Raw reset token from the email link + new_password: + type: string + minLength: 8 + responses: + "204": + description: Password reset successful + "400": + description: Invalid or expired token, or password too short + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/me/providers/{provider}/connect: + parameters: + - name: provider + in: path + required: true + schema: + type: string + enum: [github] + description: OAuth provider name + + get: + summary: Initiate OAuth provider link + operationId: connectProvider + tags: [account] + security: + - bearerAuth: [] + description: | + Sets OAuth state and link cookies, then returns the provider's + authorization URL. The frontend navigates to this URL to start the + OAuth flow. On callback, the provider is linked to the current account + (not a new registration). + responses: + "200": + description: Authorization URL + content: + application/json: + schema: + type: object + properties: + auth_url: + type: string + format: uri + "404": + description: Provider not found or not configured + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/me/providers/{provider}: + parameters: + - name: provider + in: path + required: true + schema: + type: string + enum: [github] + description: OAuth provider name + + delete: + summary: Disconnect an OAuth provider + operationId: disconnectProvider + tags: [account] + security: + - bearerAuth: [] + description: | + Unlinks the OAuth provider from the current account. Blocked if this + is the user's only login method (no password and no other providers). + responses: + "204": + description: Provider disconnected + "400": + description: Cannot disconnect last login method + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Provider not connected + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/api-keys: post: summary: Create an API key @@ -1386,7 +1668,6 @@ paths: PTY data (input and output) is base64-encoded because it contains raw terminal bytes (escape sequences, control codes) that are not valid UTF-8. - Sessions have a 120-second inactivity timeout (reset on input/resize). Sessions persist across WebSocket disconnections — the process keeps running in the capsule. Use the `tag` from the "started" response to reconnect later. @@ -2078,6 +2359,13 @@ components: password: type: string + SignupResponse: + type: object + properties: + message: + type: string + description: Confirmation message instructing user to check email + AuthResponse: type: object properties: @@ -2781,6 +3069,37 @@ components: nullable: true description: Webhook secret. Only returned on creation, never again. + MeResponse: + type: object + properties: + name: + type: string + email: + type: string + format: email + has_password: + type: boolean + description: Whether the user has a password set (false for OAuth-only accounts) + providers: + type: array + items: + type: string + description: List of linked OAuth provider names (e.g. ["github"]) + + ChangePasswordRequest: + type: object + required: [new_password] + properties: + current_password: + type: string + description: Required when changing an existing password + new_password: + type: string + minLength: 8 + confirm_password: + type: string + description: Required when adding a password to an OAuth-only account (must match new_password) + Error: type: object properties: diff --git a/src/wrenn/code_interpreter/__init__.py b/src/wrenn/code_interpreter/__init__.py index 137dc17..9818204 100644 --- a/src/wrenn/code_interpreter/__init__.py +++ b/src/wrenn/code_interpreter/__init__.py @@ -1,10 +1,19 @@ from wrenn.code_interpreter.async_capsule import AsyncCapsule -from wrenn.code_interpreter.capsule import Capsule, CodeResult +from wrenn.code_interpreter.capsule import Capsule +from wrenn.code_interpreter.models import ( + Execution, + ExecutionError, + Logs, + Result, +) __all__ = [ "AsyncCapsule", "Capsule", - "CodeResult", + "Execution", + "ExecutionError", + "Logs", + "Result", "Sandbox", ] diff --git a/src/wrenn/code_interpreter/async_capsule.py b/src/wrenn/code_interpreter/async_capsule.py index 090b21c..885136d 100644 --- a/src/wrenn/code_interpreter/async_capsule.py +++ b/src/wrenn/code_interpreter/async_capsule.py @@ -4,6 +4,8 @@ import asyncio import json import time import uuid +from collections.abc import Callable +from typing import Any import httpx import httpx_ws @@ -11,7 +13,13 @@ import httpx_ws from wrenn.async_capsule import AsyncCapsule as BaseAsyncCapsule from wrenn.capsule import _build_proxy_url from wrenn.client import AsyncWrennClient -from wrenn.code_interpreter.capsule import CodeResult, DEFAULT_TEMPLATE +from wrenn.code_interpreter.capsule import DEFAULT_TEMPLATE +from wrenn.code_interpreter.models import ( + Execution, + ExecutionError, + Logs, + Result, +) class AsyncCapsule(BaseAsyncCapsule): @@ -151,15 +159,36 @@ class AsyncCapsule(BaseAsyncCapsule): language: str = "python", timeout: float = 30, jupyter_timeout: float = 30, - ) -> CodeResult: - """Execute code in a persistent Jupyter kernel (async).""" + on_result: Callable[[Result], Any] | None = None, + on_stdout: Callable[[str], Any] | None = None, + on_stderr: Callable[[str], Any] | None = None, + on_error: Callable[[ExecutionError], Any] | None = None, + ) -> Execution: + """Execute code in a persistent Jupyter kernel (async). + + Args: + code: Code string to execute. + language: Execution backend language. Currently only ``"python"``. + timeout: Maximum seconds to wait for execution to complete. + jupyter_timeout: Maximum seconds to wait for Jupyter to become + available. + on_result: Called for each rich output (charts, images, expression + values). + on_stdout: Called for each stdout chunk. + on_stderr: Called for each stderr chunk. + on_error: Called when the cell raises an exception. + + Returns: + An :class:`Execution` with ``.results``, ``.logs``, ``.error``, + and a convenience ``.text`` property. + """ kernel_id = await self._ensure_kernel(jupyter_timeout=jupyter_timeout) ws_url = self._jupyter_ws_url(kernel_id) msg = self._jupyter_execute_request(code) msg_id = msg["msg_id"] - result = CodeResult() + execution = Execution() deadline = time.monotonic() + timeout headers = {"X-API-Key": self._client._api_key} @@ -186,31 +215,43 @@ class AsyncCapsule(BaseAsyncCapsule): content = data.get("content", {}) if msg_type == "stream": + text = content.get("text", "") name = content.get("name", "stdout") if name == "stderr": - result.stderr += content.get("text", "") + execution.logs.stderr.append(text) + if on_stderr is not None: + on_stderr(text) else: - result.stdout += content.get("text", "") - elif msg_type == "execute_result": + execution.logs.stdout.append(text) + if on_stdout is not None: + on_stdout(text) + elif msg_type in ("execute_result", "display_data"): bundle = content.get("data", {}) - text = bundle.get("text/plain") - if text and ( - (text.startswith("'") and text.endswith("'")) - or (text.startswith('"') and text.endswith('"')) - ): - text = text[1:-1] - result.text = text - result.data = bundle + is_main = msg_type == "execute_result" + result = Result.from_bundle(bundle, is_main_result=is_main) + execution.results.append(result) + if is_main: + execution.execution_count = content.get( + "execution_count" + ) + if on_result is not None: + on_result(result) elif msg_type == "error": - traceback = content.get("traceback", []) - result.error = "\n".join(traceback) - elif msg_type == "status" and content.get("execution_state") == "idle": + err = ExecutionError( + name=content.get("ename", ""), + value=content.get("evalue", ""), + traceback="\n".join(content.get("traceback", [])), + ) + execution.error = err + if on_error is not None: + on_error(err) + elif ( + msg_type == "status" + and content.get("execution_state") == "idle" + ): break - if result.text is None and result.stdout: - result.text = result.stdout.strip() - - return result + return execution async def __aexit__(self, *args) -> None: if self._proxy_client is not None: diff --git a/src/wrenn/code_interpreter/capsule.py b/src/wrenn/code_interpreter/capsule.py index e92f72a..0e732e8 100644 --- a/src/wrenn/code_interpreter/capsule.py +++ b/src/wrenn/code_interpreter/capsule.py @@ -3,37 +3,24 @@ from __future__ import annotations import json import time import uuid -from dataclasses import dataclass +from collections.abc import Callable +from typing import Any import httpx import httpx_ws from wrenn.capsule import Capsule as BaseCapsule from wrenn.capsule import _build_proxy_url - +from wrenn.code_interpreter.models import ( + Execution, + ExecutionError, + Logs, + Result, +) DEFAULT_TEMPLATE = "code-runner-beta" -@dataclass -class CodeResult: - """Result from stateful code execution. - - Attributes: - text: text/plain representation of the result. - data: rich MIME bundle (e.g. ``{"image/png": "..."}``). - stdout: accumulated stdout output. - stderr: accumulated stderr output. - error: language-specific error/traceback string. - """ - - text: str | None = None - data: dict[str, str] | None = None - stdout: str = "" - stderr: str = "" - error: str | None = None - - class Capsule(BaseCapsule): """Code interpreter capsule with ``run_code`` support. @@ -43,7 +30,7 @@ class Capsule(BaseCapsule): capsule = Capsule() result = capsule.run_code("print('hello')") - print(result.stdout) # "hello\\n" + print(result.logs.stdout) # ["hello\\n"] """ _kernel_id: str | None @@ -184,7 +171,11 @@ class Capsule(BaseCapsule): language: str = "python", timeout: float = 30, jupyter_timeout: float = 30, - ) -> CodeResult: + on_result: Callable[[Result], Any] | None = None, + on_stdout: Callable[[str], Any] | None = None, + on_stderr: Callable[[str], Any] | None = None, + on_error: Callable[[ExecutionError], Any] | None = None, + ) -> Execution: """Execute code in a persistent Jupyter kernel. Variables, imports, and function definitions survive across calls. @@ -193,10 +184,17 @@ class Capsule(BaseCapsule): code: Code string to execute. language: Execution backend language. Currently only ``"python"``. timeout: Maximum seconds to wait for execution to complete. - jupyter_timeout: Maximum seconds to wait for Jupyter to become available. + jupyter_timeout: Maximum seconds to wait for Jupyter to become + available. + on_result: Called for each rich output (charts, images, expression + values). + on_stdout: Called for each stdout chunk. + on_stderr: Called for each stderr chunk. + on_error: Called when the cell raises an exception. Returns: - A ``CodeResult`` with ``.text``, ``.data``, ``.stdout``, ``.stderr``, ``.error``. + An :class:`Execution` with ``.results``, ``.logs``, ``.error``, + and a convenience ``.text`` property. """ kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout) ws_url = self._jupyter_ws_url(kernel_id) @@ -204,7 +202,7 @@ class Capsule(BaseCapsule): msg = self._jupyter_execute_request(code) msg_id = msg["msg_id"] - result = CodeResult() + execution = Execution() deadline = time.monotonic() + timeout headers = {"X-API-Key": self._client._api_key} @@ -229,31 +227,43 @@ class Capsule(BaseCapsule): content = data.get("content", {}) if msg_type == "stream": + text = content.get("text", "") name = content.get("name", "stdout") if name == "stderr": - result.stderr += content.get("text", "") + execution.logs.stderr.append(text) + if on_stderr is not None: + on_stderr(text) else: - result.stdout += content.get("text", "") - elif msg_type == "execute_result": + execution.logs.stdout.append(text) + if on_stdout is not None: + on_stdout(text) + elif msg_type in ("execute_result", "display_data"): bundle = content.get("data", {}) - text = bundle.get("text/plain") - if text and ( - (text.startswith("'") and text.endswith("'")) - or (text.startswith('"') and text.endswith('"')) - ): - text = text[1:-1] - result.text = text - result.data = bundle + is_main = msg_type == "execute_result" + result = Result.from_bundle(bundle, is_main_result=is_main) + execution.results.append(result) + if is_main: + execution.execution_count = content.get( + "execution_count" + ) + if on_result is not None: + on_result(result) elif msg_type == "error": - traceback = content.get("traceback", []) - result.error = "\n".join(traceback) - elif msg_type == "status" and content.get("execution_state") == "idle": + err = ExecutionError( + name=content.get("ename", ""), + value=content.get("evalue", ""), + traceback="\n".join(content.get("traceback", [])), + ) + execution.error = err + if on_error is not None: + on_error(err) + elif ( + msg_type == "status" + and content.get("execution_state") == "idle" + ): break - if result.text is None and result.stdout: - result.text = result.stdout.strip() - - return result + return execution def __exit__(self, *args) -> None: if self._proxy_client is not None: diff --git a/src/wrenn/code_interpreter/models.py b/src/wrenn/code_interpreter/models.py new file mode 100644 index 0000000..1449bc4 --- /dev/null +++ b/src/wrenn/code_interpreter/models.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +_MIME_MAP: dict[str, str] = { + "text/plain": "text", + "text/html": "html", + "text/markdown": "markdown", + "image/svg+xml": "svg", + "image/png": "png", + "image/jpeg": "jpeg", + "application/pdf": "pdf", + "text/latex": "latex", + "application/json": "json", + "application/javascript": "javascript", +} + + +@dataclass +class ExecutionError: + """Error raised during code execution. + + Attributes: + name: Exception class name (e.g. ``"NameError"``). + value: Exception message. + traceback: Full traceback string. + """ + + name: str = "" + value: str = "" + traceback: str = "" + + +@dataclass +class Logs: + """Captured stdout/stderr streams. + + Each element in the list is one chunk of text as it arrived from + the kernel. + """ + + stdout: list[str] = field(default_factory=list) + stderr: list[str] = field(default_factory=list) + + +@dataclass +class Result: + """A single rich output from code execution. + + Jupyter cells can produce multiple outputs — one ``execute_result`` + (the expression value) and zero or more ``display_data`` messages + (from ``plt.show()``, ``display()``, etc.). Each becomes a + ``Result``. + + Known MIME types are unpacked into named attributes; anything else + lands in :pyattr:`extra`. + """ + + # --- MIME type fields --- + text: str | None = None + """``text/plain`` representation.""" + html: str | None = None + """``text/html`` representation.""" + markdown: str | None = None + """``text/markdown`` representation.""" + svg: str | None = None + """``image/svg+xml`` representation.""" + png: str | None = None + """``image/png`` — base64-encoded.""" + jpeg: str | None = None + """``image/jpeg`` — base64-encoded.""" + pdf: str | None = None + """``application/pdf`` — base64-encoded.""" + latex: str | None = None + """``text/latex`` representation.""" + json: dict | None = None + """``application/json`` representation.""" + javascript: str | None = None + """``application/javascript`` representation.""" + extra: dict[str, str] | None = None + """MIME types not covered by the named fields above.""" + + is_main_result: bool = False + """``True`` when this came from an ``execute_result`` message + (i.e. the value of the last expression in the cell). ``False`` + for ``display_data`` outputs.""" + + @classmethod + def from_bundle( + cls, bundle: dict[str, str], *, is_main_result: bool = False + ) -> Result: + """Build a ``Result`` from a Jupyter MIME bundle dict.""" + kwargs: dict = {"is_main_result": is_main_result} + extra: dict[str, str] = {} + for mime, value in bundle.items(): + attr = _MIME_MAP.get(mime) + if attr is not None: + kwargs[attr] = value + else: + extra[mime] = value + if extra: + kwargs["extra"] = extra + # Strip surrounding quotes from text/plain (Jupyter repr artefact) + text = kwargs.get("text") + if isinstance(text, str) and len(text) >= 2: + if (text[0] == text[-1]) and text[0] in ("'", '"'): + kwargs["text"] = text[1:-1] + return cls(**kwargs) + + def formats(self) -> list[str]: + """Return names of non-``None`` MIME-type fields.""" + out: list[str] = [] + for attr in ( + "text", + "html", + "markdown", + "svg", + "png", + "jpeg", + "pdf", + "latex", + "json", + "javascript", + ): + if getattr(self, attr) is not None: + out.append(attr) + if self.extra: + out.extend(self.extra) + return out + + +@dataclass +class Execution: + """Complete result of a ``run_code`` call. + + Attributes: + results: All rich outputs produced by the cell — charts, tables, + images, expression values, etc. + logs: Captured stdout/stderr text. + error: Populated when the cell raised an exception. + execution_count: Jupyter execution counter (the ``[N]`` number). + """ + + results: list[Result] = field(default_factory=list) + logs: Logs = field(default_factory=Logs) + error: ExecutionError | None = None + execution_count: int | None = None + + @property + def text(self) -> str | None: + """Convenience — ``text/plain`` of the main ``execute_result``, + or ``None`` if the cell had no expression value.""" + for r in self.results: + if r.is_main_result: + return r.text + return None diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py index 4ebdc74..3d53b03 100644 --- a/src/wrenn/models/_generated.py +++ b/src/wrenn/models/_generated.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-04-15T08:37:41+00:00 +# timestamp: 2026-04-16T20:32:20+00:00 from __future__ import annotations from pydantic import AwareDatetime, BaseModel, EmailStr, Field @@ -19,6 +19,13 @@ class LoginRequest(BaseModel): password: str +class SignupResponse(BaseModel): + message: Annotated[ + str | None, + Field(description="Confirmation message instructing user to check email"), + ] = None + + class AuthResponse(BaseModel): token: Annotated[str | None, Field(description="JWT token (valid for 6 hours)")] = ( None @@ -534,6 +541,34 @@ class ChannelResponse(BaseModel): ] = None +class MeResponse(BaseModel): + name: str | None = None + email: EmailStr | None = None + has_password: Annotated[ + bool | None, + Field( + description="Whether the user has a password set (false for OAuth-only accounts)" + ), + ] = None + providers: Annotated[ + list[str] | None, + Field(description='List of linked OAuth provider names (e.g. ["github"])'), + ] = None + + +class ChangePasswordRequest(BaseModel): + current_password: Annotated[ + str | None, Field(description="Required when changing an existing password") + ] = None + new_password: Annotated[str, Field(min_length=8)] + confirm_password: Annotated[ + str | None, + Field( + description="Required when adding a password to an OAuth-only account (must match new_password)" + ), + ] = None + + class Error2(BaseModel): code: str | None = None message: str | None = None diff --git a/tests/test_capsule_features.py b/tests/test_capsule_features.py index 54f280f..58b7be0 100644 --- a/tests/test_capsule_features.py +++ b/tests/test_capsule_features.py @@ -4,7 +4,7 @@ import pytest import respx from wrenn.capsule import Capsule, _build_proxy_url -from wrenn.code_interpreter.capsule import CodeResult +from wrenn.code_interpreter.models import Execution, ExecutionError, Logs, Result BASE = "https://app.wrenn.dev/api" @@ -120,30 +120,61 @@ class TestCapsuleConnect: assert cap.capsule_id == "cl-1" -class TestCodeResult: - def test_defaults(self): - r = CodeResult() - assert r.text is None - assert r.data is None - assert r.stdout == "" - assert r.stderr == "" - assert r.error is None +class TestExecutionModels: + def test_execution_defaults(self): + e = Execution() + assert e.results == [] + assert e.logs.stdout == [] + assert e.logs.stderr == [] + assert e.error is None + assert e.text is None - def test_with_values(self): - r = CodeResult( - text="84", - data={"text/plain": "84"}, - stdout="", - stderr="", - error=None, - ) + def test_result_from_bundle(self): + bundle = {"text/plain": "84", "image/png": "base64data"} + r = Result.from_bundle(bundle, is_main_result=True) assert r.text == "84" - assert r.data["text/plain"] == "84" + assert r.png == "base64data" + assert r.is_main_result is True - def test_error_result(self): - r = CodeResult(error="ZeroDivisionError: division by zero\n...") - assert r.error is not None - assert "ZeroDivisionError" in r.error + def test_result_from_bundle_strips_quotes(self): + bundle = {"text/plain": "'hello'"} + r = Result.from_bundle(bundle) + assert r.text == "hello" + + def test_result_from_bundle_extra_mimes(self): + bundle = {"text/plain": "x", "application/vnd.custom": "data"} + r = Result.from_bundle(bundle) + assert r.extra == {"application/vnd.custom": "data"} + + def test_result_formats(self): + r = Result(text="hi", png="data") + assert "text" in r.formats() + assert "png" in r.formats() + assert "html" not in r.formats() + + def test_execution_text_property(self): + e = Execution( + results=[ + Result(text="chart", is_main_result=False), + Result(text="42", is_main_result=True), + ] + ) + assert e.text == "42" + + def test_execution_error(self): + err = ExecutionError( + name="ZeroDivisionError", + value="division by zero", + traceback="Traceback ...\nZeroDivisionError: division by zero", + ) + e = Execution(error=err) + assert e.error is not None + assert "ZeroDivisionError" in e.error.name + + def test_logs(self): + logs = Logs(stdout=["hello\n", "world\n"], stderr=["warn\n"]) + assert "".join(logs.stdout) == "hello\nworld\n" + assert "".join(logs.stderr) == "warn\n" class TestDeprecationWarnings: -- 2.49.0 From 42bcc792d64e711e01439da7b3cf54e950f7f653 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Fri, 17 Apr 2026 03:29:45 +0600 Subject: [PATCH 13/44] Updated dependency --- docs/.gitignore | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/.gitignore diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..b2d6de3 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,20 @@ +# Dependencies +/node_modules + +# Production +/build + +# Generated files +.docusaurus +.cache-loader + +# Misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* -- 2.49.0 From 7b35ffb60c67359efb2849991e6524bebf923077 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Fri, 17 Apr 2026 04:29:34 +0600 Subject: [PATCH 14/44] docs: add Google-style docstrings to all public SDK methods --- .gitignore | 2 - pyproject.toml | 1 - src/wrenn/async_capsule.py | 128 ++++++++++++- src/wrenn/capsule.py | 154 ++++++++++++++- src/wrenn/client.py | 190 +++++++++++++++++++ src/wrenn/code_interpreter/async_capsule.py | 16 ++ src/wrenn/code_interpreter/capsule.py | 28 +++ src/wrenn/commands.py | 116 +++++++++++- src/wrenn/exceptions.py | 33 +++- src/wrenn/files.py | 199 ++++++++++++++++++-- uv.lock | 11 -- 11 files changed, 829 insertions(+), 49 deletions(-) diff --git a/.gitignore b/.gitignore index a8714f9..23b2ad4 100644 --- a/.gitignore +++ b/.gitignore @@ -175,5 +175,3 @@ cython_debug/ .pypirc CODE_EXECUTION.md - -docs/ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0f51113..839941f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,6 @@ dependencies = [ "httpx>=0.28.1", "httpx-ws>=0.9.0", "pydantic>=2.12.5", - "python-dotenv>=1.2.2", ] [build-system] diff --git a/src/wrenn/async_capsule.py b/src/wrenn/async_capsule.py index d4bfb4b..cf55560 100644 --- a/src/wrenn/async_capsule.py +++ b/src/wrenn/async_capsule.py @@ -47,10 +47,21 @@ class AsyncCapsule: @property def capsule_id(self) -> str: + """The capsule's unique identifier. + + Returns: + str: Capsule ID assigned by the Wrenn API. + """ return self._id @property def info(self) -> CapsuleModel | None: + """Cached capsule metadata from the last API call. + + Returns: + CapsuleModel | None: The last-fetched capsule model, or ``None`` + if the capsule was connected without an initial fetch. + """ return self._info # ── Factory classmethods ──────────────────────────────────── @@ -67,7 +78,21 @@ class AsyncCapsule: api_key: str | None = None, base_url: str | None = None, ) -> AsyncCapsule: - """Create a new capsule.""" + """Create a new capsule. + + Args: + template (str | None): Template name to boot from. + vcpus (int | None): Number of virtual CPUs. + memory_mb (int | None): Memory in MiB. + timeout (int | None): Inactivity TTL in seconds before auto-pause. + wait (bool): Await until the capsule reaches ``running`` status. + api_key (str | None): Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. + base_url (str | None): API base URL override. + + Returns: + AsyncCapsule: A new capsule instance. + """ client = AsyncWrennClient(api_key=api_key, base_url=base_url) info = await client.capsules.create( template=template, @@ -92,7 +117,20 @@ class AsyncCapsule: api_key: str | None = None, base_url: str | None = None, ) -> AsyncCapsule: - """Connect to an existing capsule. Resumes it if paused.""" + """Connect to an existing capsule, resuming it if paused. + + Args: + capsule_id (str): ID of the capsule to connect to. + api_key (str | None): Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. + base_url (str | None): API base URL override. + + Returns: + AsyncCapsule: A capsule instance bound to the existing capsule. + + Raises: + WrennNotFoundError: If no capsule with the given ID exists. + """ client = AsyncWrennClient(api_key=api_key, base_url=base_url) info = await client.capsules.get(capsule_id) @@ -174,9 +212,26 @@ class AsyncCapsule: # ── Instance-only methods ─────────────────────────────────── async def ping(self) -> None: + """Reset the capsule inactivity timer. + + Call this to prevent the capsule from being auto-paused when the + inactivity TTL is set. + """ await self._client.capsules.ping(self._id) async def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None: + """Await until the capsule status is ``running``. + + Args: + timeout (float): Maximum seconds to wait. Defaults to ``30``. + interval (float): Polling interval in seconds. Defaults to ``0.5``. + + Raises: + TimeoutError: If the capsule does not reach ``running`` state + within ``timeout`` seconds. + RuntimeError: If the capsule enters an error, stopped, or paused + state while waiting. + """ deadline = time.monotonic() + timeout while time.monotonic() < deadline: info = await self._client.capsules.get(self._id) @@ -193,6 +248,13 @@ class AsyncCapsule: ) async def is_running(self) -> bool: + """Check whether the capsule is currently running. + + Makes a live API call to fetch current status. + + Returns: + bool: ``True`` if the capsule status is ``running``. + """ info = await self._instance_get_info() return info.status == Status.running @@ -205,6 +267,16 @@ class AsyncCapsule: api_key: str | None = None, base_url: str | None = None, ) -> list[CapsuleModel]: + """List all capsules belonging to the team. + + Args: + api_key (str | None): Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. + base_url (str | None): API base URL override. + + Returns: + list[CapsuleModel]: All capsules for the authenticated team. + """ async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client: return await client.capsules.list() @@ -220,6 +292,30 @@ class AsyncCapsule: envs: dict[str, str] | None = None, cwd: str | None = None, ) -> AsyncIterator[AsyncPtySession]: + """Open an async interactive PTY session backed by a WebSocket. + + Use as an async context manager and async iterate over + :class:`PtyEvent` objects:: + + async with capsule.pty() as term: + await term.write(b"echo hello\\n") + async for event in term: + if event.type == "output": + print(event.data.decode()) + + Args: + cmd (str): Command to run inside the PTY. Defaults to + ``"/bin/bash"``. + args (list[str] | None): Additional arguments for ``cmd``. + cols (int): Initial terminal column count. Defaults to ``80``. + rows (int): Initial terminal row count. Defaults to ``24``. + envs (dict[str, str] | None): Additional environment variables + to inject into the process. + cwd (str | None): Working directory for the process. + + Yields: + AsyncPtySession: An interactive async PTY session. + """ async with httpx_ws.aconnect_ws( f"/v1/capsules/{self._id}/pty", client=self._client.http ) as ws: @@ -231,6 +327,14 @@ class AsyncCapsule: @asynccontextmanager async def pty_connect(self, tag: str) -> AsyncIterator[AsyncPtySession]: + """Reconnect to an existing PTY session by tag. + + Args: + tag (str): Session tag returned in the ``started`` PTY event. + + Yields: + AsyncPtySession: The reconnected async PTY session. + """ async with httpx_ws.aconnect_ws( f"/v1/capsules/{self._id}/pty", client=self._client.http ) as ws: @@ -241,6 +345,15 @@ class AsyncCapsule: # ── Proxy helpers ─────────────────────────────────────────── def get_url(self, port: int) -> str: + """Get the proxy URL for a port exposed inside this capsule. + + Args: + port (int): Port number to proxy. + + Returns: + str: A ``wss://`` (or ``ws://``) URL that proxies to the given + port inside the capsule. + """ return _build_proxy_url(self._client._base_url, self._id, port) # ── Snapshots ─────────────────────────────────────────────── @@ -248,6 +361,17 @@ class AsyncCapsule: async def create_snapshot( self, name: str | None = None, overwrite: bool = False ) -> Template: + """Create a snapshot template from this capsule's current state. + + Args: + name (str | None): Name for the snapshot template. Auto-generated + if not provided. + overwrite (bool): If ``True``, overwrite an existing template with + the same name. Defaults to ``False``. + + Returns: + Template: The created snapshot template. + """ return await self._client.snapshots.create( capsule_id=self._id, name=name, overwrite=overwrite ) diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py index 62eddd1..3f35b35 100644 --- a/src/wrenn/capsule.py +++ b/src/wrenn/capsule.py @@ -74,6 +74,24 @@ class Capsule: _client: WrennClient | None = None, _info: CapsuleModel | None = None, ) -> None: + """Create and start a new capsule. + + Args: + template (str | None): Template name to boot from. Defaults to + the server-side default (``"minimal"``). + vcpus (int | None): Number of virtual CPUs. Defaults to the + server-side default. + memory_mb (int | None): Memory in MiB. Defaults to the + server-side default. + timeout (int | None): Inactivity TTL in seconds before the capsule + is auto-paused. ``0`` disables auto-pause. + wait (bool): If ``True``, block until the capsule status is + ``running`` before returning. + api_key (str | None): Wrenn API key (``wrn_...``). Falls back to + the ``WRENN_API_KEY`` environment variable. + base_url (str | None): Wrenn API base URL. Falls back to + ``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 @@ -101,10 +119,21 @@ class Capsule: @property def capsule_id(self) -> str: + """The capsule's unique identifier. + + Returns: + str: Capsule ID assigned by the Wrenn API. + """ return self._id @property def info(self) -> CapsuleModel | None: + """Cached capsule metadata from the last API call. + + Returns: + CapsuleModel | None: The last-fetched capsule model, or ``None`` + if the capsule was connected without an initial fetch. + """ return self._info # ── Factory classmethods ──────────────────────────────────── @@ -121,7 +150,23 @@ class Capsule: api_key: str | None = None, base_url: str | None = None, ) -> Capsule: - """Create a new capsule. Alias for ``Capsule(...)``.""" + """Create a new capsule. + + Equivalent to calling ``Capsule(...)`` directly. + + Args: + template (str | None): Template name to boot from. + vcpus (int | None): Number of virtual CPUs. + memory_mb (int | None): Memory in MiB. + timeout (int | None): Inactivity TTL in seconds before auto-pause. + wait (bool): Block until the capsule reaches ``running`` status. + api_key (str | None): Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. + base_url (str | None): API base URL override. + + Returns: + Capsule: A new capsule instance. + """ return cls( template=template, vcpus=vcpus, @@ -140,7 +185,20 @@ class Capsule: api_key: str | None = None, base_url: str | None = None, ) -> Capsule: - """Connect to an existing capsule. Resumes it if paused.""" + """Connect to an existing capsule, resuming it if paused. + + Args: + capsule_id (str): ID of the capsule to connect to. + api_key (str | None): Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. + base_url (str | None): API base URL override. + + Returns: + Capsule: A capsule instance bound to the existing capsule. + + Raises: + WrennNotFoundError: If no capsule with the given ID exists. + """ client = WrennClient(api_key=api_key, base_url=base_url) info = client.capsules.get(capsule_id) @@ -230,11 +288,26 @@ class Capsule: # ── Instance-only methods ─────────────────────────────────── def ping(self) -> None: - """Reset the capsule inactivity timer.""" + """Reset the capsule inactivity timer. + + Call this to prevent the capsule from being auto-paused when the + inactivity TTL is set. + """ self._client.capsules.ping(self._id) def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None: - """Block until the capsule status is ``running``.""" + """Block until the capsule status is ``running``. + + Args: + timeout (float): Maximum seconds to wait. Defaults to ``30``. + interval (float): Polling interval in seconds. Defaults to ``0.5``. + + Raises: + TimeoutError: If the capsule does not reach ``running`` state + within ``timeout`` seconds. + RuntimeError: If the capsule enters an error, stopped, or paused + state while waiting. + """ deadline = time.monotonic() + timeout while time.monotonic() < deadline: info = self._client.capsules.get(self._id) @@ -251,6 +324,13 @@ class Capsule: ) def is_running(self) -> bool: + """Check whether the capsule is currently running. + + Makes a live API call to fetch current status. + + Returns: + bool: ``True`` if the capsule status is ``running``. + """ info = self._instance_get_info() return info.status == Status.running @@ -263,7 +343,16 @@ class Capsule: api_key: str | None = None, base_url: str | None = None, ) -> list[CapsuleModel]: - """List all capsules for the team.""" + """List all capsules belonging to the team. + + Args: + api_key (str | None): Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. + base_url (str | None): API base URL override. + + Returns: + list[CapsuleModel]: All capsules for the authenticated team. + """ with WrennClient(api_key=api_key, base_url=base_url) as client: return client.capsules.list() @@ -279,7 +368,29 @@ class Capsule: envs: dict[str, str] | None = None, cwd: str | None = None, ) -> Iterator[PtySession]: - """Open an interactive PTY session.""" + """Open an interactive PTY session backed by a WebSocket. + + Use as a context manager and iterate over :class:`PtyEvent` objects:: + + with capsule.pty() as term: + term.write(b"echo hello\\n") + for event in term: + if event.type == "output": + print(event.data.decode()) + + Args: + cmd (str): Command to run inside the PTY. Defaults to + ``"/bin/bash"``. + args (list[str] | None): Additional arguments for ``cmd``. + cols (int): Initial terminal column count. Defaults to ``80``. + rows (int): Initial terminal row count. Defaults to ``24``. + envs (dict[str, str] | None): Additional environment variables to + inject into the process. + cwd (str | None): Working directory for the process. + + Yields: + PtySession: An interactive PTY session. + """ with httpx_ws.connect_ws( f"/v1/capsules/{self._id}/pty", client=self._client.http ) as ws: @@ -291,7 +402,14 @@ class Capsule: @contextmanager def pty_connect(self, tag: str) -> Iterator[PtySession]: - """Reconnect to an existing PTY session by tag.""" + """Reconnect to an existing PTY session by tag. + + Args: + tag (str): Session tag returned in the ``started`` PTY event. + + Yields: + PtySession: The reconnected PTY session. + """ with httpx_ws.connect_ws( f"/v1/capsules/{self._id}/pty", client=self._client.http ) as ws: @@ -302,7 +420,15 @@ class Capsule: # ── Proxy helpers ─────────────────────────────────────────── def get_url(self, port: int) -> str: - """Get the proxy URL for a port inside this capsule.""" + """Get the proxy URL for a port exposed inside this capsule. + + Args: + port (int): Port number to proxy. + + Returns: + str: A ``wss://`` (or ``ws://``) URL that proxies to the given + port inside the capsule. + """ return _build_proxy_url(self._client._base_url, self._id, port) # ── Snapshots ─────────────────────────────────────────────── @@ -310,7 +436,17 @@ class Capsule: def create_snapshot( self, name: str | None = None, overwrite: bool = False ) -> Template: - """Create a snapshot template from this capsule.""" + """Create a snapshot template from this capsule's current state. + + Args: + name (str | None): Name for the snapshot template. Auto-generated + if not provided. + overwrite (bool): If ``True``, overwrite an existing template with + the same name. Defaults to ``False``. + + Returns: + Template: The created snapshot template. + """ return self._client.snapshots.create( capsule_id=self._id, name=name, overwrite=overwrite ) diff --git a/src/wrenn/client.py b/src/wrenn/client.py index ea9e74c..c927396 100644 --- a/src/wrenn/client.py +++ b/src/wrenn/client.py @@ -36,6 +36,18 @@ class CapsulesResource: memory_mb: int | None = None, timeout_sec: int | None = None, ) -> CapsuleModel: + """Create a new capsule. + + Args: + template (str | None): Template name to boot from. + vcpus (int | None): Number of virtual CPUs. + memory_mb (int | None): Memory in MiB. + timeout_sec (int | None): Inactivity TTL in seconds before + auto-pause. ``0`` disables auto-pause. + + Returns: + CapsuleModel: The newly created capsule. + """ payload: dict = {} if template is not None: payload["template"] = template @@ -49,26 +61,80 @@ class CapsulesResource: return CapsuleModel.model_validate(handle_response(resp)) def list(self) -> list[CapsuleModel]: + """List all capsules for the authenticated team. + + Returns: + list[CapsuleModel]: All capsules belonging to the team. + """ resp = self._http.get("/v1/capsules") return [CapsuleModel.model_validate(item) for item in handle_response(resp)] def get(self, id: str) -> CapsuleModel: + """Get a capsule by ID. + + Args: + id (str): Capsule ID. + + Returns: + CapsuleModel: Current state of the capsule. + + Raises: + WrennNotFoundError: If no capsule with the given ID exists. + """ resp = self._http.get(f"/v1/capsules/{id}") return CapsuleModel.model_validate(handle_response(resp)) def destroy(self, id: str) -> None: + """Destroy a capsule permanently. + + Args: + id (str): Capsule ID. + + Raises: + WrennNotFoundError: If no capsule with the given ID exists. + """ resp = self._http.delete(f"/v1/capsules/{id}") handle_response(resp) def pause(self, id: str) -> CapsuleModel: + """Pause a running capsule. + + Args: + id (str): Capsule ID. + + Returns: + CapsuleModel: Updated capsule state. + + Raises: + WrennNotFoundError: If no capsule with the given ID exists. + """ resp = self._http.post(f"/v1/capsules/{id}/pause") return CapsuleModel.model_validate(handle_response(resp)) def resume(self, id: str) -> CapsuleModel: + """Resume a paused capsule. + + Args: + id (str): Capsule ID. + + Returns: + CapsuleModel: Updated capsule state. + + Raises: + WrennNotFoundError: If no capsule with the given ID exists. + """ resp = self._http.post(f"/v1/capsules/{id}/resume") return CapsuleModel.model_validate(handle_response(resp)) def ping(self, id: str) -> None: + """Reset the inactivity timer for a capsule. + + Args: + id (str): Capsule ID. + + Raises: + WrennNotFoundError: If no capsule with the given ID exists. + """ resp = self._http.post(f"/v1/capsules/{id}/ping") handle_response(resp) @@ -86,6 +152,18 @@ class AsyncCapsulesResource: memory_mb: int | None = None, timeout_sec: int | None = None, ) -> CapsuleModel: + """Create a new capsule. + + Args: + template (str | None): Template name to boot from. + vcpus (int | None): Number of virtual CPUs. + memory_mb (int | None): Memory in MiB. + timeout_sec (int | None): Inactivity TTL in seconds before + auto-pause. ``0`` disables auto-pause. + + Returns: + CapsuleModel: The newly created capsule. + """ payload: dict = {} if template is not None: payload["template"] = template @@ -99,26 +177,80 @@ class AsyncCapsulesResource: return CapsuleModel.model_validate(handle_response(resp)) async def list(self) -> list[CapsuleModel]: + """List all capsules for the authenticated team. + + Returns: + list[CapsuleModel]: All capsules belonging to the team. + """ resp = await self._http.get("/v1/capsules") return [CapsuleModel.model_validate(item) for item in handle_response(resp)] async def get(self, id: str) -> CapsuleModel: + """Get a capsule by ID. + + Args: + id (str): Capsule ID. + + Returns: + CapsuleModel: Current state of the capsule. + + Raises: + WrennNotFoundError: If no capsule with the given ID exists. + """ resp = await self._http.get(f"/v1/capsules/{id}") return CapsuleModel.model_validate(handle_response(resp)) async def destroy(self, id: str) -> None: + """Destroy a capsule permanently. + + Args: + id (str): Capsule ID. + + Raises: + WrennNotFoundError: If no capsule with the given ID exists. + """ resp = await self._http.delete(f"/v1/capsules/{id}") handle_response(resp) async def pause(self, id: str) -> CapsuleModel: + """Pause a running capsule. + + Args: + id (str): Capsule ID. + + Returns: + CapsuleModel: Updated capsule state. + + Raises: + WrennNotFoundError: If no capsule with the given ID exists. + """ resp = await self._http.post(f"/v1/capsules/{id}/pause") return CapsuleModel.model_validate(handle_response(resp)) async def resume(self, id: str) -> CapsuleModel: + """Resume a paused capsule. + + Args: + id (str): Capsule ID. + + Returns: + CapsuleModel: Updated capsule state. + + Raises: + WrennNotFoundError: If no capsule with the given ID exists. + """ resp = await self._http.post(f"/v1/capsules/{id}/resume") return CapsuleModel.model_validate(handle_response(resp)) async def ping(self, id: str) -> None: + """Reset the inactivity timer for a capsule. + + Args: + id (str): Capsule ID. + + Raises: + WrennNotFoundError: If no capsule with the given ID exists. + """ resp = await self._http.post(f"/v1/capsules/{id}/ping") handle_response(resp) @@ -135,6 +267,18 @@ class SnapshotsResource: name: str | None = None, overwrite: bool = False, ) -> Template: + """Create a snapshot template from a running capsule. + + Args: + capsule_id (str): ID of the capsule to snapshot. + name (str | None): Name for the snapshot template. Auto-generated + if not provided. + overwrite (bool): If ``True``, overwrite an existing template with + the same name. Defaults to ``False``. + + Returns: + Template: The created snapshot template. + """ payload: dict = {"sandbox_id": capsule_id} if name is not None: payload["name"] = name @@ -145,6 +289,15 @@ class SnapshotsResource: return Template.model_validate(handle_response(resp)) def list(self, type: str | None = None) -> list[Template]: + """List snapshot templates. + + Args: + type (str | None): Filter by template type. Returns all templates + if not provided. + + Returns: + list[Template]: Matching snapshot templates. + """ params: dict = {} if type is not None: params["type"] = type @@ -152,6 +305,14 @@ class SnapshotsResource: return [Template.model_validate(item) for item in handle_response(resp)] def delete(self, name: str) -> None: + """Delete a snapshot template by name. + + Args: + name (str): Template name to delete. + + Raises: + WrennNotFoundError: If no template with the given name exists. + """ resp = self._http.delete(f"/v1/snapshots/{name}") handle_response(resp) @@ -168,6 +329,18 @@ class AsyncSnapshotsResource: name: str | None = None, overwrite: bool = False, ) -> Template: + """Create a snapshot template from a running capsule. + + Args: + capsule_id (str): ID of the capsule to snapshot. + name (str | None): Name for the snapshot template. Auto-generated + if not provided. + overwrite (bool): If ``True``, overwrite an existing template with + the same name. Defaults to ``False``. + + Returns: + Template: The created snapshot template. + """ payload: dict = {"sandbox_id": capsule_id} if name is not None: payload["name"] = name @@ -178,6 +351,15 @@ class AsyncSnapshotsResource: return Template.model_validate(handle_response(resp)) async def list(self, type: str | None = None) -> list[Template]: + """List snapshot templates. + + Args: + type (str | None): Filter by template type. Returns all templates + if not provided. + + Returns: + list[Template]: Matching snapshot templates. + """ params: dict = {} if type is not None: params["type"] = type @@ -185,6 +367,14 @@ class AsyncSnapshotsResource: return [Template.model_validate(item) for item in handle_response(resp)] async def delete(self, name: str) -> None: + """Delete a snapshot template by name. + + Args: + name (str): Template name to delete. + + Raises: + WrennNotFoundError: If no template with the given name exists. + """ resp = await self._http.delete(f"/v1/snapshots/{name}") handle_response(resp) diff --git a/src/wrenn/code_interpreter/async_capsule.py b/src/wrenn/code_interpreter/async_capsule.py index 885136d..d74eb77 100644 --- a/src/wrenn/code_interpreter/async_capsule.py +++ b/src/wrenn/code_interpreter/async_capsule.py @@ -53,6 +53,22 @@ class AsyncCapsule(BaseAsyncCapsule): api_key: str | None = None, base_url: str | None = None, ) -> AsyncCapsule: + """Create a new async code interpreter capsule. + + Args: + template (str | None): Template to boot from. Defaults to + ``"code-runner-beta"``. + vcpus (int | None): Number of virtual CPUs. + memory_mb (int | None): Memory in MiB. + timeout (int | None): Inactivity TTL in seconds before auto-pause. + wait (bool): Await until the capsule reaches ``running`` status. + api_key (str | None): Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. + base_url (str | None): API base URL override. + + Returns: + AsyncCapsule: A new async code interpreter capsule instance. + """ client = AsyncWrennClient(api_key=api_key, base_url=base_url) info = await client.capsules.create( template=template or DEFAULT_TEMPLATE, diff --git a/src/wrenn/code_interpreter/capsule.py b/src/wrenn/code_interpreter/capsule.py index 0e732e8..43a9f54 100644 --- a/src/wrenn/code_interpreter/capsule.py +++ b/src/wrenn/code_interpreter/capsule.py @@ -47,6 +47,18 @@ class Capsule(BaseCapsule): base_url: str | None = None, **kwargs, ) -> None: + """Create a code interpreter capsule. + + Args: + template (str | None): Template to boot from. Defaults to + ``"code-runner-beta"``. + vcpus (int | None): Number of virtual CPUs. + memory_mb (int | None): Memory in MiB. + timeout (int | None): Inactivity TTL in seconds before auto-pause. + api_key (str | None): Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. + base_url (str | None): API base URL override. + """ super().__init__( template=template or DEFAULT_TEMPLATE, vcpus=vcpus, @@ -71,6 +83,22 @@ class Capsule(BaseCapsule): api_key: str | None = None, base_url: str | None = None, ) -> Capsule: + """Create a new code interpreter capsule. + + Args: + template (str | None): Template to boot from. Defaults to + ``"code-runner-beta"``. + vcpus (int | None): Number of virtual CPUs. + memory_mb (int | None): Memory in MiB. + timeout (int | None): Inactivity TTL in seconds before auto-pause. + wait (bool): Block until the capsule reaches ``running`` status. + api_key (str | None): Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. + base_url (str | None): API base URL override. + + Returns: + Capsule: A new code interpreter capsule instance. + """ return cls( template=template or DEFAULT_TEMPLATE, vcpus=vcpus, diff --git a/src/wrenn/commands.py b/src/wrenn/commands.py index 13d97a2..c42f136 100644 --- a/src/wrenn/commands.py +++ b/src/wrenn/commands.py @@ -161,6 +161,28 @@ class Commands: cwd: str | None = None, tag: str | None = None, ) -> CommandResult | CommandHandle: + """Execute a shell command inside the capsule. + + Args: + cmd (str): Shell command string to execute. + background (bool): If ``True``, launch the process in the + background and return a :class:`CommandHandle` immediately. + Defaults to ``False``. + timeout (int | None): Seconds before the foreground command times + out. Ignored for background commands. Defaults to ``30``. + envs (dict[str, str] | None): Additional environment variables + to set for the process. + cwd (str | None): Working directory for the process. + tag (str | None): Optional label attached to background processes + for later retrieval via :meth:`connect`. + + Returns: + CommandResult: stdout, stderr, exit code, and duration for + foreground commands (``background=False``). + + CommandHandle: PID and tag for background commands + (``background=True``). + """ payload: dict = {"cmd": cmd, "background": background} if timeout is not None and not background: payload["timeout_sec"] = timeout @@ -185,6 +207,12 @@ class Commands: return _decode_exec_response(data) def list(self) -> list[ProcessInfo]: + """List all running background processes in the capsule. + + Returns: + list[ProcessInfo]: Running processes with their PID, tag, and + command information. + """ resp = self._http.get(f"/v1/capsules/{self._capsule_id}/processes") data = handle_response(resp) return [ @@ -198,13 +226,29 @@ class Commands: ] def kill(self, pid: int) -> None: + """Send SIGKILL to a background process. + + Args: + pid (int): PID of the process to kill. + + Raises: + WrennNotFoundError: If no process with the given PID exists. + """ resp = self._http.delete( f"/v1/capsules/{self._capsule_id}/processes/{pid}" ) handle_response(resp) def connect(self, pid: int) -> Iterator[StreamEvent]: - """Connect to a running background process and stream its output.""" + """Connect to a running background process and stream its output. + + Args: + pid (int): PID of the background process to attach to. + + Yields: + StreamEvent: Successive output events. Stops on + :class:`StreamExitEvent` or :class:`StreamErrorEvent`. + """ with httpx_ws.connect_ws( f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream", self._http, @@ -222,7 +266,17 @@ class Commands: def stream( self, cmd: str, args: list[str] | None = None ) -> Iterator[StreamEvent]: - """Execute a command via WebSocket, yielding ``StreamEvent`` objects.""" + """Execute a command via WebSocket, streaming output as events. + + Args: + cmd (str): Command to execute. + args (list[str] | None): Additional arguments for the command. + + Yields: + StreamEvent: Successive events including :class:`StreamStartEvent`, + :class:`StreamStdoutEvent`, :class:`StreamStderrEvent`, + :class:`StreamExitEvent`, and :class:`StreamErrorEvent`. + """ with httpx_ws.connect_ws( f"/v1/capsules/{self._capsule_id}/exec/stream", self._http, @@ -283,6 +337,28 @@ class AsyncCommands: cwd: str | None = None, tag: str | None = None, ) -> CommandResult | CommandHandle: + """Execute a shell command inside the capsule. + + Args: + cmd (str): Shell command string to execute. + background (bool): If ``True``, launch the process in the + background and return a :class:`CommandHandle` immediately. + Defaults to ``False``. + timeout (int | None): Seconds before the foreground command times + out. Ignored for background commands. Defaults to ``30``. + envs (dict[str, str] | None): Additional environment variables + to set for the process. + cwd (str | None): Working directory for the process. + tag (str | None): Optional label attached to background processes + for later retrieval via :meth:`connect`. + + Returns: + CommandResult: stdout, stderr, exit code, and duration for + foreground commands (``background=False``). + + CommandHandle: PID and tag for background commands + (``background=True``). + """ payload: dict = {"cmd": cmd, "background": background} if timeout is not None and not background: payload["timeout_sec"] = timeout @@ -307,6 +383,12 @@ class AsyncCommands: return _decode_exec_response(data) async def list(self) -> list[ProcessInfo]: + """List all running background processes in the capsule. + + Returns: + list[ProcessInfo]: Running processes with their PID, tag, and + command information. + """ resp = await self._http.get( f"/v1/capsules/{self._capsule_id}/processes" ) @@ -322,13 +404,29 @@ class AsyncCommands: ] async def kill(self, pid: int) -> None: + """Send SIGKILL to a background process. + + Args: + pid (int): PID of the process to kill. + + Raises: + WrennNotFoundError: If no process with the given PID exists. + """ resp = await self._http.delete( f"/v1/capsules/{self._capsule_id}/processes/{pid}" ) handle_response(resp) async def connect(self, pid: int) -> AsyncIterator[StreamEvent]: - """Connect to a running background process and stream its output.""" + """Connect to a running background process and stream its output. + + Args: + pid (int): PID of the background process to attach to. + + Yields: + StreamEvent: Successive output events. Stops on + :class:`StreamExitEvent` or :class:`StreamErrorEvent`. + """ async with httpx_ws.aconnect_ws( f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream", self._http, @@ -346,7 +444,17 @@ class AsyncCommands: async def stream( self, cmd: str, args: list[str] | None = None ) -> AsyncIterator[StreamEvent]: - """Execute a command via WebSocket, yielding ``StreamEvent`` objects.""" + """Execute a command via WebSocket, streaming output as events. + + Args: + cmd (str): Command to execute. + args (list[str] | None): Additional arguments for the command. + + Yields: + StreamEvent: Successive events including :class:`StreamStartEvent`, + :class:`StreamStdoutEvent`, :class:`StreamStderrEvent`, + :class:`StreamExitEvent`, and :class:`StreamErrorEvent`. + """ async with httpx_ws.aconnect_ws( f"/v1/capsules/{self._capsule_id}/exec/stream", self._http, diff --git a/src/wrenn/exceptions.py b/src/wrenn/exceptions.py index c4b39d8..438cfcb 100644 --- a/src/wrenn/exceptions.py +++ b/src/wrenn/exceptions.py @@ -6,9 +6,26 @@ import httpx class WrennError(Exception): - """Base exception for all Wrenn SDK errors.""" + """Base exception for all Wrenn SDK errors. + + All SDK exceptions inherit from this class, so you can catch + ``WrennError`` to handle any API error generically. + + Attributes: + code (str): Machine-readable error code from the API + (e.g. ``"not_found"``). + message (str): Human-readable error description. + status_code (int): HTTP status code of the response. + """ def __init__(self, code: str, message: str, status_code: int) -> None: + """Initialize a WrennError. + + Args: + code (str): Machine-readable error code. + message (str): Human-readable error description. + status_code (int): HTTP status code of the response. + """ self.code = code self.message = message self.status_code = status_code @@ -36,11 +53,23 @@ class WrennConflictError(WrennError): class WrennHostHasCapsulesError(WrennConflictError): - """409 — Host still has running capsules.""" + """409 — Host still has running capsules. + + Attributes: + capsule_ids (list[str]): IDs of the capsules still running on the host. + """ def __init__( self, code: str, message: str, status_code: int, capsule_ids: list[str] ) -> None: + """Initialize a WrennHostHasCapsulesError. + + Args: + code (str): Machine-readable error code. + message (str): Human-readable error description. + status_code (int): HTTP status code of the response. + capsule_ids (list[str]): IDs of capsules still on the host. + """ self.capsule_ids = capsule_ids super().__init__(code, message, status_code) diff --git a/src/wrenn/files.py b/src/wrenn/files.py index 837aa2f..94a1dcc 100644 --- a/src/wrenn/files.py +++ b/src/wrenn/files.py @@ -17,11 +17,31 @@ class Files: self._http = http def read(self, path: str) -> str: - """Read a file as a UTF-8 string.""" + """Read a file as a UTF-8 string. + + Args: + path (str): Absolute path to the file inside the capsule. + + Returns: + str: File contents decoded as UTF-8. + + Raises: + WrennNotFoundError: If the path does not exist. + """ return self.read_bytes(path).decode("utf-8", errors="replace") def read_bytes(self, path: str) -> bytes: - """Read a file as raw bytes.""" + """Read a file as raw bytes. + + Args: + path (str): Absolute path to the file inside the capsule. + + Returns: + bytes: Raw file contents. + + Raises: + WrennNotFoundError: If the path does not exist. + """ resp = self._http.post( f"/v1/capsules/{self._capsule_id}/files/read", json={"path": path}, @@ -30,7 +50,14 @@ class Files: return resp.content def write(self, path: str, data: str | bytes) -> None: - """Write data to a file inside the capsule.""" + """Write data to a file inside the capsule. + + Creates parent directories if they do not exist. + + Args: + path (str): Absolute destination path inside the capsule. + data (str | bytes): Content to write. Strings are UTF-8 encoded. + """ if isinstance(data, str): data = data.encode("utf-8") resp = self._http.post( @@ -41,7 +68,19 @@ class Files: resp.raise_for_status() def list(self, path: str, depth: int = 1) -> list[FileEntry]: - """List directory contents.""" + """List directory contents. + + Args: + path (str): Absolute path to the directory inside the capsule. + depth (int): Recursion depth. ``1`` lists only immediate children. + Defaults to ``1``. + + Returns: + list[FileEntry]: Entries in the directory. + + Raises: + WrennNotFoundError: If the path does not exist. + """ resp = self._http.post( f"/v1/capsules/{self._capsule_id}/files/list", json={"path": path, "depth": depth}, @@ -50,7 +89,14 @@ class Files: return parsed.entries or [] def exists(self, path: str) -> bool: - """Check whether a path exists inside the capsule.""" + """Check whether a path exists inside the capsule. + + Args: + path (str): Absolute path to check. + + Returns: + bool: ``True`` if the path exists. + """ parent = os.path.dirname(path) name = os.path.basename(path) try: @@ -60,7 +106,14 @@ class Files: return any(e.name == name for e in entries) def make_dir(self, path: str) -> FileEntry: - """Create a directory (with parents). Idempotent.""" + """Create a directory (with parents). Idempotent. + + Args: + path (str): Absolute path of the directory to create. + + Returns: + FileEntry: The created (or already-existing) directory entry. + """ resp = self._http.post( f"/v1/capsules/{self._capsule_id}/files/mkdir", json={"path": path}, @@ -82,7 +135,14 @@ class Files: return parsed.entry def remove(self, path: str) -> None: - """Remove a file or directory recursively.""" + """Remove a file or directory recursively. + + Args: + path (str): Absolute path to remove. + + Raises: + WrennNotFoundError: If the path does not exist. + """ resp = self._http.post( f"/v1/capsules/{self._capsule_id}/files/remove", json={"path": path}, @@ -90,7 +150,15 @@ class Files: handle_response(resp) def upload_stream(self, path: str, stream: Iterator[bytes]) -> None: - """Streaming upload for large files.""" + """Stream a large file into the capsule. + + Prefer this over :meth:`write` when the file is too large to hold in + memory. + + Args: + path (str): Absolute destination path inside the capsule. + stream (Iterator[bytes]): Iterable of byte chunks to upload. + """ boundary = os.urandom(16).hex().encode("utf-8") def _multipart() -> Iterator[bytes]: @@ -114,7 +182,20 @@ class Files: resp.raise_for_status() def download_stream(self, path: str) -> Iterator[bytes]: - """Streaming download for large files.""" + """Stream a large file out of the capsule. + + Prefer this over :meth:`read_bytes` when the file is too large to hold + in memory. + + Args: + path (str): Absolute path to the file inside the capsule. + + Yields: + bytes: Successive byte chunks of the file. + + Raises: + WrennNotFoundError: If the path does not exist. + """ with self._http.stream( "POST", f"/v1/capsules/{self._capsule_id}/files/stream/read", @@ -132,12 +213,32 @@ class AsyncFiles: self._http = http async def read(self, path: str) -> str: - """Read a file as a UTF-8 string.""" + """Read a file as a UTF-8 string. + + Args: + path (str): Absolute path to the file inside the capsule. + + Returns: + str: File contents decoded as UTF-8. + + Raises: + WrennNotFoundError: If the path does not exist. + """ data = await self.read_bytes(path) return data.decode("utf-8", errors="replace") async def read_bytes(self, path: str) -> bytes: - """Read a file as raw bytes.""" + """Read a file as raw bytes. + + Args: + path (str): Absolute path to the file inside the capsule. + + Returns: + bytes: Raw file contents. + + Raises: + WrennNotFoundError: If the path does not exist. + """ resp = await self._http.post( f"/v1/capsules/{self._capsule_id}/files/read", json={"path": path}, @@ -146,7 +247,14 @@ class AsyncFiles: return resp.content async def write(self, path: str, data: str | bytes) -> None: - """Write data to a file inside the capsule.""" + """Write data to a file inside the capsule. + + Creates parent directories if they do not exist. + + Args: + path (str): Absolute destination path inside the capsule. + data (str | bytes): Content to write. Strings are UTF-8 encoded. + """ if isinstance(data, str): data = data.encode("utf-8") resp = await self._http.post( @@ -157,7 +265,19 @@ class AsyncFiles: resp.raise_for_status() async def list(self, path: str, depth: int = 1) -> list[FileEntry]: - """List directory contents.""" + """List directory contents. + + Args: + path (str): Absolute path to the directory inside the capsule. + depth (int): Recursion depth. ``1`` lists only immediate children. + Defaults to ``1``. + + Returns: + list[FileEntry]: Entries in the directory. + + Raises: + WrennNotFoundError: If the path does not exist. + """ resp = await self._http.post( f"/v1/capsules/{self._capsule_id}/files/list", json={"path": path, "depth": depth}, @@ -166,7 +286,14 @@ class AsyncFiles: return parsed.entries or [] async def exists(self, path: str) -> bool: - """Check whether a path exists inside the capsule.""" + """Check whether a path exists inside the capsule. + + Args: + path (str): Absolute path to check. + + Returns: + bool: ``True`` if the path exists. + """ parent = os.path.dirname(path) name = os.path.basename(path) try: @@ -176,7 +303,14 @@ class AsyncFiles: return any(e.name == name for e in entries) async def make_dir(self, path: str) -> FileEntry: - """Create a directory (with parents). Idempotent.""" + """Create a directory (with parents). Idempotent. + + Args: + path (str): Absolute path of the directory to create. + + Returns: + FileEntry: The created (or already-existing) directory entry. + """ resp = await self._http.post( f"/v1/capsules/{self._capsule_id}/files/mkdir", json={"path": path}, @@ -198,7 +332,14 @@ class AsyncFiles: return parsed.entry async def remove(self, path: str) -> None: - """Remove a file or directory recursively.""" + """Remove a file or directory recursively. + + Args: + path (str): Absolute path to remove. + + Raises: + WrennNotFoundError: If the path does not exist. + """ resp = await self._http.post( f"/v1/capsules/{self._capsule_id}/files/remove", json={"path": path}, @@ -206,7 +347,16 @@ class AsyncFiles: handle_response(resp) async def upload_stream(self, path: str, stream: AsyncIterator[bytes]) -> None: - """Streaming upload for large files.""" + """Stream a large file into the capsule. + + Prefer this over :meth:`write` when the file is too large to hold in + memory. + + Args: + path (str): Absolute destination path inside the capsule. + stream (AsyncIterator[bytes]): Async iterable of byte chunks to + upload. + """ boundary = os.urandom(16).hex().encode("utf-8") async def _multipart() -> AsyncIterator[bytes]: @@ -230,7 +380,20 @@ class AsyncFiles: resp.raise_for_status() async def download_stream(self, path: str) -> AsyncIterator[bytes]: - """Streaming download for large files.""" + """Stream a large file out of the capsule. + + Prefer this over :meth:`read_bytes` when the file is too large to hold + in memory. + + Args: + path (str): Absolute path to the file inside the capsule. + + Yields: + bytes: Successive byte chunks of the file. + + Raises: + WrennNotFoundError: If the path does not exist. + """ async with self._http.stream( "POST", f"/v1/capsules/{self._capsule_id}/files/stream/read", diff --git a/uv.lock b/uv.lock index 36827e6..985de91 100644 --- a/uv.lock +++ b/uv.lock @@ -546,15 +546,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - [[package]] name = "pytokens" version = "0.4.1" @@ -694,7 +685,6 @@ dependencies = [ { name = "httpx" }, { name = "httpx-ws" }, { name = "pydantic" }, - { name = "python-dotenv" }, ] [package.dev-dependencies] @@ -713,7 +703,6 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "httpx-ws", specifier = ">=0.9.0" }, { name = "pydantic", specifier = ">=2.12.5" }, - { name = "python-dotenv", specifier = ">=1.2.2" }, ] [package.metadata.requires-dev] -- 2.49.0 From 61bc04009804e89465b50e8c6b6ce0a57bac6733 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Thu, 23 Apr 2026 02:31:47 +0600 Subject: [PATCH 15/44] Minor patches --- Makefile | 2 +- api/openapi.yaml | 56 +++++++++++++++++++++++++++++++++- docs/.gitignore | 20 ------------ pyproject.toml | 5 +-- src/wrenn/models/_generated.py | 15 ++++++++- 5 files changed, 73 insertions(+), 25 deletions(-) delete mode 100644 docs/.gitignore diff --git a/Makefile b/Makefile index 51535c3..7b1b356 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ .PHONY: generate lint test check test-integration # Variables -SPEC_URL = "https://git.omukk.dev/wrenn/wrenn/raw/branch/main/internal/api/openapi.yaml" +SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml" SPEC_PATH = "api/openapi.yaml" generate: diff --git a/api/openapi.yaml b/api/openapi.yaml index f4c369d..8d3861c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2,7 +2,7 @@ openapi: "3.1.0" info: title: Wrenn API description: MicroVM-based code execution platform API. - version: "0.1.0" + version: "0.1.3" servers: - url: http://localhost:8080 @@ -921,6 +921,38 @@ paths: "400": $ref: "#/components/responses/BadRequest" + /v1/capsules/usage: + get: + summary: Get daily CPU and RAM usage for your team + operationId: getCapsuleUsage + tags: [capsules] + security: + - apiKeyAuth: [] + parameters: + - name: from + in: query + required: false + schema: + type: string + format: date + description: Start date (YYYY-MM-DD). Defaults to 30 days ago. + - name: to + in: query + required: false + schema: + type: string + format: date + description: End date (YYYY-MM-DD). Defaults to today. + responses: + "200": + description: Daily usage data for the team + content: + application/json: + schema: + $ref: "#/components/schemas/UsageResponse" + "400": + $ref: "#/components/responses/BadRequest" + /v1/capsules/{id}: parameters: - name: id @@ -2432,6 +2464,28 @@ components: after this duration of inactivity (no exec or ping). 0 means no auto-pause. + UsageResponse: + type: object + properties: + from: + type: string + format: date + to: + type: string + format: date + points: + type: array + items: + type: object + properties: + date: + type: string + format: date + cpu_minutes: + type: number + ram_mb_minutes: + type: number + CapsuleStats: type: object properties: diff --git a/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index b2d6de3..0000000 --- a/docs/.gitignore +++ /dev/null @@ -1,20 +0,0 @@ -# Dependencies -/node_modules - -# Production -/build - -# Generated files -.docusaurus -.cache-loader - -# Misc -.DS_Store -.env.local -.env.development.local -.env.test.local -.env.production.local - -npm-debug.log* -yarn-debug.log* -yarn-error.log* diff --git a/pyproject.toml b/pyproject.toml index 839941f..33c72ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,11 @@ [project] name = "wrenn" version = "0.1.0" -description = "Add your description here" +description = "Python SDK for Wrenn" readme = "README.md" authors = [ - { name = "Tasnim Kabir Sadik", email = "tksadik92@gmail.com" } + { name = "Rafeed M. Bhuiyan", email = "rafeed@omukk.dev" } + { name = "Tasnim Kabir Sadik", email = "tksadik@omukk.dev" } ] requires-python = ">=3.13" dependencies = [ diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py index 3d53b03..5542c2f 100644 --- a/src/wrenn/models/_generated.py +++ b/src/wrenn/models/_generated.py @@ -1,10 +1,11 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-04-16T20:32:20+00:00 +# timestamp: 2026-04-22T20:21:34+00:00 from __future__ import annotations from pydantic import AwareDatetime, BaseModel, EmailStr, Field from typing import Annotated +from datetime import date as date_aliased from enum import StrEnum @@ -69,6 +70,18 @@ class CreateCapsuleRequest(BaseModel): ] = 0 +class Point(BaseModel): + date: date_aliased | None = None + cpu_minutes: float | None = None + ram_mb_minutes: float | None = None + + +class UsageResponse(BaseModel): + from_: Annotated[date_aliased | None, Field(alias="from")] = None + to: date_aliased | None = None + points: list[Point] | None = None + + class Range(StrEnum): field_5m = "5m" field_1h = "1h" -- 2.49.0 From 6bdf28e2ae3f1fc5d75d7e51cd900a5041ead4a8 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Thu, 23 Apr 2026 04:46:57 +0600 Subject: [PATCH 16/44] Added git integration --- pyproject.toml | 4 +- src/wrenn/__init__.py | 18 + src/wrenn/_git/__init__.py | 1423 ++++++++++++++++++++++++++++++++++ src/wrenn/_git/_auth.py | 104 +++ src/wrenn/_git/_cmd.py | 495 ++++++++++++ src/wrenn/_git/exceptions.py | 30 + src/wrenn/async_capsule.py | 2 + src/wrenn/capsule.py | 2 + tests/test_git.py | 944 ++++++++++++++++++++++ tests/test_integration.py | 568 -------------- 10 files changed, 3020 insertions(+), 570 deletions(-) create mode 100644 src/wrenn/_git/__init__.py create mode 100644 src/wrenn/_git/_auth.py create mode 100644 src/wrenn/_git/_cmd.py create mode 100644 src/wrenn/_git/exceptions.py create mode 100644 tests/test_git.py delete mode 100644 tests/test_integration.py diff --git a/pyproject.toml b/pyproject.toml index 33c72ac..359c8a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ version = "0.1.0" description = "Python SDK for Wrenn" readme = "README.md" authors = [ - { name = "Rafeed M. Bhuiyan", email = "rafeed@omukk.dev" } - { name = "Tasnim Kabir Sadik", email = "tksadik@omukk.dev" } + { name = "Rafeed M. Bhuiyan", email = "rafeed@omukk.dev" }, + { name = "Tasnim Kabir Sadik", email = "tksadik@omukk.dev" }, ] requires-python = ">=3.13" dependencies = [ diff --git a/src/wrenn/__init__.py b/src/wrenn/__init__.py index 55447c6..1ae84ae 100644 --- a/src/wrenn/__init__.py +++ b/src/wrenn/__init__.py @@ -1,3 +1,13 @@ +from wrenn._git import ( + AsyncGit, + FileStatus, + Git, + GitAuthError, + GitBranch, + GitCommandError, + GitError, + GitStatus, +) from wrenn.async_capsule import AsyncCapsule from wrenn.capsule import Capsule from wrenn.client import AsyncWrennClient, WrennClient @@ -32,12 +42,20 @@ __version__ = "0.1.0" __all__ = [ "__version__", "AsyncCapsule", + "AsyncGit", "AsyncPtySession", "AsyncWrennClient", "Capsule", "CommandHandle", "CommandResult", "FileEntry", + "FileStatus", + "Git", + "GitAuthError", + "GitBranch", + "GitCommandError", + "GitError", + "GitStatus", "ProcessInfo", "PtyEvent", "PtyEventType", diff --git a/src/wrenn/_git/__init__.py b/src/wrenn/_git/__init__.py new file mode 100644 index 0000000..89a42a5 --- /dev/null +++ b/src/wrenn/_git/__init__.py @@ -0,0 +1,1423 @@ +"""Git operations inside a Wrenn capsule. + +Provides :class:`Git` (sync) and :class:`AsyncGit` (async) interfaces +accessed via ``capsule.git``. All operations execute the real ``git`` +binary inside the capsule through :class:`~wrenn.commands.Commands`. +""" + +from __future__ import annotations + +import posixpath +import shlex +from collections.abc import Awaitable, Callable +from urllib.parse import urlparse + +import httpx + +from wrenn._git._auth import ( + build_credential_approve_cmd, + embed_credentials, + is_auth_error, + strip_credentials, +) +from wrenn._git._cmd import ( + FileStatus, + GitBranch, + GitStatus, + build_add, + build_branches, + build_checkout, + build_clone, + build_commit, + build_config_get, + build_config_set, + build_create_branch, + build_delete_branch, + build_init, + build_pull, + build_push, + build_remote_add, + build_remote_get_url, + build_remote_set_url, + build_reset, + build_restore, + build_status, + parse_branches, + parse_status, +) +from wrenn._git.exceptions import GitAuthError, GitCommandError, GitError +from wrenn.commands import AsyncCommands, CommandResult, Commands + +__all__ = [ + "AsyncGit", + "FileStatus", + "Git", + "GitAuthError", + "GitBranch", + "GitCommandError", + "GitError", + "GitStatus", +] + +_DEFAULT_GIT_ENV: dict[str, str] = {"GIT_TERMINAL_PROMPT": "0"} + + +def _check_result(result: CommandResult, *, op: str) -> None: + """Raise a :class:`GitError` subclass if the command failed. + + Args: + result: Result from ``commands.run()``. + op: Short operation name for error messages (e.g. ``"clone"``). + + Raises: + GitAuthError: If stderr contains authentication failure signals. + GitCommandError: For all other non-zero exit codes. + """ + if result.exit_code == 0: + return + if is_auth_error(result.stderr): + raise GitAuthError( + f"git {op}: authentication failed", + stderr=result.stderr, + exit_code=result.exit_code, + ) + msg = result.stderr.strip() or result.stdout.strip() + raise GitCommandError( + msg or f"git {op} failed (exit {result.exit_code})", + stderr=result.stderr, + exit_code=result.exit_code, + ) + + +def _merge_envs(envs: dict[str, str] | None) -> dict[str, str]: + """Merge caller-provided envs with default git environment.""" + return {**_DEFAULT_GIT_ENV, **(envs or {})} + + +def _derive_repo_dir(url: str) -> str | None: + """Derive the default repo directory name from a git URL.""" + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return None + trimmed = parsed.path.rstrip("/") + if not trimmed: + return None + last = trimmed.split("/")[-1] + if not last: + return None + return last[:-4] if last.endswith(".git") else last + + +class Git: + """Sync git interface. Accessed via ``capsule.git``. + + Executes the real ``git`` binary inside the capsule through + :meth:`Commands.run`. Methods raise :class:`GitCommandError` (or + :class:`GitAuthError`) on non-zero exit codes. + """ + + def __init__(self, capsule_id: str, http: httpx.Client) -> None: + self._capsule_id = capsule_id + self._http = http + self._commands = Commands(capsule_id, http) + + def _run( + self, + argv: list[str], + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Build a shell command from *argv* and execute it.""" + return self._commands.run( + shlex.join(argv), + cwd=cwd, + envs=_merge_envs(envs), + timeout=timeout, + ) + + def _run_shell( + self, + cmd: str, + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Execute a raw shell command string.""" + return self._commands.run( + cmd, + cwd=cwd, + envs=_merge_envs(envs), + timeout=timeout, + ) + + # ── Repository setup ─────────────────────────────────────── + + def clone( + self, + url: str, + dest: str | None = None, + *, + branch: str | None = None, + depth: int | None = None, + username: str | None = None, + password: str | None = None, + dangerously_store_credentials: bool = False, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 300, + ) -> CommandResult: + """Clone a remote repository into the capsule. + + Args: + url: Remote repository URL. + dest: Destination path. Defaults to the repository name + derived from the URL. + branch: Branch or tag to check out. + depth: Create a shallow clone with this many commits. + username: Username for HTTP(S) authentication. + password: Password or token for HTTP(S) authentication. + dangerously_store_credentials: If ``True``, leave credentials + embedded in the remote URL after cloning. + cwd: Working directory for the command. + envs: Extra environment variables. + timeout: Command timeout in seconds. Defaults to ``300``. + + Returns: + Command result with stdout, stderr, exit_code, and duration. + + Raises: + GitAuthError: If the remote rejected authentication. + GitCommandError: If clone failed for another reason. + ValueError: If *password* is provided without *username*. + """ + if password and not username: + raise ValueError( + "Username is required when using a password for git clone." + ) + + clone_url = url + if username and password: + clone_url = embed_credentials(url, username, password) + + argv = build_clone(clone_url, dest, branch=branch, depth=depth) + result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="clone") + + if username and password and not dangerously_store_credentials: + sanitized = strip_credentials(clone_url) + if sanitized != clone_url: + repo_dir = dest or _derive_repo_dir(url) + if repo_dir: + repo_cwd = ( + posixpath.join(cwd, repo_dir) if cwd else repo_dir + ) + strip_result = self._run( + build_remote_set_url("origin", sanitized), + cwd=repo_cwd, + envs=envs, + ) + _check_result(strip_result, op="clone (strip credentials)") + + return result + + def init( + self, + path: str = ".", + *, + bare: bool = False, + initial_branch: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Initialize a new git repository. + + Args: + path: Destination path for the repository. + bare: Create a bare repository. + initial_branch: Name for the initial branch (e.g. ``"main"``). + cwd: Working directory for the command. + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Returns: + Command result. + + Raises: + GitCommandError: If init failed. + """ + argv = build_init(path, bare=bare, initial_branch=initial_branch) + result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="init") + return result + + # ── Staging and committing ───────────────────────────────── + + def add( + self, + paths: list[str] | None = None, + *, + all: bool = False, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Stage files for commit. + + Args: + paths: Specific files to stage. If ``None``, stages the + current directory (or all with ``all=True``). + all: Stage all changes including untracked files. + cwd: Working directory (repository root). + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Returns: + Command result. + + Raises: + GitCommandError: If add failed. + """ + argv = build_add(paths, all=all) + result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="add") + return result + + def commit( + self, + message: str, + *, + allow_empty: bool = False, + author_name: str | None = None, + author_email: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Create a commit. + + Args: + message: Commit message. + allow_empty: Allow creating a commit with no changes. + author_name: Override the commit author name. + author_email: Override the commit author email. + cwd: Working directory (repository root). + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Returns: + Command result. + + Raises: + GitCommandError: If commit failed. + """ + argv = build_commit( + message, + allow_empty=allow_empty, + author_name=author_name, + author_email=author_email, + ) + result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="commit") + return result + + # ── Remote sync ──────────────────────────────────────────── + + def push( + self, + remote: str = "origin", + branch: str | None = None, + *, + force: bool = False, + set_upstream: bool = False, + username: str | None = None, + password: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 60, + ) -> CommandResult: + """Push commits to a remote. + + Args: + remote: Remote name. Defaults to ``"origin"``. + branch: Branch to push. Defaults to the current branch. + force: Force-push. + set_upstream: Set upstream tracking reference. + username: Username for HTTP(S) authentication. + password: Password or token for HTTP(S) authentication. + cwd: Working directory (repository root). + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Returns: + Command result. + + Raises: + GitAuthError: If authentication failed. + GitCommandError: If push failed. + """ + if username and password: + return self._with_remote_credentials( + remote=remote, + username=username, + password=password, + operation=lambda: self._run( + build_push(remote, branch, force=force, set_upstream=set_upstream), + cwd=cwd, + envs=envs, + timeout=timeout, + ), + cwd=cwd, + envs=envs, + timeout=timeout, + op="push", + ) + + argv = build_push(remote, branch, force=force, set_upstream=set_upstream) + result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="push") + return result + + def pull( + self, + remote: str = "origin", + branch: str | None = None, + *, + rebase: bool = False, + ff_only: bool = False, + username: str | None = None, + password: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 60, + ) -> CommandResult: + """Pull changes from a remote. + + Args: + remote: Remote name. Defaults to ``"origin"``. + branch: Branch to pull. + rebase: Rebase instead of merge. + ff_only: Only allow fast-forward merges. + username: Username for HTTP(S) authentication. + password: Password or token for HTTP(S) authentication. + cwd: Working directory (repository root). + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Returns: + Command result. + + Raises: + GitAuthError: If authentication failed. + GitCommandError: If pull failed. + """ + if username and password: + return self._with_remote_credentials( + remote=remote, + username=username, + password=password, + operation=lambda: self._run( + build_pull(remote, branch, rebase=rebase, ff_only=ff_only), + cwd=cwd, + envs=envs, + timeout=timeout, + ), + cwd=cwd, + envs=envs, + timeout=timeout, + op="pull", + ) + + argv = build_pull(remote, branch, rebase=rebase, ff_only=ff_only) + result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="pull") + return result + + # ── Status and branches ──────────────────────────────────── + + def status( + self, + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> GitStatus: + """Get repository status. + + Args: + cwd: Working directory (repository root). + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Returns: + Parsed :class:`GitStatus` with branch info and file changes. + + Raises: + GitCommandError: If the command failed. + """ + result = self._run(build_status(), cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="status") + return parse_status(result.stdout) + + def branches( + self, + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> list[GitBranch]: + """List local branches. + + Args: + cwd: Working directory (repository root). + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Returns: + List of :class:`GitBranch`. + + Raises: + GitCommandError: If the command failed. + """ + result = self._run( + build_branches(), cwd=cwd, envs=envs, timeout=timeout + ) + _check_result(result, op="branches") + return parse_branches(result.stdout) + + def create_branch( + self, + name: str, + *, + start_point: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Create and check out a new branch. + + Args: + name: Branch name. + start_point: Commit or ref to branch from. + cwd: Working directory (repository root). + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Returns: + Command result. + + Raises: + GitCommandError: If the command failed. + """ + argv = build_create_branch(name, start_point=start_point) + result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="create_branch") + return result + + def checkout_branch( + self, + name: str, + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Check out an existing branch. + + Args: + name: Branch name. + cwd: Working directory (repository root). + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Returns: + Command result. + + Raises: + GitCommandError: If the command failed. + """ + argv = build_checkout(name) + result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="checkout_branch") + return result + + def delete_branch( + self, + name: str, + *, + force: bool = False, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Delete a branch. + + Args: + name: Branch name. + force: Force-delete with ``-D``. + cwd: Working directory (repository root). + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Returns: + Command result. + + Raises: + GitCommandError: If the command failed. + """ + argv = build_delete_branch(name, force=force) + result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="delete_branch") + return result + + # ── Remotes ──────────────────────────────────────────────── + + def remote_add( + self, + name: str, + url: str, + *, + fetch: bool = False, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Add a remote. + + Args: + name: Remote name (e.g. ``"origin"``). + url: Remote URL. + fetch: Fetch after adding. + cwd: Working directory (repository root). + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Returns: + Command result. + + Raises: + GitCommandError: If the command failed. + """ + argv = build_remote_add(name, url, fetch=fetch) + result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="remote_add") + return result + + def remote_get( + self, + name: str = "origin", + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> str | None: + """Get the URL of a remote. + + Returns ``None`` if the remote does not exist rather than raising. + + Args: + name: Remote name. Defaults to ``"origin"``. + cwd: Working directory (repository root). + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Returns: + Remote URL or ``None``. + """ + result = self._run( + build_remote_get_url(name), cwd=cwd, envs=envs, timeout=timeout + ) + if result.exit_code != 0: + return None + url = result.stdout.strip() + return url or None + + # ── Reset and restore ────────────────────────────────────── + + def reset( + self, + *, + mode: str | None = None, + ref: str | None = None, + paths: list[str] | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Reset the current HEAD. + + Args: + mode: Reset mode (``soft``, ``mixed``, ``hard``, ``merge``, + ``keep``). + ref: Commit, branch, or ref to reset to. + paths: Paths to reset. + cwd: Working directory (repository root). + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Returns: + Command result. + + Raises: + GitCommandError: If the command failed. + """ + argv = build_reset(mode=mode, ref=ref, paths=paths) + result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="reset") + return result + + def restore( + self, + paths: list[str], + *, + staged: bool = False, + worktree: bool = False, + source: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Restore working-tree files or unstage changes. + + Args: + paths: Paths to restore. + staged: Restore the index (unstage). + worktree: Restore working-tree files. + source: Commit or ref to restore from. + cwd: Working directory (repository root). + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Returns: + Command result. + + Raises: + GitCommandError: If the command failed. + """ + argv = build_restore( + paths, staged=staged, worktree=worktree, source=source + ) + result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="restore") + return result + + # ── Configuration ────────────────────────────────────────── + + def set_config( + self, + key: str, + value: str, + *, + scope: str = "local", + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Set a git config value. + + Args: + key: Config key (e.g. ``"user.name"``). + value: Config value. + scope: Config scope: ``"local"``, ``"global"``, or + ``"system"``. + cwd: Working directory (repository root). Required when + scope is ``"local"``. + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Returns: + Command result. + + Raises: + GitCommandError: If the command failed. + """ + argv = build_config_set(key, value, scope=scope, repo_path=cwd) + result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="set_config") + return result + + def get_config( + self, + key: str, + *, + scope: str = "local", + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> str | None: + """Get a git config value. + + Returns ``None`` if the key is not set rather than raising. + + Args: + key: Config key (e.g. ``"user.name"``). + scope: Config scope: ``"local"``, ``"global"``, or + ``"system"``. + cwd: Working directory (repository root). Required when + scope is ``"local"``. + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Returns: + Config value or ``None``. + """ + argv = build_config_get(key, scope=scope, repo_path=cwd) + result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + if result.exit_code != 0: + return None + val = result.stdout.strip() + return val or None + + def configure_user( + self, + name: str, + email: str, + *, + scope: str = "global", + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> None: + """Configure git user name and email. + + Args: + name: Git user name. + email: Git user email. + scope: Config scope. Defaults to ``"global"``. + cwd: Working directory (repository root). Required when + scope is ``"local"``. + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Raises: + ValueError: If *name* or *email* is empty. + GitCommandError: If a config command failed. + """ + if not name or not email: + raise ValueError("Both name and email are required.") + self.set_config("user.name", name, scope=scope, cwd=cwd, envs=envs, timeout=timeout) + self.set_config("user.email", email, scope=scope, cwd=cwd, envs=envs, timeout=timeout) + + def dangerously_authenticate( + self, + username: str, + password: str, + host: str = "github.com", + protocol: str = "https", + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> None: + """Persist git credentials via the credential store. + + .. warning:: + + Credentials are written in plain text to the capsule + filesystem and are accessible to any process running inside + the capsule. Prefer per-operation ``username``/``password`` + parameters on :meth:`clone`, :meth:`push`, and :meth:`pull` + instead. + + Args: + username: Git username. + password: Password or personal access token. + host: Target host. Defaults to ``"github.com"``. + protocol: Protocol. Defaults to ``"https"``. + cwd: Working directory. + envs: Extra environment variables. + timeout: Command timeout in seconds. + + Raises: + ValueError: If *username* or *password* is empty. + GitCommandError: If a command failed. + """ + if not username or not password: + raise ValueError( + "Both username and password are required." + ) + self.set_config( + "credential.helper", "store", + scope="global", cwd=cwd, envs=envs, timeout=timeout, + ) + cmd = build_credential_approve_cmd( + username=username, + password=password, + host=host, + protocol=protocol, + ) + result = self._run_shell(cmd, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="dangerously_authenticate") + + # ── Credential helper for push/pull ──────────────────────── + + def _with_remote_credentials( + self, + *, + remote: str, + username: str, + password: str, + operation: Callable[[], CommandResult], + cwd: str | None, + envs: dict[str, str] | None, + timeout: int | None, + op: str, + ) -> CommandResult: + """Temporarily embed credentials in a remote URL, run an operation, + then restore the original URL. + """ + original_url = self.remote_get(remote, cwd=cwd, envs=envs, timeout=timeout) + if not original_url: + raise GitCommandError( + f"Remote '{remote}' not found.", + stderr="", + exit_code=1, + ) + + credential_url = embed_credentials(original_url, username, password) + self._run( + build_remote_set_url(remote, credential_url), + cwd=cwd, envs=envs, timeout=timeout, + ) + + op_error: Exception | None = None + result: CommandResult | None = None + try: + result = operation() + _check_result(result, op=op) + except Exception as err: + op_error = err + + restore_error: Exception | None = None + try: + self._run( + build_remote_set_url(remote, original_url), + cwd=cwd, envs=envs, timeout=timeout, + ) + except Exception as err: + restore_error = err + + if op_error: + raise op_error + if restore_error: + raise restore_error + + assert result is not None + return result + + +class AsyncGit: + """Async git interface. Accessed via ``capsule.git``. + + Async mirror of :class:`Git`. See that class for full method + documentation. + """ + + def __init__(self, capsule_id: str, http: httpx.AsyncClient) -> None: + self._capsule_id = capsule_id + self._http = http + self._commands = AsyncCommands(capsule_id, http) + + async def _run( + self, + argv: list[str], + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Build a shell command from *argv* and execute it.""" + return await self._commands.run( + shlex.join(argv), + cwd=cwd, + envs=_merge_envs(envs), + timeout=timeout, + ) + + async def _run_shell( + self, + cmd: str, + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Execute a raw shell command string.""" + return await self._commands.run( + cmd, + cwd=cwd, + envs=_merge_envs(envs), + timeout=timeout, + ) + + # ── Repository setup ─────────────────────────────────────── + + async def clone( + self, + url: str, + dest: str | None = None, + *, + branch: str | None = None, + depth: int | None = None, + username: str | None = None, + password: str | None = None, + dangerously_store_credentials: bool = False, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 300, + ) -> CommandResult: + """Clone a remote repository into the capsule.""" + if password and not username: + raise ValueError( + "Username is required when using a password for git clone." + ) + + clone_url = url + if username and password: + clone_url = embed_credentials(url, username, password) + + argv = build_clone(clone_url, dest, branch=branch, depth=depth) + result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="clone") + + if username and password and not dangerously_store_credentials: + sanitized = strip_credentials(clone_url) + if sanitized != clone_url: + repo_dir = dest or _derive_repo_dir(url) + if repo_dir: + repo_cwd = ( + posixpath.join(cwd, repo_dir) if cwd else repo_dir + ) + strip_result = await self._run( + build_remote_set_url("origin", sanitized), + cwd=repo_cwd, + envs=envs, + ) + _check_result(strip_result, op="clone (strip credentials)") + + return result + + async def init( + self, + path: str = ".", + *, + bare: bool = False, + initial_branch: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Initialize a new git repository.""" + argv = build_init(path, bare=bare, initial_branch=initial_branch) + result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="init") + return result + + # ── Staging and committing ───────────────────────────────── + + async def add( + self, + paths: list[str] | None = None, + *, + all: bool = False, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Stage files for commit.""" + argv = build_add(paths, all=all) + result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="add") + return result + + async def commit( + self, + message: str, + *, + allow_empty: bool = False, + author_name: str | None = None, + author_email: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Create a commit.""" + argv = build_commit( + message, + allow_empty=allow_empty, + author_name=author_name, + author_email=author_email, + ) + result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="commit") + return result + + # ── Remote sync ──────────────────────────────────────────── + + async def push( + self, + remote: str = "origin", + branch: str | None = None, + *, + force: bool = False, + set_upstream: bool = False, + username: str | None = None, + password: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 60, + ) -> CommandResult: + """Push commits to a remote.""" + if username and password: + async def _op() -> CommandResult: + return await self._run( + build_push(remote, branch, force=force, set_upstream=set_upstream), + cwd=cwd, envs=envs, timeout=timeout, + ) + + return await self._with_remote_credentials( + remote=remote, + username=username, + password=password, + operation=_op, + cwd=cwd, + envs=envs, + timeout=timeout, + op="push", + ) + + argv = build_push(remote, branch, force=force, set_upstream=set_upstream) + result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="push") + return result + + async def pull( + self, + remote: str = "origin", + branch: str | None = None, + *, + rebase: bool = False, + ff_only: bool = False, + username: str | None = None, + password: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 60, + ) -> CommandResult: + """Pull changes from a remote.""" + if username and password: + async def _op() -> CommandResult: + return await self._run( + build_pull(remote, branch, rebase=rebase, ff_only=ff_only), + cwd=cwd, envs=envs, timeout=timeout, + ) + + return await self._with_remote_credentials( + remote=remote, + username=username, + password=password, + operation=_op, + cwd=cwd, + envs=envs, + timeout=timeout, + op="pull", + ) + + argv = build_pull(remote, branch, rebase=rebase, ff_only=ff_only) + result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="pull") + return result + + # ── Status and branches ──────────────────────────────────── + + async def status( + self, + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> GitStatus: + """Get repository status.""" + result = await self._run( + build_status(), cwd=cwd, envs=envs, timeout=timeout + ) + _check_result(result, op="status") + return parse_status(result.stdout) + + async def branches( + self, + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> list[GitBranch]: + """List local branches.""" + result = await self._run( + build_branches(), cwd=cwd, envs=envs, timeout=timeout + ) + _check_result(result, op="branches") + return parse_branches(result.stdout) + + async def create_branch( + self, + name: str, + *, + start_point: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Create and check out a new branch.""" + argv = build_create_branch(name, start_point=start_point) + result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="create_branch") + return result + + async def checkout_branch( + self, + name: str, + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Check out an existing branch.""" + argv = build_checkout(name) + result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="checkout_branch") + return result + + async def delete_branch( + self, + name: str, + *, + force: bool = False, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Delete a branch.""" + argv = build_delete_branch(name, force=force) + result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="delete_branch") + return result + + # ── Remotes ──────────────────────────────────────────────── + + async def remote_add( + self, + name: str, + url: str, + *, + fetch: bool = False, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Add a remote.""" + argv = build_remote_add(name, url, fetch=fetch) + result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="remote_add") + return result + + async def remote_get( + self, + name: str = "origin", + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> str | None: + """Get the URL of a remote. Returns ``None`` if not found.""" + result = await self._run( + build_remote_get_url(name), cwd=cwd, envs=envs, timeout=timeout + ) + if result.exit_code != 0: + return None + url = result.stdout.strip() + return url or None + + # ── Reset and restore ────────────────────────────────────── + + async def reset( + self, + *, + mode: str | None = None, + ref: str | None = None, + paths: list[str] | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Reset the current HEAD.""" + argv = build_reset(mode=mode, ref=ref, paths=paths) + result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="reset") + return result + + async def restore( + self, + paths: list[str], + *, + staged: bool = False, + worktree: bool = False, + source: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Restore working-tree files or unstage changes.""" + argv = build_restore( + paths, staged=staged, worktree=worktree, source=source + ) + result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="restore") + return result + + # ── Configuration ────────────────────────────────────────── + + async def set_config( + self, + key: str, + value: str, + *, + scope: str = "local", + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """Set a git config value.""" + argv = build_config_set(key, value, scope=scope, repo_path=cwd) + result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="set_config") + return result + + async def get_config( + self, + key: str, + *, + scope: str = "local", + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> str | None: + """Get a git config value. Returns ``None`` if not set.""" + argv = build_config_get(key, scope=scope, repo_path=cwd) + result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + if result.exit_code != 0: + return None + val = result.stdout.strip() + return val or None + + async def configure_user( + self, + name: str, + email: str, + *, + scope: str = "global", + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> None: + """Configure git user name and email.""" + if not name or not email: + raise ValueError("Both name and email are required.") + await self.set_config("user.name", name, scope=scope, cwd=cwd, envs=envs, timeout=timeout) + await self.set_config("user.email", email, scope=scope, cwd=cwd, envs=envs, timeout=timeout) + + async def dangerously_authenticate( + self, + username: str, + password: str, + host: str = "github.com", + protocol: str = "https", + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> None: + """Persist git credentials via the credential store. + + .. warning:: + + Credentials are written in plain text to the capsule + filesystem. Prefer per-operation ``username``/``password`` + parameters instead. + """ + if not username or not password: + raise ValueError( + "Both username and password are required." + ) + await self.set_config( + "credential.helper", "store", + scope="global", cwd=cwd, envs=envs, timeout=timeout, + ) + cmd = build_credential_approve_cmd( + username=username, + password=password, + host=host, + protocol=protocol, + ) + result = await self._run_shell(cmd, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op="dangerously_authenticate") + + # ── Credential helper for push/pull ──────────────────────── + + async def _with_remote_credentials( + self, + *, + remote: str, + username: str, + password: str, + operation: Callable[[], Awaitable[CommandResult]], + cwd: str | None, + envs: dict[str, str] | None, + timeout: int | None, + op: str, + ) -> CommandResult: + """Temporarily embed credentials in a remote URL, run an operation, + then restore the original URL. + """ + original_url = await self.remote_get( + remote, cwd=cwd, envs=envs, timeout=timeout + ) + if not original_url: + raise GitCommandError( + f"Remote '{remote}' not found.", + stderr="", + exit_code=1, + ) + + credential_url = embed_credentials(original_url, username, password) + await self._run( + build_remote_set_url(remote, credential_url), + cwd=cwd, envs=envs, timeout=timeout, + ) + + op_error: Exception | None = None + result: CommandResult | None = None + try: + result = await operation() + _check_result(result, op=op) + except Exception as err: + op_error = err + + restore_error: Exception | None = None + try: + await self._run( + build_remote_set_url(remote, original_url), + cwd=cwd, envs=envs, timeout=timeout, + ) + except Exception as err: + restore_error = err + + if op_error: + raise op_error + if restore_error: + raise restore_error + + assert result is not None + return result diff --git a/src/wrenn/_git/_auth.py b/src/wrenn/_git/_auth.py new file mode 100644 index 0000000..b517cf4 --- /dev/null +++ b/src/wrenn/_git/_auth.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import shlex +from urllib.parse import urlparse, urlunparse + + +def embed_credentials(url: str, username: str, password: str) -> str: + """Embed HTTP(S) credentials into a git URL. + + Args: + url: Git repository URL. + username: Username for authentication. + password: Password or personal access token. + + Returns: + URL with ``username:password@`` embedded in the netloc. + + Raises: + ValueError: If the URL scheme is not ``http`` or ``https``. + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError( + "Only http(s) URLs support embedded credentials." + ) + netloc = f"{username}:{password}@{parsed.hostname}" + if parsed.port: + netloc = f"{netloc}:{parsed.port}" + return urlunparse(parsed._replace(netloc=netloc)) + + +def strip_credentials(url: str) -> str: + """Remove embedded credentials from a git URL. + + Args: + url: Git repository URL, possibly with credentials. + + Returns: + URL with credentials removed. Non-HTTP(S) URLs are returned + unchanged. + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return url + if not parsed.username and not parsed.password: + return url + host = parsed.hostname or "" + if parsed.port: + host = f"{host}:{parsed.port}" + return urlunparse(parsed._replace(netloc=host)) + + +def is_auth_error(stderr: str) -> bool: + """Check whether git stderr indicates an authentication failure. + + Args: + stderr: Combined stderr output from a git command. + + Returns: + ``True`` if any known auth-failure pattern is found. + """ + lower = stderr.lower() + patterns = ( + "authentication failed", + "terminal prompts disabled", + "could not read username", + "invalid username or password", + "access denied", + "permission denied", + "not authorized", + ) + return any(p in lower for p in patterns) + + +def build_credential_approve_cmd( + username: str, + password: str, + host: str = "github.com", + protocol: str = "https", +) -> str: + """Build a shell command that pipes credentials into ``git credential approve``. + + Args: + username: Git username. + password: Password or personal access token. + host: Target host. Defaults to ``"github.com"``. + protocol: Protocol. Defaults to ``"https"``. + + Returns: + A shell command string safe to pass to ``commands.run()``. + """ + if "\n" in username or "\n" in password: + raise ValueError("Credentials must not contain newline characters.") + target_host = host.strip() or "github.com" + target_protocol = protocol.strip() or "https" + credential_input = "\n".join([ + f"protocol={target_protocol}", + f"host={target_host}", + f"username={username}", + f"password={password}", + "", + "", + ]) + return f"printf %s {shlex.quote(credential_input)} | git credential approve" diff --git a/src/wrenn/_git/_cmd.py b/src/wrenn/_git/_cmd.py new file mode 100644 index 0000000..b97a328 --- /dev/null +++ b/src/wrenn/_git/_cmd.py @@ -0,0 +1,495 @@ +"""Pure functions that build git argument lists and parse git output. + +No I/O, no network, no imports from ``wrenn``. Every ``build_*`` function +returns a ``list[str]`` suitable for ``shlex.join()``. Every ``parse_*`` +function takes raw stdout and returns a typed structure. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + + +# ── Data types ───────────────────────────────────────────────────── + +@dataclass +class FileStatus: + """A single entry from ``git status --porcelain=v1``. + + Attributes: + path (str): File path relative to the repository root. + index_status (str): Index (staged) status character. + work_tree_status (str): Working-tree status character. + renamed_from (str | None): Original path when status is a rename. + """ + + path: str + index_status: str + work_tree_status: str + renamed_from: str | None = None + + @property + def staged(self) -> bool: + """Whether the change is staged in the index.""" + return self.index_status not in (" ", "?") + + @property + def status(self) -> str: + """Normalized human-readable status label.""" + return _derive_status(self.index_status, self.work_tree_status) + + +@dataclass +class GitStatus: + """Parsed output of ``git status --porcelain=v1 --branch``. + + Attributes: + branch (str | None): Current branch name, or ``None`` if detached. + upstream (str | None): Upstream tracking branch. + ahead (int): Commits ahead of upstream. + behind (int): Commits behind upstream. + detached (bool): Whether HEAD is detached. + files (list[FileStatus]): Per-file status entries. + """ + + branch: str | None = None + upstream: str | None = None + ahead: int = 0 + behind: int = 0 + detached: bool = False + files: list[FileStatus] = field(default_factory=list) + + @property + def is_clean(self) -> bool: + """``True`` when there are no changed or untracked files.""" + return len(self.files) == 0 + + @property + def has_staged(self) -> bool: + """``True`` when at least one file has staged changes.""" + return any(f.staged for f in self.files) + + @property + def has_untracked(self) -> bool: + """``True`` when at least one file is untracked.""" + return any(f.status == "untracked" for f in self.files) + + @property + def has_conflicts(self) -> bool: + """``True`` when at least one file has merge conflicts.""" + return any(f.status == "conflict" for f in self.files) + + +@dataclass +class GitBranch: + """A single branch entry. + + Attributes: + name (str): Branch name (short ref). + is_current (bool): Whether this is the checked-out branch. + """ + + name: str + is_current: bool = False + + +# ── Argument builders ────────────────────────────────────────────── + +def build_clone( + url: str, + dest: str | None = None, + *, + branch: str | None = None, + depth: int | None = None, +) -> list[str]: + """Build ``git clone`` arguments.""" + args = ["git", "clone"] + if branch: + args.extend(["--branch", branch, "--single-branch"]) + if depth is not None: + args.extend(["--depth", str(depth)]) + args.append(url) + if dest: + args.append(dest) + return args + + +def build_init( + path: str = ".", + *, + bare: bool = False, + initial_branch: str | None = None, +) -> list[str]: + """Build ``git init`` arguments.""" + args = ["git", "init"] + if initial_branch: + args.extend(["--initial-branch", initial_branch]) + if bare: + args.append("--bare") + args.append(path) + return args + + +def build_add( + paths: list[str] | None = None, + *, + all: bool = False, +) -> list[str]: + """Build ``git add`` arguments.""" + args = ["git", "add"] + if not paths: + args.append("-A" if all else ".") + else: + args.append("--") + args.extend(paths) + return args + + +def build_commit( + message: str, + *, + allow_empty: bool = False, + author_name: str | None = None, + author_email: str | None = None, +) -> list[str]: + """Build ``git commit`` arguments.""" + args = ["git"] + if author_name: + args.extend(["-c", f"user.name={author_name}"]) + if author_email: + args.extend(["-c", f"user.email={author_email}"]) + args.extend(["commit", "-m", message]) + if allow_empty: + args.append("--allow-empty") + return args + + +def build_push( + remote: str = "origin", + branch: str | None = None, + *, + force: bool = False, + set_upstream: bool = False, +) -> list[str]: + """Build ``git push`` arguments.""" + args = ["git", "push"] + if force: + args.append("--force") + if set_upstream: + args.append("--set-upstream") + args.append(remote) + if branch: + args.append(branch) + return args + + +def build_pull( + remote: str = "origin", + branch: str | None = None, + *, + rebase: bool = False, + ff_only: bool = False, +) -> list[str]: + """Build ``git pull`` arguments.""" + args = ["git", "pull"] + if rebase: + args.append("--rebase") + if ff_only: + args.append("--ff-only") + args.append(remote) + if branch: + args.append(branch) + return args + + +def build_status() -> list[str]: + """Build ``git status`` arguments for porcelain parsing.""" + return ["git", "status", "--porcelain=v1", "--branch"] + + +def build_branches() -> list[str]: + """Build ``git branch`` arguments for structured parsing.""" + return ["git", "branch", "--format=%(refname:short)\t%(HEAD)"] + + +def build_create_branch( + name: str, + *, + start_point: str | None = None, +) -> list[str]: + """Build ``git checkout -b`` arguments.""" + args = ["git", "checkout", "-b", name] + if start_point: + args.append(start_point) + return args + + +def build_checkout(name: str) -> list[str]: + """Build ``git checkout`` arguments.""" + return ["git", "checkout", name] + + +def build_delete_branch( + name: str, + *, + force: bool = False, +) -> list[str]: + """Build ``git branch -d/-D`` arguments.""" + return ["git", "branch", "-D" if force else "-d", name] + + +def build_remote_add(name: str, url: str, *, fetch: bool = False) -> list[str]: + """Build ``git remote add`` arguments.""" + args = ["git", "remote", "add"] + if fetch: + args.append("-f") + args.extend([name, url]) + return args + + +def build_remote_get_url(name: str = "origin") -> list[str]: + """Build ``git remote get-url`` arguments.""" + return ["git", "remote", "get-url", name] + + +def build_remote_set_url(name: str, url: str) -> list[str]: + """Build ``git remote set-url`` arguments.""" + return ["git", "remote", "set-url", name, url] + + +def build_reset( + *, + mode: str | None = None, + ref: str | None = None, + paths: list[str] | None = None, +) -> list[str]: + """Build ``git reset`` arguments. + + Args: + mode: Reset mode (``soft``, ``mixed``, ``hard``, ``merge``, ``keep``). + ref: Commit, branch, or ref to reset to. + paths: Paths to reset (mutually exclusive with ``mode``). + """ + _ALLOWED_MODES = {"soft", "mixed", "hard", "merge", "keep"} + if mode and mode not in _ALLOWED_MODES: + raise ValueError( + f"Reset mode must be one of {', '.join(sorted(_ALLOWED_MODES))}." + ) + args = ["git", "reset"] + if mode: + args.append(f"--{mode}") + if ref: + args.append(ref) + if paths: + args.append("--") + args.extend(paths) + return args + + +def build_restore( + paths: list[str], + *, + staged: bool = False, + worktree: bool = False, + source: str | None = None, +) -> list[str]: + """Build ``git restore`` arguments. + + Args: + paths: Paths to restore. + staged: Restore the index (unstage). + worktree: Restore working-tree files. + source: Commit or ref to restore from. + """ + if not paths: + raise ValueError("At least one path is required.") + if not staged and not worktree: + worktree = True + args = ["git", "restore"] + if worktree: + args.append("--worktree") + if staged: + args.append("--staged") + if source: + args.extend(["--source", source]) + args.append("--") + args.extend(paths) + return args + + +def build_config_set( + key: str, + value: str, + *, + scope: str = "local", + repo_path: str | None = None, +) -> list[str]: + """Build ``git config`` set arguments.""" + scope_flag = _resolve_scope_flag(scope) + args = ["git"] + if scope == "local" and repo_path: + args.extend(["-C", repo_path]) + args.extend(["config", scope_flag, key, value]) + return args + + +def build_config_get( + key: str, + *, + scope: str = "local", + repo_path: str | None = None, +) -> list[str]: + """Build ``git config --get`` arguments.""" + scope_flag = _resolve_scope_flag(scope) + args = ["git"] + if scope == "local" and repo_path: + args.extend(["-C", repo_path]) + args.extend(["config", scope_flag, "--get", key]) + return args + + +def build_has_upstream() -> list[str]: + """Build arguments to check if current branch has upstream tracking.""" + return ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"] + + +# ── Parsers ──────────────────────────────────────────────────────── + +def parse_status(stdout: str) -> GitStatus: + """Parse ``git status --porcelain=v1 --branch`` output. + + Args: + stdout: Raw stdout from the git status command. + + Returns: + Parsed :class:`GitStatus`. + """ + lines = [line for line in stdout.split("\n") if line.rstrip()] + if not lines: + return GitStatus() + + status = GitStatus() + + branch_line = lines[0] + if branch_line.startswith("## "): + _parse_branch_line(branch_line[3:], status) + + for line in lines[1:]: + if line.startswith("?? "): + status.files.append(FileStatus( + path=line[3:], + index_status="?", + work_tree_status="?", + )) + continue + + if len(line) < 4: + continue + + idx = line[0] + wt = line[1] + path = line[3:] + renamed_from = None + if " -> " in path: + renamed_from, path = path.split(" -> ", 1) + + status.files.append(FileStatus( + path=path, + index_status=idx, + work_tree_status=wt, + renamed_from=renamed_from, + )) + + return status + + +def parse_branches(stdout: str) -> list[GitBranch]: + """Parse ``git branch --format=%(refname:short)\\t%(HEAD)`` output. + + Args: + stdout: Raw stdout from the git branch command. + + Returns: + List of :class:`GitBranch`. + """ + branches: list[GitBranch] = [] + for line in stdout.split("\n"): + line = line.strip() + if not line: + continue + parts = line.split("\t") + name = parts[0] + is_current = len(parts) > 1 and parts[1] == "*" + branches.append(GitBranch(name=name, is_current=is_current)) + return branches + + +# ── Internal helpers ─────────────────────────────────────────────── + +def _resolve_scope_flag(scope: str) -> str: + """Convert a scope name to a git config flag.""" + scope = scope.strip().lower() + if scope == "local": + return "--local" + if scope == "global": + return "--global" + if scope == "system": + return "--system" + raise ValueError( + "Git config scope must be one of: local, global, system." + ) + + +def _parse_branch_line(info: str, status: GitStatus) -> None: + """Parse the ``## branch...upstream [ahead N, behind M]`` header.""" + ahead_start = info.find(" [") + branch_part = info if ahead_start == -1 else info[:ahead_start] + ahead_part = None if ahead_start == -1 else info[ahead_start + 2:-1] + + if branch_part.startswith("HEAD (detached at "): + status.detached = True + status.branch = branch_part[18:].rstrip(")") + elif "detached" in branch_part or branch_part.startswith("HEAD"): + status.detached = True + elif "..." in branch_part: + local, remote = branch_part.split("...", 1) + status.branch = local or None + status.upstream = remote or None + else: + name = ( + branch_part + .replace("No commits yet on ", "") + .replace("Initial commit on ", "") + ) + status.branch = name or None + + if ahead_part: + m = re.search(r"ahead (\d+)", ahead_part) + if m: + status.ahead = int(m.group(1)) + m = re.search(r"behind (\d+)", ahead_part) + if m: + status.behind = int(m.group(1)) + + +def _derive_status(index_status: str, work_tree_status: str) -> str: + """Derive a normalized status label from porcelain XY characters.""" + chars = {index_status, work_tree_status} + if "U" in chars: + return "conflict" + if "R" in chars: + return "renamed" + if "C" in chars: + return "copied" + if "D" in chars: + return "deleted" + if "A" in chars: + return "added" + if "M" in chars: + return "modified" + if "T" in chars: + return "typechange" + if "?" in chars: + return "untracked" + return "unknown" diff --git a/src/wrenn/_git/exceptions.py b/src/wrenn/_git/exceptions.py new file mode 100644 index 0000000..80259b9 --- /dev/null +++ b/src/wrenn/_git/exceptions.py @@ -0,0 +1,30 @@ +from __future__ import annotations + + +class GitError(Exception): + """Base exception for all git operations inside a capsule. + + Not a subclass of :class:`WrennError` because git errors originate + from a process exit code, not an HTTP response. + + Attributes: + message (str): Human-readable error description. + stderr (str): Raw stderr output from the git process. + exit_code (int): Process exit code. + """ + + def __init__( + self, message: str, *, stderr: str = "", exit_code: int = -1 + ) -> None: + self.message = message + self.stderr = stderr + self.exit_code = exit_code + super().__init__(message) + + +class GitCommandError(GitError): + """A git command exited with a non-zero exit code.""" + + +class GitAuthError(GitError): + """Authentication failed when communicating with a remote.""" diff --git a/src/wrenn/async_capsule.py b/src/wrenn/async_capsule.py index cf55560..41c1767 100644 --- a/src/wrenn/async_capsule.py +++ b/src/wrenn/async_capsule.py @@ -7,6 +7,7 @@ from contextlib import asynccontextmanager import httpx_ws +from wrenn._git import AsyncGit from wrenn.capsule import _DualMethod, _build_proxy_url from wrenn.client import AsyncWrennClient from wrenn.commands import AsyncCommands @@ -42,6 +43,7 @@ class AsyncCapsule: self.commands = AsyncCommands(_capsule_id, _client.http) self.files = AsyncFiles(_capsule_id, _client.http) + self.git = AsyncGit(_capsule_id, _client.http) # ── Properties ────────────────────────────────────────────── diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py index 3f35b35..3d70b25 100644 --- a/src/wrenn/capsule.py +++ b/src/wrenn/capsule.py @@ -8,6 +8,7 @@ from typing import Any import httpx import httpx_ws +from wrenn._git import Git from wrenn.client import WrennClient from wrenn.commands import Commands from wrenn.files import Files @@ -111,6 +112,7 @@ class Capsule: self.commands = Commands(self._id, self._client.http) self.files = Files(self._id, self._client.http) + self.git = Git(self._id, self._client.http) if wait: self.wait_ready() diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..1fc7463 --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,944 @@ +from __future__ import annotations + +import pytest +import respx +from httpx import Response + +from wrenn._git import ( + AsyncGit, + FileStatus, + Git, + GitAuthError, + GitBranch, + GitCommandError, + GitError, + GitStatus, + _check_result, + _derive_repo_dir, +) +from wrenn._git._auth import ( + build_credential_approve_cmd, + embed_credentials, + is_auth_error, + strip_credentials, +) +from wrenn._git._cmd import ( + build_add, + build_branches, + build_checkout, + build_clone, + build_commit, + build_config_get, + build_config_set, + build_create_branch, + build_delete_branch, + build_init, + build_pull, + build_push, + build_remote_add, + build_remote_get_url, + build_remote_set_url, + build_reset, + build_restore, + build_status, + parse_branches, + parse_status, +) +from wrenn.commands import CommandResult + +BASE = "https://app.wrenn.dev/api" +CAPSULE_ID = "cl-test123" +EXEC_URL = f"{BASE}/v1/capsules/{CAPSULE_ID}/exec" + + +# ── Helpers ──────────────────────────────────────────────────────── + + +def _exec_response( + stdout: str = "", + stderr: str = "", + exit_code: int = 0, + duration_ms: int = 10, +) -> dict: + """Build a mock exec API response body.""" + return { + "stdout": stdout, + "stderr": stderr, + "exit_code": exit_code, + "duration_ms": duration_ms, + } + + +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") + return Git(CAPSULE_ID, client.http) + + +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") + return AsyncGit(CAPSULE_ID, client.http) + + +# ══════════════════════════════════════════════════════════════════ +# Pure function tests — no I/O, no mocking +# ══════════════════════════════════════════════════════════════════ + + +class TestBuildClone: + def test_basic(self): + args = build_clone("https://github.com/user/repo.git") + assert args == ["git", "clone", "https://github.com/user/repo.git"] + + def test_with_dest(self): + args = build_clone("https://github.com/user/repo.git", "/tmp/repo") + assert args[-1] == "/tmp/repo" + + def test_with_branch(self): + args = build_clone("https://github.com/user/repo.git", branch="main") + assert "--branch" in args + assert "main" in args + assert "--single-branch" in args + + def test_with_depth(self): + args = build_clone("https://github.com/user/repo.git", depth=1) + assert "--depth" in args + assert "1" in args + + def test_all_options(self): + args = build_clone( + "https://github.com/user/repo.git", + "/tmp/repo", + branch="dev", + depth=5, + ) + assert args == [ + "git", "clone", + "--branch", "dev", "--single-branch", + "--depth", "5", + "https://github.com/user/repo.git", + "/tmp/repo", + ] + + +class TestBuildInit: + def test_basic(self): + assert build_init("/repo") == ["git", "init", "/repo"] + + def test_bare(self): + args = build_init("/repo", bare=True) + assert "--bare" in args + + def test_initial_branch(self): + args = build_init("/repo", initial_branch="main") + assert "--initial-branch" in args + assert "main" in args + + +class TestBuildAdd: + def test_default(self): + assert build_add() == ["git", "add", "."] + + def test_all(self): + assert build_add(all=True) == ["git", "add", "-A"] + + def test_specific_files(self): + args = build_add(["file1.py", "file2.py"]) + assert args == ["git", "add", "--", "file1.py", "file2.py"] + + +class TestBuildCommit: + def test_basic(self): + args = build_commit("initial commit") + assert args == ["git", "commit", "-m", "initial commit"] + + def test_allow_empty(self): + args = build_commit("empty", allow_empty=True) + assert "--allow-empty" in args + + def test_author_override(self): + args = build_commit("msg", author_name="Bob", author_email="bob@test.com") + assert "-c" in args + assert "user.name=Bob" in args + assert "user.email=bob@test.com" in args + + +class TestBuildPush: + def test_basic(self): + assert build_push() == ["git", "push", "origin"] + + def test_with_branch(self): + args = build_push("origin", "main") + assert args == ["git", "push", "origin", "main"] + + def test_force(self): + args = build_push(force=True) + assert "--force" in args + + def test_set_upstream(self): + args = build_push(set_upstream=True) + assert "--set-upstream" in args + + +class TestBuildPull: + def test_basic(self): + assert build_pull() == ["git", "pull", "origin"] + + def test_rebase(self): + args = build_pull(rebase=True) + assert "--rebase" in args + + def test_ff_only(self): + args = build_pull(ff_only=True) + assert "--ff-only" in args + + def test_with_branch(self): + args = build_pull("upstream", "feature") + assert args == ["git", "pull", "upstream", "feature"] + + +class TestBuildStatus: + def test_args(self): + assert build_status() == ["git", "status", "--porcelain=v1", "--branch"] + + +class TestBuildBranches: + def test_args(self): + assert build_branches() == [ + "git", "branch", "--format=%(refname:short)\t%(HEAD)" + ] + + +class TestBuildBranchOps: + def test_create(self): + assert build_create_branch("feat") == ["git", "checkout", "-b", "feat"] + + def test_create_with_start_point(self): + args = build_create_branch("feat", start_point="abc123") + assert args == ["git", "checkout", "-b", "feat", "abc123"] + + def test_checkout(self): + assert build_checkout("main") == ["git", "checkout", "main"] + + def test_delete(self): + assert build_delete_branch("old") == ["git", "branch", "-d", "old"] + + def test_force_delete(self): + assert build_delete_branch("old", force=True) == ["git", "branch", "-D", "old"] + + +class TestBuildRemote: + def test_add(self): + args = build_remote_add("origin", "https://example.com/repo.git") + assert args == ["git", "remote", "add", "origin", "https://example.com/repo.git"] + + def test_add_with_fetch(self): + args = build_remote_add("origin", "https://example.com/repo.git", fetch=True) + assert "-f" in args + + def test_get_url(self): + assert build_remote_get_url("origin") == ["git", "remote", "get-url", "origin"] + + def test_set_url(self): + args = build_remote_set_url("origin", "https://new.url/repo.git") + assert args == ["git", "remote", "set-url", "origin", "https://new.url/repo.git"] + + +class TestBuildReset: + def test_basic(self): + assert build_reset() == ["git", "reset"] + + def test_hard(self): + args = build_reset(mode="hard") + assert args == ["git", "reset", "--hard"] + + def test_with_ref(self): + args = build_reset(mode="soft", ref="HEAD~1") + assert args == ["git", "reset", "--soft", "HEAD~1"] + + def test_with_paths(self): + args = build_reset(paths=["file.py"]) + assert args == ["git", "reset", "--", "file.py"] + + def test_invalid_mode(self): + with pytest.raises(ValueError, match="Reset mode"): + build_reset(mode="invalid") + + +class TestBuildRestore: + def test_basic(self): + args = build_restore(["file.py"]) + assert args == ["git", "restore", "--worktree", "--", "file.py"] + + def test_staged(self): + args = build_restore(["file.py"], staged=True) + assert "--staged" in args + + def test_both(self): + args = build_restore(["file.py"], staged=True, worktree=True) + assert "--staged" in args + assert "--worktree" in args + + def test_with_source(self): + args = build_restore(["file.py"], source="HEAD~1") + assert "--source" in args + assert "HEAD~1" in args + + def test_empty_paths_raises(self): + with pytest.raises(ValueError, match="At least one path"): + build_restore([]) + + +class TestBuildConfig: + def test_set_local(self): + args = build_config_set("user.name", "Bob", scope="local", repo_path="/repo") + assert args == ["git", "-C", "/repo", "config", "--local", "user.name", "Bob"] + + def test_set_global(self): + args = build_config_set("user.name", "Bob", scope="global") + assert args == ["git", "config", "--global", "user.name", "Bob"] + + def test_get_global(self): + args = build_config_get("user.name", scope="global") + assert args == ["git", "config", "--global", "--get", "user.name"] + + def test_invalid_scope(self): + with pytest.raises(ValueError, match="scope"): + build_config_set("key", "val", scope="invalid") + + +# ── Parser tests ─────────────────────────────────────────────────── + + +class TestParseStatus: + def test_empty(self): + status = parse_status("") + assert status.branch is None + assert status.is_clean is True + assert status.files == [] + + def test_clean_repo(self): + status = parse_status("## main...origin/main\n") + assert status.branch == "main" + assert status.upstream == "origin/main" + assert status.is_clean is True + + def test_modified_file(self): + status = parse_status("## main\n M file.py\n") + assert len(status.files) == 1 + f = status.files[0] + assert f.path == "file.py" + assert f.work_tree_status == "M" + assert f.status == "modified" + assert f.staged is False + + def test_staged_file(self): + status = parse_status("## main\nM file.py\n") + f = status.files[0] + assert f.index_status == "M" + assert f.staged is True + + def test_untracked(self): + status = parse_status("## main\n?? new.txt\n") + f = status.files[0] + assert f.status == "untracked" + assert f.staged is False + + def test_renamed(self): + status = parse_status("## main\nR old.py -> new.py\n") + f = status.files[0] + assert f.status == "renamed" + assert f.path == "new.py" + assert f.renamed_from == "old.py" + + def test_ahead_behind(self): + status = parse_status("## main...origin/main [ahead 3, behind 1]\n") + assert status.ahead == 3 + assert status.behind == 1 + + def test_ahead_only(self): + status = parse_status("## main...origin/main [ahead 2]\n") + assert status.ahead == 2 + assert status.behind == 0 + + def test_detached_head(self): + status = parse_status("## HEAD (detached at abc1234)\n") + assert status.detached is True + assert status.branch == "abc1234" + + def test_no_commits_yet(self): + status = parse_status("## No commits yet on main\n") + assert status.branch == "main" + + def test_multiple_files(self): + output = "## dev\nM a.py\n M b.py\n?? c.txt\nA d.py\nD e.py\n" + status = parse_status(output) + assert len(status.files) == 5 + assert status.has_staged is True + assert status.has_untracked is True + + def test_has_conflicts(self): + status = parse_status("## main\nUU conflict.py\n") + assert status.has_conflicts is True + assert status.files[0].status == "conflict" + + +class TestParseBranches: + def test_single_branch(self): + branches = parse_branches("main\t*\n") + assert len(branches) == 1 + assert branches[0].name == "main" + assert branches[0].is_current is True + + def test_multiple(self): + branches = parse_branches("main\t*\ndev\t \nfeature\t \n") + assert len(branches) == 3 + current = [b for b in branches if b.is_current] + assert len(current) == 1 + assert current[0].name == "main" + + def test_empty(self): + branches = parse_branches("") + assert branches == [] + + def test_no_current(self): + branches = parse_branches("main\t \ndev\t \n") + assert all(not b.is_current for b in branches) + + +# ── Auth helper tests ────────────────────────────────────────────── + + +class TestEmbedCredentials: + def test_basic(self): + url = embed_credentials("https://github.com/user/repo.git", "user", "token") + assert url == "https://user:token@github.com/user/repo.git" + + def test_with_port(self): + url = embed_credentials("https://git.example.com:8443/repo.git", "u", "p") + assert "u:p@git.example.com:8443" in url + + def test_ssh_raises(self): + with pytest.raises(ValueError, match="http"): + embed_credentials("git@github.com:user/repo.git", "u", "p") + + +class TestStripCredentials: + def test_basic(self): + url = strip_credentials("https://user:token@github.com/user/repo.git") + assert url == "https://github.com/user/repo.git" + + def test_no_credentials(self): + url = "https://github.com/user/repo.git" + assert strip_credentials(url) == url + + def test_ssh_unchanged(self): + url = "git@github.com:user/repo.git" + assert strip_credentials(url) == url + + +class TestIsAuthError: + @pytest.mark.parametrize("msg", [ + "fatal: Authentication failed for 'https://...'", + "fatal: could not read Username", + "remote: Invalid username or password", + "fatal: terminal prompts disabled", + "Permission denied (publickey)", + ]) + def test_auth_patterns(self, msg): + assert is_auth_error(msg) is True + + @pytest.mark.parametrize("msg", [ + "fatal: repository 'https://...' not found", + "error: pathspec 'foo' did not match any file(s)", + "", + ]) + def test_non_auth_patterns(self, msg): + assert is_auth_error(msg) is False + + +class TestBuildCredentialApproveCmd: + def test_basic(self): + cmd = build_credential_approve_cmd("user", "token123", "github.com", "https") + assert "git credential approve" in cmd + assert "protocol=https" in cmd + assert "host=github.com" in cmd + assert "username=user" in cmd + assert "password=token123" in cmd + + def test_newline_rejected(self): + with pytest.raises(ValueError, match="newline"): + build_credential_approve_cmd("user", "tok\nen", "github.com", "https") + + +# ── _check_result tests ─────────────────────────────────────────── + + +class TestCheckResult: + def test_success(self): + result = CommandResult(stdout="ok\n", stderr="", exit_code=0) + _check_result(result, op="test") # should not raise + + def test_generic_failure(self): + result = CommandResult(stdout="", stderr="fatal: bad thing", exit_code=1) + with pytest.raises(GitCommandError) as exc_info: + _check_result(result, op="push") + assert exc_info.value.exit_code == 1 + assert "fatal: bad thing" in exc_info.value.message + + def test_auth_failure(self): + result = CommandResult( + stdout="", stderr="fatal: Authentication failed for 'https://...'", exit_code=128 + ) + with pytest.raises(GitAuthError) as exc_info: + _check_result(result, op="clone") + assert "authentication failed" in exc_info.value.message + assert exc_info.value.exit_code == 128 + + def test_fallback_message(self): + result = CommandResult(stdout="", stderr="", exit_code=42) + with pytest.raises(GitCommandError, match="git test failed"): + _check_result(result, op="test") + + +# ── _derive_repo_dir tests ──────────────────────────────────────── + + +class TestDeriveRepoDir: + def test_basic(self): + assert _derive_repo_dir("https://github.com/user/repo.git") == "repo" + + def test_no_git_suffix(self): + assert _derive_repo_dir("https://github.com/user/repo") == "repo" + + def test_trailing_slash(self): + assert _derive_repo_dir("https://github.com/user/repo.git/") == "repo" + + def test_ssh_returns_none(self): + assert _derive_repo_dir("git@github.com:user/repo.git") is None + + def test_empty_path(self): + assert _derive_repo_dir("https://github.com") is None + + +# ── FileStatus property tests ───────────────────────────────────── + + +class TestFileStatus: + def test_staged_property(self): + f = FileStatus(path="a.py", index_status="M", work_tree_status=" ") + assert f.staged is True + + def test_not_staged(self): + f = FileStatus(path="a.py", index_status=" ", work_tree_status="M") + assert f.staged is False + + def test_untracked_not_staged(self): + f = FileStatus(path="a.py", index_status="?", work_tree_status="?") + assert f.staged is False + + def test_status_property(self): + cases = [ + (("U", " "), "conflict"), + (("R", " "), "renamed"), + (("C", " "), "copied"), + (("D", " "), "deleted"), + (("A", " "), "added"), + (("M", " "), "modified"), + (("T", " "), "typechange"), + (("?", "?"), "untracked"), + ((" ", " "), "unknown"), + ] + for (idx, wt), expected in cases: + f = FileStatus(path="x", index_status=idx, work_tree_status=wt) + assert f.status == expected, f"Expected {expected} for ({idx!r}, {wt!r})" + + +# ── GitStatus property tests ────────────────────────────────────── + + +class TestGitStatus: + def test_is_clean(self): + s = GitStatus() + assert s.is_clean is True + + def test_has_staged(self): + s = GitStatus(files=[ + FileStatus(path="a.py", index_status="M", work_tree_status=" "), + ]) + assert s.has_staged is True + + def test_has_untracked(self): + s = GitStatus(files=[ + FileStatus(path="a.py", index_status="?", work_tree_status="?"), + ]) + assert s.has_untracked is True + + def test_has_conflicts(self): + s = GitStatus(files=[ + FileStatus(path="a.py", index_status="U", work_tree_status="U"), + ]) + assert s.has_conflicts is True + + +# ══════════════════════════════════════════════════════════════════ +# Integration tests — Git class with mocked HTTP +# ══════════════════════════════════════════════════════════════════ + + +class TestGitInit: + @respx.mock + def test_init(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stdout="Initialized empty Git repository in /repo/.git/\n" + )) + git = _make_git() + result = git.init("/repo") + assert result.exit_code == 0 + + @respx.mock + def test_init_failure(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stderr="fatal: cannot mkdir /readonly", exit_code=128 + )) + git = _make_git() + with pytest.raises(GitCommandError): + git.init("/readonly") + + +class TestGitClone: + @respx.mock + def test_clone_basic(self): + route = respx.post(EXEC_URL).respond(200, json=_exec_response( + stderr="Cloning into 'repo'...\n" + )) + git = _make_git() + result = git.clone("https://github.com/user/repo.git") + assert result.exit_code == 0 + req_body = route.calls[0].request.content.decode() + assert "git clone" in req_body + + @respx.mock + def test_clone_auth_failure(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stderr="fatal: Authentication failed for 'https://...'", + exit_code=128, + )) + git = _make_git() + with pytest.raises(GitAuthError): + git.clone("https://github.com/private/repo.git") + + def test_clone_password_without_username(self): + git = _make_git() + with pytest.raises(ValueError, match="Username is required"): + git.clone("https://github.com/user/repo.git", password="token") + + @respx.mock + def test_clone_with_credentials_strips(self): + # First call: clone. Second call: set-url to strip creds. + respx.post(EXEC_URL).respond(200, json=_exec_response()) + git = _make_git() + git.clone( + "https://github.com/user/repo.git", + dest="/tmp/repo", + username="user", + password="token", + ) + # Should have made 2 calls: clone + set-url + assert len(respx.calls) == 2 + + +class TestGitAdd: + @respx.mock + def test_add_all(self): + respx.post(EXEC_URL).respond(200, json=_exec_response()) + git = _make_git() + result = git.add(all=True, cwd="/repo") + assert result.exit_code == 0 + + +class TestGitCommit: + @respx.mock + def test_commit(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stdout="[main abc1234] initial commit\n" + )) + git = _make_git() + result = git.commit("initial commit", cwd="/repo") + assert result.exit_code == 0 + + @respx.mock + def test_commit_nothing_to_commit(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stdout="nothing to commit, working tree clean\n", + stderr="", + exit_code=1, + )) + git = _make_git() + with pytest.raises(GitCommandError): + git.commit("empty", cwd="/repo") + + +class TestGitPushPull: + @respx.mock + def test_push(self): + respx.post(EXEC_URL).respond(200, json=_exec_response()) + git = _make_git() + result = git.push(cwd="/repo") + assert result.exit_code == 0 + + @respx.mock + def test_pull(self): + respx.post(EXEC_URL).respond(200, json=_exec_response()) + git = _make_git() + result = git.pull(cwd="/repo") + assert result.exit_code == 0 + + +class TestGitStatus: + @respx.mock + def test_status(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stdout="## main...origin/main [ahead 1]\n M file.py\n?? new.txt\n" + )) + git = _make_git() + status = git.status(cwd="/repo") + assert isinstance(status, GitStatus) + assert status.branch == "main" + assert status.ahead == 1 + assert len(status.files) == 2 + + +class TestGitBranches: + @respx.mock + def test_branches(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stdout="main\t*\ndev\t \n" + )) + git = _make_git() + branches = git.branches(cwd="/repo") + assert len(branches) == 2 + assert branches[0].name == "main" + assert branches[0].is_current is True + + @respx.mock + def test_create_branch(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stderr="Switched to a new branch 'feat'\n" + )) + git = _make_git() + result = git.create_branch("feat", cwd="/repo") + assert result.exit_code == 0 + + @respx.mock + def test_checkout_branch(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stderr="Switched to branch 'main'\n" + )) + git = _make_git() + result = git.checkout_branch("main", cwd="/repo") + assert result.exit_code == 0 + + @respx.mock + def test_delete_branch(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stdout="Deleted branch old (was abc1234).\n" + )) + git = _make_git() + result = git.delete_branch("old", cwd="/repo") + assert result.exit_code == 0 + + +class TestGitRemote: + @respx.mock + def test_remote_add(self): + respx.post(EXEC_URL).respond(200, json=_exec_response()) + git = _make_git() + result = git.remote_add("origin", "https://example.com/repo.git", cwd="/repo") + assert result.exit_code == 0 + + @respx.mock + def test_remote_get(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stdout="https://example.com/repo.git\n" + )) + git = _make_git() + url = git.remote_get("origin", cwd="/repo") + assert url == "https://example.com/repo.git" + + @respx.mock + def test_remote_get_not_found(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stderr="fatal: No such remote 'nope'", exit_code=2 + )) + git = _make_git() + url = git.remote_get("nope", cwd="/repo") + assert url is None + + +class TestGitResetRestore: + @respx.mock + def test_reset(self): + respx.post(EXEC_URL).respond(200, json=_exec_response()) + git = _make_git() + result = git.reset(mode="hard", ref="HEAD~1", cwd="/repo") + assert result.exit_code == 0 + + @respx.mock + def test_restore(self): + respx.post(EXEC_URL).respond(200, json=_exec_response()) + git = _make_git() + result = git.restore(["file.py"], staged=True, cwd="/repo") + assert result.exit_code == 0 + + +class TestGitConfig: + @respx.mock + def test_set_config(self): + respx.post(EXEC_URL).respond(200, json=_exec_response()) + git = _make_git() + result = git.set_config("user.name", "Bob", scope="global") + assert result.exit_code == 0 + + @respx.mock + def test_get_config(self): + respx.post(EXEC_URL).respond(200, json=_exec_response(stdout="Bob\n")) + git = _make_git() + val = git.get_config("user.name", scope="global") + assert val == "Bob" + + @respx.mock + def test_get_config_not_set(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stderr="", exit_code=1 + )) + git = _make_git() + val = git.get_config("nonexistent.key", scope="global") + assert val is None + + @respx.mock + def test_configure_user(self): + respx.post(EXEC_URL).respond(200, json=_exec_response()) + git = _make_git() + git.configure_user("Bob", "bob@test.com", scope="global") + assert len(respx.calls) == 2 # user.name + user.email + + def test_configure_user_empty_name(self): + git = _make_git() + with pytest.raises(ValueError, match="Both name and email"): + git.configure_user("", "bob@test.com") + + +class TestDangerouslyAuthenticate: + @respx.mock + def test_authenticate(self): + respx.post(EXEC_URL).respond(200, json=_exec_response()) + git = _make_git() + git.dangerously_authenticate("user", "token123") + # Should make 2 calls: config set + credential approve + assert len(respx.calls) == 2 + + def test_empty_credentials(self): + git = _make_git() + with pytest.raises(ValueError, match="Both username and password"): + git.dangerously_authenticate("", "token") + + +# ── Exception hierarchy tests ───────────────────────────────────── + + +class TestExceptionHierarchy: + def test_git_command_error_is_git_error(self): + assert issubclass(GitCommandError, GitError) + + def test_git_auth_error_is_git_error(self): + assert issubclass(GitAuthError, GitError) + + def test_git_error_is_not_wrenn_error(self): + from wrenn.exceptions import WrennError + + assert not issubclass(GitError, WrennError) + + def test_error_attributes(self): + err = GitCommandError("msg", stderr="err output", exit_code=42) + assert err.message == "msg" + assert err.stderr == "err output" + assert err.exit_code == 42 + assert str(err) == "msg" + + +# ── Capsule wiring tests ────────────────────────────────────────── + + +class TestCapsuleWiring: + @respx.mock + def test_capsule_has_git(self): + from wrenn.capsule import Capsule + + respx.post(f"{BASE}/v1/capsules").respond( + 201, json={"id": "cl-1", "status": "pending"} + ) + cap = Capsule(api_key="wrn_test1234567890abcdef12345678") + assert hasattr(cap, "git") + assert isinstance(cap.git, Git) + + +# ── Async tests ─────────────────────────────────────────────────── + + +class TestAsyncGit: + @pytest.mark.asyncio + @respx.mock + async def test_async_init(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stdout="Initialized empty Git repository\n" + )) + git = _make_async_git() + result = await git.init("/repo") + assert result.exit_code == 0 + + @pytest.mark.asyncio + @respx.mock + async def test_async_status(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stdout="## main\n M file.py\n" + )) + git = _make_async_git() + status = await git.status(cwd="/repo") + assert isinstance(status, GitStatus) + assert status.branch == "main" + + @pytest.mark.asyncio + @respx.mock + async def test_async_clone_auth_error(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stderr="fatal: Authentication failed", exit_code=128 + )) + git = _make_async_git() + with pytest.raises(GitAuthError): + await git.clone("https://github.com/private/repo.git") + + @pytest.mark.asyncio + @respx.mock + async def test_async_commit(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stdout="[main abc1234] test\n" + )) + git = _make_async_git() + result = await git.commit("test", cwd="/repo") + assert result.exit_code == 0 + + @pytest.mark.asyncio + @respx.mock + async def test_async_branches(self): + respx.post(EXEC_URL).respond(200, json=_exec_response( + stdout="main\t*\ndev\t \n" + )) + git = _make_async_git() + branches = await git.branches(cwd="/repo") + assert len(branches) == 2 diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index 9cba1c8..0000000 --- a/tests/test_integration.py +++ /dev/null @@ -1,568 +0,0 @@ -from __future__ import annotations - -import os -from typing import Generator - -import pytest - -from wrenn.client import AsyncWrennClient, WrennClient -from wrenn.exceptions import WrennNotFoundError, WrennValidationError -from wrenn.pty import PtyEventType - -WRENN_API_KEY = os.environ.get("WRENN_API_KEY") -WRENN_TOKEN = os.environ.get("WRENN_TOKEN") -WRENN_BASE_URL = os.environ.get("WRENN_BASE_URL", "http://localhost:8080") -WRENN_TEST_EMAIL = os.environ.get("WRENN_TEST_EMAIL") -WRENN_TEST_PASSWORD = os.environ.get("WRENN_TEST_PASSWORD") - - -def _has_auth() -> bool: - return bool(WRENN_API_KEY or WRENN_TOKEN) - - -requires_auth = pytest.mark.skipif( - not _has_auth(), - reason="Set WRENN_API_KEY or WRENN_TOKEN to run integration tests", -) - - -@pytest.fixture -def client() -> Generator[WrennClient, None, None]: - with WrennClient( - api_key=WRENN_API_KEY, - token=WRENN_TOKEN, - base_url=WRENN_BASE_URL, - ) as c: - yield c - - -@pytest.fixture -def async_client() -> AsyncWrennClient: - return AsyncWrennClient( - api_key=WRENN_API_KEY, - token=WRENN_TOKEN, - base_url=WRENN_BASE_URL, - ) - - -@pytest.fixture -def bearer_client() -> Generator[WrennClient, None, None]: - if WRENN_TOKEN: - with WrennClient(token=WRENN_TOKEN, base_url=WRENN_BASE_URL) as c: - yield c - elif WRENN_TEST_EMAIL and WRENN_TEST_PASSWORD: - with WrennClient( - api_key=WRENN_API_KEY, token=WRENN_TOKEN, base_url=WRENN_BASE_URL - ) as c: - resp = c.auth.login(WRENN_TEST_EMAIL, WRENN_TEST_PASSWORD) - with WrennClient(token=resp.token, base_url=WRENN_BASE_URL) as c: - yield c - else: - pytest.skip( - "Set WRENN_TOKEN or WRENN_TEST_EMAIL+WRENN_TEST_PASSWORD for bearer-auth tests" - ) - - -@requires_auth -class TestCapsuleLifecycle: - def test_create_exec_destroy(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - result = cap.exec("echo", args=["hello"]) - assert result.exit_code == 0 - assert "hello" in result.stdout - - def test_exec_with_args(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - result = cap.exec("echo", args=["hello", "world"]) - assert result.exit_code == 0 - assert "hello world" in result.stdout - - def test_exec_nonzero_exit(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - result = cap.exec("sh", args=["-c", "exit 42"]) - assert result.exit_code == 42 - - def test_exec_stderr(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - result = cap.exec("sh", args=["-c", "echo err>&2"]) - assert result.exit_code == 0 - assert "err" in result.stderr - - def test_context_manager_cleanup(self, client): - cap = client.capsules.create(template="minimal", timeout_sec=120) - cap_id = cap.id - - with cap: - cap.wait_ready(timeout=60, interval=1) - - fetched = client.capsules.get(cap_id) - assert fetched.status in ("stopped", "destroyed") - - -@requires_auth -class TestFileIO: - def test_upload_and_download(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - content = b"Hello from integration test!" - cap.upload("/tmp/test_file.txt", content) - downloaded = cap.download("/tmp/test_file.txt") - assert downloaded == content - - def test_download_nonexistent_file(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - with pytest.raises(Exception): - cap.download("/tmp/no_such_file_12345") - - -@requires_auth -class TestPauseResume: - def test_pause_and_resume(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - cap.pause() - assert cap.status == "paused" - - cap.resume() - cap.wait_ready(timeout=60, interval=1) - - result = cap.exec("echo", args=["resumed"]) - assert result.exit_code == 0 - assert "resumed" in result.stdout - - -@requires_auth -class TestPing: - def test_ping_resets_timer(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - cap.ping() - result = cap.exec("echo", args=["still_alive"]) - assert result.exit_code == 0 - assert "still_alive" in result.stdout - - -@requires_auth -class TestProxy: - def test_get_url(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - url = cap.get_url(8888) - assert cap.id in url - assert "8888" in url - - -@requires_auth -class TestListAndGet: - def test_list_capsules(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - boxes = client.capsules.list() - ids = [b.id for b in boxes] - assert cap.id in ids - - def test_get_existing_capsule(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - fetched = client.capsules.get(cap.id) - assert fetched.id == cap.id - assert fetched.status == "running" - - def test_get_nonexistent_capsule(self, client): - with pytest.raises((WrennNotFoundError, WrennValidationError)): - client.capsules.get("cl-nonexistent00000000000000000") - - -@requires_auth -class TestSnapshots: - def test_list_templates(self, client): - templates = client.snapshots.list() - assert isinstance(templates, list) - - -@requires_auth -class TestAPIKeys: - def test_create_list_delete(self, bearer_client): - key_resp = bearer_client.api_keys.create(name="integration-test-key") - assert key_resp.name == "integration-test-key" - assert key_resp.key is not None - assert key_resp.id is not None - - try: - keys = bearer_client.api_keys.list() - ids = [k.id for k in keys] - assert key_resp.id in ids - finally: - bearer_client.api_keys.delete(key_resp.id) - - -@requires_auth -class TestRunCode: - def test_basic_execution(self, client): - with client.capsules.create( - template="python-interpreter-v0-beta", timeout_sec=120 - ) as cap: - cap.wait_ready(timeout=60, interval=1) - - r = cap.run_code("x = 42") - assert r.error is None - - r = cap.run_code("x * 2") - assert r.text == "84" - - def test_state_persists(self, client): - with client.capsules.create( - template="python-interpreter-v0-beta", timeout_sec=120 - ) as cap: - cap.wait_ready(timeout=60, interval=1) - - cap.run_code("def greet(name): return f'hello {name}'") - r = cap.run_code("greet('capsule')") - assert "hello capsule" in (r.text or "") - - def test_error_traceback(self, client): - with client.capsules.create( - template="python-interpreter-v0-beta", timeout_sec=120 - ) as cap: - cap.wait_ready(timeout=60, interval=1) - - r = cap.run_code("1/0") - assert r.error is not None - assert "ZeroDivisionError" in r.error - - def test_stdout_capture(self, client): - with client.capsules.create( - template="python-interpreter-v0-beta", timeout_sec=120 - ) as cap: - cap.wait_ready(timeout=60, interval=1) - - r = cap.run_code("print('hello from kernel')") - assert "hello from kernel" in r.stdout - - -@requires_auth -class TestAsyncCapsuleLifecycle: - @pytest.mark.asyncio - async def test_async_create_exec_destroy(self, async_client): - async with async_client: - cap = await async_client.capsules.create( - template="minimal", timeout_sec=120 - ) - try: - await cap.async_wait_ready(timeout=60, interval=1) - result = await cap.async_exec("echo", args=["async_hello"]) - assert result.exit_code == 0 - assert "async_hello" in result.stdout - finally: - await cap.async_destroy() - - @pytest.mark.asyncio - async def test_async_upload_download(self, async_client): - async with async_client: - cap = await async_client.capsules.create( - template="minimal", timeout_sec=120 - ) - try: - await cap.async_wait_ready(timeout=60, interval=1) - content = b"Async upload test" - await cap.async_upload("/tmp/async_test.txt", content) - downloaded = await cap.async_download("/tmp/async_test.txt") - assert downloaded == content - finally: - await cap.async_destroy() - - @pytest.mark.asyncio - async def test_async_run_code(self, async_client): - async with async_client: - cap = await async_client.capsules.create( - template="python-interpreter-v0-beta", timeout_sec=120 - ) - try: - await cap.async_wait_ready(timeout=60, interval=1) - r = await cap.async_run_code("42 * 2") - assert r.text == "84" - finally: - await cap.async_destroy() - - -@requires_auth -class TestFilesystemListDir: - def test_list_dir_root(self, client: WrennClient): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - cap.mkdir("/tmp/ls_test_root") - cap.upload("/tmp/ls_test_root/hello.txt", b"hello") - entries = cap.list_dir("/tmp/ls_test_root") - assert isinstance(entries, list) - names = [e.name for e in entries] - assert "hello.txt" in names - - def test_list_dir_after_mkdir(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - cap.mkdir("/tmp/fs_test_dir") - entries = cap.list_dir("/tmp") - names = [e.name for e in entries] - assert "fs_test_dir" in names - - def test_list_dir_file_metadata(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - cap.upload("/tmp/meta_test.txt", b"hello world") - entries = cap.list_dir("/tmp") - match = [e for e in entries if e.name == "meta_test.txt"] - assert len(match) == 1 - f = match[0] - assert f.type == "file" - assert f.size == 11 - assert f.permissions is not None - assert f.owner is not None - assert f.group is not None - assert f.modified_at is not None - - def test_list_dir_depth(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - cap.mkdir("/tmp/depth_a/depth_b") - cap.upload("/tmp/depth_a/depth_b/nested.txt", b"deep") - entries = cap.list_dir("/tmp/depth_a", depth=2) - paths = [e.path for e in entries] - assert any("nested.txt" in p for p in paths) - - def test_list_dir_empty_directory(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - cap.mkdir("/tmp/empty_dir_test") - entries = cap.list_dir("/tmp/empty_dir_test") - assert entries == [] - - -@requires_auth -class TestFilesystemMkdir: - def test_mkdir_creates_directory(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - entry = cap.mkdir("/tmp/mkdir_test") - assert entry.name == "mkdir_test" - assert entry.type == "directory" - assert entry.path == "/tmp/mkdir_test" - - def test_mkdir_creates_parents(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - entry = cap.mkdir("/tmp/a/b/c/d") - assert entry.type == "directory" - - def test_mkdir_already_exists(self, client: WrennClient): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - cap.mkdir("/tmp/exist_test") - entry = cap.mkdir("/tmp/exist_test") - assert entry.type == "directory" - - -@requires_auth -class TestFilesystemRemove: - def test_remove_file(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - cap.upload("/tmp/rm_test.txt", b"delete me") - entries_before = cap.list_dir("/tmp") - assert any(e.name == "rm_test.txt" for e in entries_before) - cap.remove("/tmp/rm_test.txt") - entries_after = cap.list_dir("/tmp") - assert not any(e.name == "rm_test.txt" for e in entries_after) - - def test_remove_directory(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - cap.mkdir("/tmp/rm_dir_test") - cap.upload("/tmp/rm_dir_test/file.txt", b"inside") - cap.remove("/tmp/rm_dir_test") - entries = cap.list_dir("/tmp") - assert not any(e.name == "rm_dir_test" for e in entries) - - def test_upload_download_remove_roundtrip(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - content = b"round trip test data " * 100 - cap.upload("/tmp/rt.txt", content) - downloaded = cap.download("/tmp/rt.txt") - assert downloaded == content - cap.remove("/tmp/rt.txt") - with pytest.raises(Exception): - cap.download("/tmp/rt.txt") - - -@requires_auth -class TestStreamUploadDownload: - def test_stream_upload_and_download(self, client: WrennClient): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - chunks = [b"chunk0_", b"chunk1_", b"chunk2"] - - def data_gen(): - yield from chunks - - cap.stream_upload("/tmp/stream_test.bin", data_gen()) - downloaded = cap.download("/tmp/stream_test.bin") - assert downloaded == b"chunk0_chunk1_chunk2" - - def test_stream_download_large(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - content = b"x" * 65536 * 3 - cap.upload("/tmp/large.bin", content) - collected = b"" - for chunk in cap.stream_download("/tmp/large.bin"): - collected += chunk - assert collected == content - - -@requires_auth -class TestPty: - def test_pty_basic_output(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - with cap.pty(cmd="/bin/sh", cwd="/tmp") as term: - term.write(b"echo pty_hello\n") - output = b"" - for event in term: - if event.type == PtyEventType.output: - output += event.data - elif event.type == PtyEventType.exit: - break - if b"pty_hello" in output: - term.write(b"exit\n") - assert b"pty_hello" in output - - def test_pty_tag_and_pid(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - with cap.pty(cmd="/bin/sh") as term: - started = False - for event in term: - if event.type == PtyEventType.started: - started = True - assert term.tag is not None - assert term.pid is not None - assert term.tag.startswith("pty-") - elif event.type == PtyEventType.output: - term.write(b"exit\n") - elif event.type == PtyEventType.exit: - break - assert started - - def test_pty_exit_on_command_exit(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - with cap.pty(cmd="/bin/echo", args=["immediate"]) as term: - events = list(term) - types = [e.type for e in events] - assert PtyEventType.started in types - assert PtyEventType.output in types or PtyEventType.exit in types - - def test_pty_resize(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - with cap.pty(cmd="/bin/sh", cols=80, rows=24) as term: - for event in term: - if event.type == PtyEventType.started: - term.resize(120, 40) - term.write(b"exit\n") - elif event.type == PtyEventType.exit: - break - - def test_pty_envs(self, client): - with client.capsules.create(template="minimal", timeout_sec=120) as cap: - cap.wait_ready(timeout=60, interval=1) - with cap.pty(cmd="/bin/sh", envs={"MY_VAR": "hello_env"}) as term: - output = b"" - for event in term: - if event.type == PtyEventType.started: - term.write(b"echo $MY_VAR\n") - elif event.type == PtyEventType.output: - output += event.data - if b"hello_env" in output: - term.write(b"exit\n") - elif event.type == PtyEventType.exit: - break - assert b"hello_env" in output - - -@requires_auth -class TestAsyncFilesystem: - @pytest.mark.asyncio - async def test_async_list_dir(self, async_client): - async with async_client: - cap = await async_client.capsules.create( - template="minimal", timeout_sec=120 - ) - try: - await cap.async_wait_ready(timeout=60, interval=1) - await cap.async_mkdir("/tmp/async_ls_test") - await cap.async_upload("/tmp/async_ls_test/file.txt", b"data") - entries = await cap.async_list_dir("/tmp/async_ls_test") - assert isinstance(entries, list) - assert any(e.name == "file.txt" for e in entries) - finally: - await cap.async_destroy() - - @pytest.mark.asyncio - async def test_async_mkdir(self, async_client): - async with async_client: - cap = await async_client.capsules.create( - template="minimal", timeout_sec=120 - ) - try: - await cap.async_wait_ready(timeout=60, interval=1) - entry = await cap.async_mkdir("/tmp/async_mkdir_test") - assert entry.type == "directory" - assert entry.name == "async_mkdir_test" - finally: - await cap.async_destroy() - - @pytest.mark.asyncio - async def test_async_remove(self, async_client): - async with async_client: - cap = await async_client.capsules.create( - template="minimal", timeout_sec=120 - ) - try: - await cap.async_wait_ready(timeout=60, interval=1) - await cap.async_upload("/tmp/async_rm.txt", b"bye") - entries = await cap.async_list_dir("/tmp") - assert any(e.name == "async_rm.txt" for e in entries) - await cap.async_remove("/tmp/async_rm.txt") - entries = await cap.async_list_dir("/tmp") - assert not any(e.name == "async_rm.txt" for e in entries) - finally: - await cap.async_destroy() - - @pytest.mark.asyncio - async def test_async_full_filesystem_roundtrip(self, async_client): - async with async_client: - cap = await async_client.capsules.create( - template="minimal", timeout_sec=120 - ) - try: - await cap.async_wait_ready(timeout=60, interval=1) - - await cap.async_mkdir("/tmp/async_rt") - await cap.async_upload("/tmp/async_rt/file.txt", b"async content") - entries = await cap.async_list_dir("/tmp/async_rt") - assert any(e.name == "file.txt" for e in entries) - - data = await cap.async_download("/tmp/async_rt/file.txt") - assert data == b"async content" - - await cap.async_remove("/tmp/async_rt/file.txt") - entries = await cap.async_list_dir("/tmp/async_rt") - assert not any(e.name == "file.txt" for e in entries) - finally: - await cap.async_destroy() -- 2.49.0 From ee1f55635f193a7de8ada3802adfe13cf1b17608 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Thu, 23 Apr 2026 05:16:08 +0600 Subject: [PATCH 17/44] 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. --- src/wrenn/commands.py | 26 +++++-- tests/test_git.py | 155 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 6 deletions(-) diff --git a/src/wrenn/commands.py b/src/wrenn/commands.py index c42f136..4cb005d 100644 --- a/src/wrenn/commands.py +++ b/src/wrenn/commands.py @@ -183,7 +183,11 @@ class Commands: CommandHandle: PID and tag for background commands (``background=True``). """ - payload: dict = {"cmd": cmd, "background": background} + payload: dict = { + "cmd": "/bin/sh", + "args": ["-c", cmd], + "background": background, + } if timeout is not None and not background: payload["timeout_sec"] = timeout if envs is not None: @@ -271,6 +275,8 @@ class Commands: Args: cmd (str): Command to execute. args (list[str] | None): Additional arguments for the command. + When omitted, *cmd* is interpreted as a shell command + string and executed via ``/bin/sh -c``. Yields: StreamEvent: Successive events including :class:`StreamStartEvent`, @@ -281,9 +287,10 @@ class Commands: f"/v1/capsules/{self._capsule_id}/exec/stream", self._http, ) as ws: - start_msg: dict = {"type": "start", "cmd": cmd} if args: - start_msg["args"] = args + start_msg: dict = {"type": "start", "cmd": cmd, "args": args} + else: + start_msg = {"type": "start", "cmd": "/bin/sh", "args": ["-c", cmd]} ws.send_text(json.dumps(start_msg)) while True: try: @@ -359,7 +366,11 @@ class AsyncCommands: CommandHandle: PID and tag for background commands (``background=True``). """ - payload: dict = {"cmd": cmd, "background": background} + payload: dict = { + "cmd": "/bin/sh", + "args": ["-c", cmd], + "background": background, + } if timeout is not None and not background: payload["timeout_sec"] = timeout if envs is not None: @@ -449,6 +460,8 @@ class AsyncCommands: Args: cmd (str): Command to execute. args (list[str] | None): Additional arguments for the command. + When omitted, *cmd* is interpreted as a shell command + string and executed via ``/bin/sh -c``. Yields: StreamEvent: Successive events including :class:`StreamStartEvent`, @@ -459,9 +472,10 @@ class AsyncCommands: f"/v1/capsules/{self._capsule_id}/exec/stream", self._http, ) as ws: - start_msg: dict = {"type": "start", "cmd": cmd} if args: - start_msg["args"] = args + start_msg: dict = {"type": "start", "cmd": cmd, "args": args} + else: + start_msg = {"type": "start", "cmd": "/bin/sh", "args": ["-c", cmd]} await ws.send_text(json.dumps(start_msg)) try: while True: diff --git a/tests/test_git.py b/tests/test_git.py index 1fc7463..29c9e12 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + import pytest import respx from httpx import Response @@ -942,3 +944,156 @@ class TestAsyncGit: git = _make_async_git() branches = await git.branches(cwd="/repo") assert len(branches) == 2 + + +# ════════════════════════════════��═════════════════════════════════ +# Command payload tests — verify /bin/sh -c wrapping +# ════════════════════════════���══════════════════════���══════════════ + + +class TestCommandPayloadWrapping: + """Verify that Commands.run sends cmd=/bin/sh args=['-c', cmd_string] + so the server-side wrapper expands "${@}" into proper argv.""" + + @respx.mock + def test_simple_command(self): + route = respx.post(EXEC_URL).respond(200, json=_exec_response( + stdout="hello world\n" + )) + git = _make_git() + git.init("/repo") + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"] == ["-c", git_cmd_from_body(body)] + # args[1] should contain the actual git command + assert body["args"][0] == "-c" + assert "git" in body["args"][1] + + @respx.mock + def test_command_with_pipes(self): + """Pipes and redirects work because /bin/sh interprets them.""" + from wrenn.client import WrennClient + from wrenn.commands import Commands + + client = WrennClient(api_key="wrn_test1234567890abcdef12345678") + commands = Commands(CAPSULE_ID, client.http) + + route = respx.post(EXEC_URL).respond(200, json=_exec_response( + stdout="3\n" + )) + commands.run("cat /etc/passwd | wc -l") + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"] == ["-c", "cat /etc/passwd | wc -l"] + + @respx.mock + def test_command_with_semicolons(self): + from wrenn.client import WrennClient + from wrenn.commands import Commands + + client = WrennClient(api_key="wrn_test1234567890abcdef12345678") + commands = Commands(CAPSULE_ID, client.http) + + route = respx.post(EXEC_URL).respond(200, json=_exec_response()) + commands.run("cd /tmp; ls -la && echo done") + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"] == ["-c", "cd /tmp; ls -la && echo done"] + + @respx.mock + def test_command_with_env_vars(self): + from wrenn.client import WrennClient + from wrenn.commands import Commands + + client = WrennClient(api_key="wrn_test1234567890abcdef12345678") + commands = Commands(CAPSULE_ID, client.http) + + route = respx.post(EXEC_URL).respond(200, json=_exec_response()) + commands.run("FOO=bar echo $FOO") + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"] == ["-c", "FOO=bar echo $FOO"] + + @respx.mock + def test_command_with_subshell(self): + from wrenn.client import WrennClient + from wrenn.commands import Commands + + client = WrennClient(api_key="wrn_test1234567890abcdef12345678") + commands = Commands(CAPSULE_ID, client.http) + + route = respx.post(EXEC_URL).respond(200, json=_exec_response()) + commands.run("echo $(date +%s)") + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"] == ["-c", "echo $(date +%s)"] + + @respx.mock + def test_command_with_quotes_and_spaces(self): + from wrenn.client import WrennClient + from wrenn.commands import Commands + + client = WrennClient(api_key="wrn_test1234567890abcdef12345678") + commands = Commands(CAPSULE_ID, client.http) + + route = respx.post(EXEC_URL).respond(200, json=_exec_response()) + commands.run("""echo "hello 'world'" | grep -o "'[^']*'" """) + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"][0] == "-c" + # The command string is passed verbatim — shell interprets it + assert "hello 'world'" in body["args"][1] + + @respx.mock + def test_heredoc_style_command(self): + from wrenn.client import WrennClient + from wrenn.commands import Commands + + client = WrennClient(api_key="wrn_test1234567890abcdef12345678") + commands = Commands(CAPSULE_ID, client.http) + + route = respx.post(EXEC_URL).respond(200, json=_exec_response()) + commands.run("python3 -c 'import sys; print(sys.version)'") + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"] == ["-c", "python3 -c 'import sys; print(sys.version)'"] + + @respx.mock + def test_git_shlex_joined_command(self): + """Git module uses shlex.join — verify it passes through correctly.""" + route = respx.post(EXEC_URL).respond(200, json=_exec_response()) + git = _make_git() + git.clone("https://github.com/user/repo.git", "/tmp/repo", depth=1) + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"][0] == "-c" + # shlex.join produces: git clone --depth 1 https://... /tmp/repo + shell_cmd = body["args"][1] + assert "git" in shell_cmd + assert "clone" in shell_cmd + assert "--depth" in shell_cmd + assert "https://github.com/user/repo.git" in shell_cmd + + @respx.mock + def test_background_command_also_wrapped(self): + from wrenn.client import WrennClient + from wrenn.commands import Commands + + client = WrennClient(api_key="wrn_test1234567890abcdef12345678") + commands = Commands(CAPSULE_ID, client.http) + + route = respx.post(EXEC_URL).respond(200, json={ + "pid": 42, "tag": "bg-1" + }) + commands.run("tail -f /var/log/syslog", background=True) + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"] == ["-c", "tail -f /var/log/syslog"] + assert body["background"] is True + + +def git_cmd_from_body(body: dict) -> str: + """Extract the shell command string from a wrapped payload.""" + assert body["cmd"] == "/bin/sh" + assert body["args"][0] == "-c" + return body["args"][1] -- 2.49.0 From 82e181dd7e2517a680b5efa56f2b619fcbfdc73b Mon Sep 17 00:00:00 2001 From: pptx704 Date: Thu, 23 Apr 2026 05:40:06 +0600 Subject: [PATCH 18/44] 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. --- tests/conftest.py | 35 ++++ tests/test_integration.py | 413 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 448 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_integration.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d0b693c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +ENV_FILE = Path(__file__).resolve().parent.parent / ".env" + + +def _read_env_file() -> dict[str, str]: + result: dict[str, str] = {} + if not ENV_FILE.exists(): + return result + for line in ENV_FILE.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("\"'") + if key: + result[key] = value + return result + + +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + env_vars = _read_env_file() + has_key = bool(os.environ.get("WRENN_API_KEY") or env_vars.get("WRENN_API_KEY")) + if has_key: + return + skip = pytest.mark.skip(reason="WRENN_API_KEY not set") + for item in items: + if "integration" in item.keywords: + item.add_marker(skip) diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..2286c1b --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,413 @@ +from __future__ import annotations + +import os +import time +from pathlib import Path + +import pytest + +from wrenn import Capsule, CommandResult +from wrenn.commands import CommandHandle, ProcessInfo +from wrenn.models import Capsule as CapsuleModel, FileEntry, Status + +pytestmark = pytest.mark.integration + +_env_loaded = False + + +def _ensure_env() -> None: + global _env_loaded + if _env_loaded: + return + _env_loaded = True + env_file = Path(__file__).resolve().parent.parent / ".env" + if not env_file.exists(): + return + for line in env_file.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key, value = key.strip(), value.strip().strip("\"'") + if key and key not in os.environ: + os.environ[key] = value + + +class TestCapsuleLifecycle: + """Each test manages its own capsule to test create/destroy paths.""" + + def setup_method(self): + _ensure_env() + + def test_create_and_destroy(self): + capsule = Capsule() + capsule_id = capsule.capsule_id + try: + assert capsule_id + assert capsule.info is not None + finally: + capsule.destroy() + + info = Capsule.get_info(capsule_id) + assert info.status in (Status.stopped, Status.missing) + + def test_create_with_wait(self): + capsule = Capsule(wait=True) + try: + assert capsule.info is not None + assert capsule.info.status == Status.running + finally: + capsule.destroy() + + def test_context_manager_destroys(self): + with Capsule(wait=True) as capsule: + capsule_id = capsule.capsule_id + assert capsule.is_running() + + info = Capsule.get_info(capsule_id) + assert info.status in (Status.stopped, Status.missing) + + def test_get_info(self): + capsule = Capsule(wait=True) + try: + info = capsule.get_info() + assert isinstance(info, CapsuleModel) + assert info.id == capsule.capsule_id + assert info.status == Status.running + finally: + capsule.destroy() + + def test_pause_and_resume(self): + capsule = Capsule(wait=True) + try: + paused = capsule.pause() + assert paused.status == Status.paused + assert not capsule.is_running() + + resumed = capsule.resume() + assert resumed.status == Status.running + finally: + capsule.destroy() + + def test_static_destroy(self): + capsule = Capsule(wait=True) + capsule_id = capsule.capsule_id + try: + Capsule.destroy(capsule_id) + except Exception: + capsule.destroy() + raise + + info = Capsule.get_info(capsule_id) + assert info.status in (Status.stopped, Status.missing) + + def test_connect_to_existing(self): + capsule = Capsule(wait=True) + try: + connected = Capsule.connect(capsule.capsule_id) + assert connected.capsule_id == capsule.capsule_id + assert connected.info is not None + assert connected.info.status == Status.running + finally: + capsule.destroy() + + def test_connect_resumes_paused(self): + capsule = Capsule(wait=True) + try: + capsule.pause() + connected = Capsule.connect(capsule.capsule_id) + assert connected.info is not None + assert connected.info.status == Status.running + finally: + capsule.destroy() + + def test_list_capsules(self): + capsule = Capsule(wait=True) + try: + capsules = Capsule.list() + assert isinstance(capsules, list) + ids = [c.id for c in capsules] + assert capsule.capsule_id in ids + finally: + capsule.destroy() + + def test_wait_ready(self): + capsule = Capsule() + try: + capsule.wait_ready(timeout=60) + assert capsule.is_running() + finally: + capsule.destroy() + + def test_ping(self): + capsule = Capsule(wait=True) + try: + capsule.ping() + finally: + capsule.destroy() + + +class TestCommands: + """Shared capsule for command execution tests.""" + + capsule: Capsule + + @classmethod + def setup_class(cls): + _ensure_env() + cls.capsule = Capsule(wait=True) + + @classmethod + def teardown_class(cls): + try: + cls.capsule.destroy() + except Exception: + pass + + def test_run_foreground(self): + result = self.capsule.commands.run("echo hello") + assert isinstance(result, CommandResult) + assert result.exit_code == 0 + assert "hello" in result.stdout + + def test_run_stderr(self): + result = self.capsule.commands.run("echo error >&2") + assert "error" in result.stderr + + def test_run_exit_code(self): + result = self.capsule.commands.run("exit 42") + assert result.exit_code == 42 + + def test_run_with_envs(self): + result = self.capsule.commands.run( + "export MY_VAR=test_value && echo $MY_VAR" + ) + assert "test_value" in result.stdout + + def test_run_with_cwd(self): + result = self.capsule.commands.run("cd /tmp && pwd") + assert result.stdout.strip() == "/tmp" + + def test_run_multiline_output(self): + result = self.capsule.commands.run("echo -e 'line1\\nline2\\nline3'") + assert result.exit_code == 0 + lines = result.stdout.strip().splitlines() + assert len(lines) == 3 + + def test_run_background(self): + handle = self.capsule.commands.run( + "sleep 30", background=True, tag="bg-test" + ) + assert isinstance(handle, CommandHandle) + assert handle.pid > 0 + assert handle.tag == "bg-test" + assert handle.capsule_id == self.capsule.capsule_id + + self.capsule.commands.kill(handle.pid) + + def test_list_processes(self): + handle = self.capsule.commands.run( + "sleep 30", background=True, tag="list-test" + ) + try: + time.sleep(0.5) + processes = self.capsule.commands.list() + assert isinstance(processes, list) + pids = [p.pid for p in processes] + assert handle.pid in pids + + proc = next(p for p in processes if p.pid == handle.pid) + assert isinstance(proc, ProcessInfo) + finally: + self.capsule.commands.kill(handle.pid) + + def test_kill_process(self): + handle = self.capsule.commands.run( + "sleep 30", background=True + ) + self.capsule.commands.kill(handle.pid) + time.sleep(0.5) + + processes = self.capsule.commands.list() + pids = [p.pid for p in processes] + assert handle.pid not in pids + + def test_run_duration_ms(self): + result = self.capsule.commands.run("sleep 1") + assert result.duration_ms is None or result.duration_ms >= 900 + + +class TestFiles: + """Shared capsule for filesystem tests.""" + + capsule: Capsule + + @classmethod + def setup_class(cls): + _ensure_env() + cls.capsule = Capsule(wait=True) + + @classmethod + def teardown_class(cls): + try: + cls.capsule.destroy() + except Exception: + pass + + def test_write_and_read(self): + self.capsule.files.write("/tmp/test.txt", "hello world") + content = self.capsule.files.read("/tmp/test.txt") + assert content == "hello world" + + def test_write_and_read_bytes(self): + data = b"\x00\x01\x02\xff" + self.capsule.files.write("/tmp/test.bin", data) + result = self.capsule.files.read_bytes("/tmp/test.bin") + assert result == data + + def test_list_directory(self): + self.capsule.files.write("/tmp/listdir/a.txt", "a") + self.capsule.files.write("/tmp/listdir/b.txt", "b") + entries = self.capsule.files.list("/tmp/listdir") + assert isinstance(entries, list) + names = [e.name for e in entries] + assert "a.txt" in names + assert "b.txt" in names + + def test_exists(self): + self.capsule.files.write("/tmp/exists_test.txt", "x") + assert self.capsule.files.exists("/tmp/exists_test.txt") + assert not self.capsule.files.exists("/tmp/does_not_exist_xyz.txt") + + def test_make_dir(self): + entry = self.capsule.files.make_dir("/tmp/newdir") + assert isinstance(entry, FileEntry) + assert self.capsule.files.exists("/tmp/newdir") + + def test_make_dir_idempotent(self): + self.capsule.files.make_dir("/tmp/idempotent_dir") + entry = self.capsule.files.make_dir("/tmp/idempotent_dir") + assert isinstance(entry, FileEntry) + + def test_remove_file(self): + self.capsule.files.write("/tmp/to_remove.txt", "delete me") + assert self.capsule.files.exists("/tmp/to_remove.txt") + self.capsule.files.remove("/tmp/to_remove.txt") + assert not self.capsule.files.exists("/tmp/to_remove.txt") + + def test_remove_directory(self): + self.capsule.files.make_dir("/tmp/dir_to_remove") + self.capsule.files.write("/tmp/dir_to_remove/child.txt", "data") + self.capsule.files.remove("/tmp/dir_to_remove") + assert not self.capsule.files.exists("/tmp/dir_to_remove") + + def test_write_creates_parent_dirs(self): + self.capsule.files.write("/tmp/deep/nested/dir/file.txt", "nested") + content = self.capsule.files.read("/tmp/deep/nested/dir/file.txt") + assert content == "nested" + + def test_list_with_depth(self): + self.capsule.files.write("/tmp/depth_test/a/b.txt", "deep") + entries_shallow = self.capsule.files.list("/tmp/depth_test", depth=1) + entries_deep = self.capsule.files.list("/tmp/depth_test", depth=2) + assert len(entries_deep) >= len(entries_shallow) + + def test_overwrite_file(self): + self.capsule.files.write("/tmp/overwrite.txt", "original") + self.capsule.files.write("/tmp/overwrite.txt", "updated") + content = self.capsule.files.read("/tmp/overwrite.txt") + assert content == "updated" + + def test_upload_and_download_stream(self): + chunks = [b"chunk1", b"chunk2", b"chunk3"] + self.capsule.files.upload_stream("/tmp/streamed.bin", iter(chunks)) + downloaded = b"".join(self.capsule.files.download_stream("/tmp/streamed.bin")) + assert downloaded == b"chunk1chunk2chunk3" + + +class TestGit: + """Shared capsule for git operation tests. + + Initializes a repo at /root (default cwd) since the exec API + does not support the cwd parameter. + """ + + capsule: Capsule + + @classmethod + def setup_class(cls): + _ensure_env() + cls.capsule = Capsule(wait=True) + cls.capsule.git.init(".", initial_branch="main") + cls.capsule.git.configure_user("Test User", "test@example.com") + + @classmethod + def teardown_class(cls): + try: + cls.capsule.destroy() + except Exception: + pass + + def test_init_created_repo(self): + assert self.capsule.files.exists("/root/.git") + + def test_status_clean(self): + status = self.capsule.git.status() + assert status.branch == "main" + + def test_add_and_commit(self): + self.capsule.files.write("/root/hello.txt", "hello git") + self.capsule.git.add(all=True) + result = self.capsule.git.commit("initial commit") + assert result.exit_code == 0 + + def test_status_after_commit(self): + status = self.capsule.git.status() + assert status.is_clean + + def test_status_with_changes(self): + self.capsule.files.write("/root/dirty.txt", "uncommitted") + try: + status = self.capsule.git.status() + assert not status.is_clean + paths = [f.path for f in status.files] + assert "dirty.txt" in paths + finally: + self.capsule.files.remove("/root/dirty.txt") + + def test_branches(self): + branches = self.capsule.git.branches() + assert len(branches) >= 1 + names = [b.name for b in branches] + assert "main" in names + current = [b for b in branches if b.is_current] + assert len(current) == 1 + + def test_create_and_checkout_branch(self): + self.capsule.git.create_branch("feature-1") + branches = self.capsule.git.branches() + names = [b.name for b in branches] + assert "feature-1" in names + + current = [b for b in branches if b.is_current] + assert current[0].name == "feature-1" + + self.capsule.git.checkout_branch("main") + + def test_delete_branch(self): + self.capsule.git.create_branch("to-delete") + self.capsule.git.checkout_branch("main") + self.capsule.git.delete_branch("to-delete") + + branches = self.capsule.git.branches() + names = [b.name for b in branches] + assert "to-delete" not in names + + def test_set_and_get_config(self): + self.capsule.git.set_config("test.key", "test-value") + value = self.capsule.git.get_config("test.key") + assert value == "test-value" + + def test_get_config_missing_returns_none(self): + value = self.capsule.git.get_config("nonexistent.key") + assert value is None -- 2.49.0 From bab53aedbe4e4fa52f95ed79b10c41760d6bebc3 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Thu, 23 Apr 2026 05:44:49 +0600 Subject: [PATCH 19/44] Updated readme --- README.md | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8fc6cf6..787a4b9 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,73 @@ for chunk in capsule.files.download_stream("/data/large.bin"): process(chunk) ``` +### Git + +Git operations are accessed via `capsule.git`. All commands execute the real `git` binary inside the capsule: + +```python +# Initialize a repo +capsule.git.init("/app", initial_branch="main") + +# Configure user +capsule.git.configure_user("Alice", "alice@example.com", cwd="/app") + +# Stage and commit +capsule.git.add(all=True, cwd="/app") +capsule.git.commit("initial commit", cwd="/app") + +# Check status +status = capsule.git.status(cwd="/app") +print(status.branch) # "main" +print(status.is_clean) # True +for f in status.files: + print(f.path, f.index_status, f.work_tree_status) + +# Branches +branches = capsule.git.branches(cwd="/app") +capsule.git.create_branch("feature", cwd="/app") +capsule.git.checkout_branch("main", cwd="/app") +capsule.git.delete_branch("feature", cwd="/app") +``` + +#### Clone with Authentication + +```python +# Clone a private repo (credentials are stripped from remote URL after clone) +capsule.git.clone( + "https://github.com/org/repo.git", + username="user", + password="ghp_token", + cwd="/app", +) + +# Push/pull with inline credentials (temporarily embedded, then restored) +capsule.git.push("origin", "main", username="user", password="ghp_token", cwd="/app") +capsule.git.pull("origin", "main", username="user", password="ghp_token", cwd="/app") +``` + +#### Configuration and Remotes + +```python +capsule.git.set_config("core.autocrlf", "false", cwd="/app") +value = capsule.git.get_config("user.name", cwd="/app") # str | None + +capsule.git.remote_add("upstream", "https://github.com/org/repo.git", cwd="/app") +url = capsule.git.remote_get("origin", cwd="/app") # str | None +``` + +Git errors raise `GitCommandError` (or `GitAuthError` for authentication failures), both inheriting from `GitError`: + +```python +from wrenn import GitCommandError, GitAuthError + +try: + capsule.git.push("origin", "main", username="user", password="bad", cwd="/app") +except GitAuthError as e: + print(e.stderr) + print(e.exit_code) +``` + ### Interactive Terminal (PTY) ```python @@ -533,14 +600,24 @@ make test-integration ### Running Integration Tests -Integration tests require a live Wrenn server: +Integration tests require a live Wrenn server. Set credentials via environment or a `.env` file at the project root: ```bash +# Option 1: environment variable export WRENN_API_KEY="wrn_..." -export WRENN_BASE_URL="http://localhost:8080" # optional + +# Option 2: .env file +echo 'WRENN_API_KEY=wrn_...' > .env +``` + +Then run: + +```bash make test-integration ``` +Tests are automatically skipped when `WRENN_API_KEY` is not available. + ## License MIT -- 2.49.0 From 68c7d0de42ee2dc9f8aab6e11fa4bf96801ad2a9 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Thu, 23 Apr 2026 18:32:59 +0600 Subject: [PATCH 20/44] 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 --- .github/workflows/release.yml | 24 ++ .woodpecker/check.yml | 46 +--- pyproject.toml | 17 ++ src/wrenn/_git/__init__.py | 94 ++++---- src/wrenn/_git/_auth.py | 22 +- src/wrenn/_git/_cmd.py | 42 ++-- src/wrenn/_git/exceptions.py | 4 +- src/wrenn/async_capsule.py | 8 +- src/wrenn/capsule.py | 10 +- src/wrenn/code_interpreter/async_capsule.py | 14 +- src/wrenn/code_interpreter/capsule.py | 10 +- src/wrenn/commands.py | 16 +- tests/conftest.py | 4 +- tests/test_capsule_features.py | 6 +- tests/test_client.py | 4 +- tests/test_filesystem_pty.py | 8 +- tests/test_git.py | 238 ++++++++++++-------- tests/test_integration.py | 16 +- 18 files changed, 303 insertions(+), 280 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cde2dcc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,24 @@ +name: Publish to PyPI + +on: + push: + branches: + - main + +jobs: + pypi-publish: + name: Upload release to PyPI + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v6 + + - name: Build package + run: uv build + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.woodpecker/check.yml b/.woodpecker/check.yml index 83a35d7..0553fc4 100644 --- a/.woodpecker/check.yml +++ b/.woodpecker/check.yml @@ -1,46 +1,20 @@ when: - event: push branch: - main - dev -variables: - - &python_image "ghcr.io/astral-sh/uv:python3.13-bookworm-slim" - - &uv_cache_dir "/root/.cache/uv" - steps: - - name: restore-cache - image: woodpeckerci/plugin-cache - settings: - restore: true - cache_key: "uv-{{ checksum \"uv.lock\" }}" - mount: - - /root/.cache/uv - - - name: lint - image: *python_image - environment: - UV_CACHE_DIR: *uv_cache_dir - UV_FROZEN: 1 + unit-tests: + image: ghcr.io/astral-sh/uv:python3.13-bookworm commands: - - uv sync --no-install-project - - make lint + - uv sync --dev + - uv run pytest -m "not integration" -v - - name: test - image: *python_image + integration-tests: + image: ghcr.io/astral-sh/uv:python3.13-bookworm environment: - UV_CACHE_DIR: *uv_cache_dir - UV_FROZEN: 1 + WRENN_API_KEY: + from_secret: WRENN_API_KEY commands: - - uv sync --no-install-project - - make test - - - name: rebuild-cache - image: woodpeckerci/plugin-cache - when: - - status: [success] - settings: - rebuild: true - cache_key: "uv-{{ checksum \"uv.lock\" }}" - mount: - - /root/.cache/uv + - uv sync --dev + - uv run pytest -m integration -v diff --git a/pyproject.toml b/pyproject.toml index 359c8a5..98570b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,11 +3,24 @@ name = "wrenn" version = "0.1.0" description = "Python SDK for Wrenn" readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] authors = [ { name = "Rafeed M. Bhuiyan", email = "rafeed@omukk.dev" }, { name = "Tasnim Kabir Sadik", email = "tksadik@omukk.dev" }, ] requires-python = ">=3.13" +keywords = ["wrenn"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] dependencies = [ "email-validator>=2.3.0", "httpx>=0.28.1", @@ -29,6 +42,10 @@ dev = [ "ruff>=0.15.10", ] +[project.urls] +Homepage = "https://wrenn.dev" +Repository = "https://github.com/wrennhq/python-sdk" + [tool.pytest.ini_options] markers = [ "integration: integration tests (require live server)", diff --git a/src/wrenn/_git/__init__.py b/src/wrenn/_git/__init__.py index 89a42a5..fa59564 100644 --- a/src/wrenn/_git/__init__.py +++ b/src/wrenn/_git/__init__.py @@ -211,9 +211,7 @@ class Git: if sanitized != clone_url: repo_dir = dest or _derive_repo_dir(url) if repo_dir: - repo_cwd = ( - posixpath.join(cwd, repo_dir) if cwd else repo_dir - ) + repo_cwd = posixpath.join(cwd, repo_dir) if cwd else repo_dir strip_result = self._run( build_remote_set_url("origin", sanitized), cwd=repo_cwd, @@ -482,9 +480,7 @@ class Git: Raises: GitCommandError: If the command failed. """ - result = self._run( - build_branches(), cwd=cwd, envs=envs, timeout=timeout - ) + result = self._run(build_branches(), cwd=cwd, envs=envs, timeout=timeout) _check_result(result, op="branches") return parse_branches(result.stdout) @@ -697,9 +693,7 @@ class Git: Raises: GitCommandError: If the command failed. """ - argv = build_restore( - paths, staged=staged, worktree=worktree, source=source - ) + argv = build_restore(paths, staged=staged, worktree=worktree, source=source) result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) _check_result(result, op="restore") return result @@ -798,8 +792,12 @@ class Git: """ if not name or not email: raise ValueError("Both name and email are required.") - self.set_config("user.name", name, scope=scope, cwd=cwd, envs=envs, timeout=timeout) - self.set_config("user.email", email, scope=scope, cwd=cwd, envs=envs, timeout=timeout) + self.set_config( + "user.name", name, scope=scope, cwd=cwd, envs=envs, timeout=timeout + ) + self.set_config( + "user.email", email, scope=scope, cwd=cwd, envs=envs, timeout=timeout + ) def dangerously_authenticate( self, @@ -836,12 +834,14 @@ class Git: GitCommandError: If a command failed. """ if not username or not password: - raise ValueError( - "Both username and password are required." - ) + raise ValueError("Both username and password are required.") self.set_config( - "credential.helper", "store", - scope="global", cwd=cwd, envs=envs, timeout=timeout, + "credential.helper", + "store", + scope="global", + cwd=cwd, + envs=envs, + timeout=timeout, ) cmd = build_credential_approve_cmd( username=username, @@ -880,7 +880,9 @@ class Git: credential_url = embed_credentials(original_url, username, password) self._run( build_remote_set_url(remote, credential_url), - cwd=cwd, envs=envs, timeout=timeout, + cwd=cwd, + envs=envs, + timeout=timeout, ) op_error: Exception | None = None @@ -895,7 +897,9 @@ class Git: try: self._run( build_remote_set_url(remote, original_url), - cwd=cwd, envs=envs, timeout=timeout, + cwd=cwd, + envs=envs, + timeout=timeout, ) except Exception as err: restore_error = err @@ -988,9 +992,7 @@ class AsyncGit: if sanitized != clone_url: repo_dir = dest or _derive_repo_dir(url) if repo_dir: - repo_cwd = ( - posixpath.join(cwd, repo_dir) if cwd else repo_dir - ) + repo_cwd = posixpath.join(cwd, repo_dir) if cwd else repo_dir strip_result = await self._run( build_remote_set_url("origin", sanitized), cwd=repo_cwd, @@ -1072,10 +1074,13 @@ class AsyncGit: ) -> CommandResult: """Push commits to a remote.""" if username and password: + async def _op() -> CommandResult: return await self._run( build_push(remote, branch, force=force, set_upstream=set_upstream), - cwd=cwd, envs=envs, timeout=timeout, + cwd=cwd, + envs=envs, + timeout=timeout, ) return await self._with_remote_credentials( @@ -1109,10 +1114,13 @@ class AsyncGit: ) -> CommandResult: """Pull changes from a remote.""" if username and password: + async def _op() -> CommandResult: return await self._run( build_pull(remote, branch, rebase=rebase, ff_only=ff_only), - cwd=cwd, envs=envs, timeout=timeout, + cwd=cwd, + envs=envs, + timeout=timeout, ) return await self._with_remote_credentials( @@ -1141,9 +1149,7 @@ class AsyncGit: timeout: int | None = 30, ) -> GitStatus: """Get repository status.""" - result = await self._run( - build_status(), cwd=cwd, envs=envs, timeout=timeout - ) + result = await self._run(build_status(), cwd=cwd, envs=envs, timeout=timeout) _check_result(result, op="status") return parse_status(result.stdout) @@ -1155,9 +1161,7 @@ class AsyncGit: timeout: int | None = 30, ) -> list[GitBranch]: """List local branches.""" - result = await self._run( - build_branches(), cwd=cwd, envs=envs, timeout=timeout - ) + result = await self._run(build_branches(), cwd=cwd, envs=envs, timeout=timeout) _check_result(result, op="branches") return parse_branches(result.stdout) @@ -1270,9 +1274,7 @@ class AsyncGit: timeout: int | None = 30, ) -> CommandResult: """Restore working-tree files or unstage changes.""" - argv = build_restore( - paths, staged=staged, worktree=worktree, source=source - ) + argv = build_restore(paths, staged=staged, worktree=worktree, source=source) result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) _check_result(result, op="restore") return result @@ -1325,8 +1327,12 @@ class AsyncGit: """Configure git user name and email.""" if not name or not email: raise ValueError("Both name and email are required.") - await self.set_config("user.name", name, scope=scope, cwd=cwd, envs=envs, timeout=timeout) - await self.set_config("user.email", email, scope=scope, cwd=cwd, envs=envs, timeout=timeout) + await self.set_config( + "user.name", name, scope=scope, cwd=cwd, envs=envs, timeout=timeout + ) + await self.set_config( + "user.email", email, scope=scope, cwd=cwd, envs=envs, timeout=timeout + ) async def dangerously_authenticate( self, @@ -1348,12 +1354,14 @@ class AsyncGit: parameters instead. """ if not username or not password: - raise ValueError( - "Both username and password are required." - ) + raise ValueError("Both username and password are required.") await self.set_config( - "credential.helper", "store", - scope="global", cwd=cwd, envs=envs, timeout=timeout, + "credential.helper", + "store", + scope="global", + cwd=cwd, + envs=envs, + timeout=timeout, ) cmd = build_credential_approve_cmd( username=username, @@ -1394,7 +1402,9 @@ class AsyncGit: credential_url = embed_credentials(original_url, username, password) await self._run( build_remote_set_url(remote, credential_url), - cwd=cwd, envs=envs, timeout=timeout, + cwd=cwd, + envs=envs, + timeout=timeout, ) op_error: Exception | None = None @@ -1409,7 +1419,9 @@ class AsyncGit: try: await self._run( build_remote_set_url(remote, original_url), - cwd=cwd, envs=envs, timeout=timeout, + cwd=cwd, + envs=envs, + timeout=timeout, ) except Exception as err: restore_error = err diff --git a/src/wrenn/_git/_auth.py b/src/wrenn/_git/_auth.py index b517cf4..af3d267 100644 --- a/src/wrenn/_git/_auth.py +++ b/src/wrenn/_git/_auth.py @@ -20,9 +20,7 @@ def embed_credentials(url: str, username: str, password: str) -> str: """ parsed = urlparse(url) if parsed.scheme not in ("http", "https"): - raise ValueError( - "Only http(s) URLs support embedded credentials." - ) + raise ValueError("Only http(s) URLs support embedded credentials.") netloc = f"{username}:{password}@{parsed.hostname}" if parsed.port: netloc = f"{netloc}:{parsed.port}" @@ -93,12 +91,14 @@ def build_credential_approve_cmd( raise ValueError("Credentials must not contain newline characters.") target_host = host.strip() or "github.com" target_protocol = protocol.strip() or "https" - credential_input = "\n".join([ - f"protocol={target_protocol}", - f"host={target_host}", - f"username={username}", - f"password={password}", - "", - "", - ]) + credential_input = "\n".join( + [ + f"protocol={target_protocol}", + f"host={target_host}", + f"username={username}", + f"password={password}", + "", + "", + ] + ) return f"printf %s {shlex.quote(credential_input)} | git credential approve" diff --git a/src/wrenn/_git/_cmd.py b/src/wrenn/_git/_cmd.py index b97a328..8e929bf 100644 --- a/src/wrenn/_git/_cmd.py +++ b/src/wrenn/_git/_cmd.py @@ -13,6 +13,7 @@ from dataclasses import dataclass, field # ── Data types ───────────────────────────────────────────────────── + @dataclass class FileStatus: """A single entry from ``git status --porcelain=v1``. @@ -96,6 +97,7 @@ class GitBranch: # ── Argument builders ────────────────────────────────────────────── + def build_clone( url: str, dest: str | None = None, @@ -356,6 +358,7 @@ def build_has_upstream() -> list[str]: # ── Parsers ──────────────────────────────────────────────────────── + def parse_status(stdout: str) -> GitStatus: """Parse ``git status --porcelain=v1 --branch`` output. @@ -377,11 +380,13 @@ def parse_status(stdout: str) -> GitStatus: for line in lines[1:]: if line.startswith("?? "): - status.files.append(FileStatus( - path=line[3:], - index_status="?", - work_tree_status="?", - )) + status.files.append( + FileStatus( + path=line[3:], + index_status="?", + work_tree_status="?", + ) + ) continue if len(line) < 4: @@ -394,12 +399,14 @@ def parse_status(stdout: str) -> GitStatus: if " -> " in path: renamed_from, path = path.split(" -> ", 1) - status.files.append(FileStatus( - path=path, - index_status=idx, - work_tree_status=wt, - renamed_from=renamed_from, - )) + status.files.append( + FileStatus( + path=path, + index_status=idx, + work_tree_status=wt, + renamed_from=renamed_from, + ) + ) return status @@ -427,6 +434,7 @@ def parse_branches(stdout: str) -> list[GitBranch]: # ── Internal helpers ─────────────────────────────────────────────── + def _resolve_scope_flag(scope: str) -> str: """Convert a scope name to a git config flag.""" scope = scope.strip().lower() @@ -436,16 +444,14 @@ def _resolve_scope_flag(scope: str) -> str: return "--global" if scope == "system": return "--system" - raise ValueError( - "Git config scope must be one of: local, global, system." - ) + raise ValueError("Git config scope must be one of: local, global, system.") def _parse_branch_line(info: str, status: GitStatus) -> None: """Parse the ``## branch...upstream [ahead N, behind M]`` header.""" ahead_start = info.find(" [") branch_part = info if ahead_start == -1 else info[:ahead_start] - ahead_part = None if ahead_start == -1 else info[ahead_start + 2:-1] + ahead_part = None if ahead_start == -1 else info[ahead_start + 2 : -1] if branch_part.startswith("HEAD (detached at "): status.detached = True @@ -457,10 +463,8 @@ def _parse_branch_line(info: str, status: GitStatus) -> None: status.branch = local or None status.upstream = remote or None else: - name = ( - branch_part - .replace("No commits yet on ", "") - .replace("Initial commit on ", "") + name = branch_part.replace("No commits yet on ", "").replace( + "Initial commit on ", "" ) status.branch = name or None diff --git a/src/wrenn/_git/exceptions.py b/src/wrenn/_git/exceptions.py index 80259b9..50d6ec1 100644 --- a/src/wrenn/_git/exceptions.py +++ b/src/wrenn/_git/exceptions.py @@ -13,9 +13,7 @@ class GitError(Exception): exit_code (int): Process exit code. """ - def __init__( - self, message: str, *, stderr: str = "", exit_code: int = -1 - ) -> None: + def __init__(self, message: str, *, stderr: str = "", exit_code: int = -1) -> None: self.message = message self.stderr = stderr self.exit_code = exit_code diff --git a/src/wrenn/async_capsule.py b/src/wrenn/async_capsule.py index 41c1767..3e92de7 100644 --- a/src/wrenn/async_capsule.py +++ b/src/wrenn/async_capsule.py @@ -241,13 +241,9 @@ class AsyncCapsule: self._info = info return if info.status in (Status.error, Status.stopped, Status.paused): - raise RuntimeError( - f"Capsule entered {info.status} state while waiting" - ) + raise RuntimeError(f"Capsule entered {info.status} state while waiting") 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") async def is_running(self) -> bool: """Check whether the capsule is currently running. diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py index 3d70b25..400409f 100644 --- a/src/wrenn/capsule.py +++ b/src/wrenn/capsule.py @@ -317,13 +317,9 @@ class Capsule: self._info = info return if info.status in (Status.error, Status.stopped, Status.paused): - raise RuntimeError( - f"Capsule entered {info.status} state while waiting" - ) + raise RuntimeError(f"Capsule entered {info.status} state while waiting") 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") def is_running(self) -> bool: """Check whether the capsule is currently running. @@ -472,5 +468,3 @@ class Capsule: self._client.close() except Exception: pass - - diff --git a/src/wrenn/code_interpreter/async_capsule.py b/src/wrenn/code_interpreter/async_capsule.py index d74eb77..fb99752 100644 --- a/src/wrenn/code_interpreter/async_capsule.py +++ b/src/wrenn/code_interpreter/async_capsule.py @@ -17,7 +17,6 @@ from wrenn.code_interpreter.capsule import DEFAULT_TEMPLATE from wrenn.code_interpreter.models import ( Execution, ExecutionError, - Logs, Result, ) @@ -215,9 +214,7 @@ class AsyncCapsule(BaseAsyncCapsule): if time_left <= 0: break 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): break if not data: @@ -247,9 +244,7 @@ class AsyncCapsule(BaseAsyncCapsule): result = Result.from_bundle(bundle, is_main_result=is_main) execution.results.append(result) if is_main: - execution.execution_count = content.get( - "execution_count" - ) + execution.execution_count = content.get("execution_count") if on_result is not None: on_result(result) elif msg_type == "error": @@ -261,10 +256,7 @@ class AsyncCapsule(BaseAsyncCapsule): execution.error = err if on_error is not None: on_error(err) - elif ( - msg_type == "status" - and content.get("execution_state") == "idle" - ): + elif msg_type == "status" and content.get("execution_state") == "idle": break return execution diff --git a/src/wrenn/code_interpreter/capsule.py b/src/wrenn/code_interpreter/capsule.py index 43a9f54..1b1f7ea 100644 --- a/src/wrenn/code_interpreter/capsule.py +++ b/src/wrenn/code_interpreter/capsule.py @@ -14,7 +14,6 @@ from wrenn.capsule import _build_proxy_url from wrenn.code_interpreter.models import ( Execution, ExecutionError, - Logs, Result, ) @@ -271,9 +270,7 @@ class Capsule(BaseCapsule): result = Result.from_bundle(bundle, is_main_result=is_main) execution.results.append(result) if is_main: - execution.execution_count = content.get( - "execution_count" - ) + execution.execution_count = content.get("execution_count") if on_result is not None: on_result(result) elif msg_type == "error": @@ -285,10 +282,7 @@ class Capsule(BaseCapsule): execution.error = err if on_error is not None: on_error(err) - elif ( - msg_type == "status" - and content.get("execution_state") == "idle" - ): + elif msg_type == "status" and content.get("execution_state") == "idle": break return execution diff --git a/src/wrenn/commands.py b/src/wrenn/commands.py index 4cb005d..7ca9f44 100644 --- a/src/wrenn/commands.py +++ b/src/wrenn/commands.py @@ -197,9 +197,7 @@ class Commands: if tag is not None: payload["tag"] = tag - resp = self._http.post( - f"/v1/capsules/{self._capsule_id}/exec", json=payload - ) + resp = self._http.post(f"/v1/capsules/{self._capsule_id}/exec", json=payload) data = handle_response(resp) if background: @@ -238,9 +236,7 @@ class Commands: Raises: WrennNotFoundError: If no process with the given PID exists. """ - resp = self._http.delete( - f"/v1/capsules/{self._capsule_id}/processes/{pid}" - ) + resp = self._http.delete(f"/v1/capsules/{self._capsule_id}/processes/{pid}") handle_response(resp) def connect(self, pid: int) -> Iterator[StreamEvent]: @@ -267,9 +263,7 @@ class Commands: except httpx_ws.WebSocketDisconnect: break - def stream( - self, cmd: str, args: list[str] | None = None - ) -> Iterator[StreamEvent]: + def stream(self, cmd: str, args: list[str] | None = None) -> Iterator[StreamEvent]: """Execute a command via WebSocket, streaming output as events. Args: @@ -400,9 +394,7 @@ class AsyncCommands: list[ProcessInfo]: Running processes with their PID, tag, and command information. """ - resp = await self._http.get( - f"/v1/capsules/{self._capsule_id}/processes" - ) + resp = await self._http.get(f"/v1/capsules/{self._capsule_id}/processes") data = handle_response(resp) return [ ProcessInfo( diff --git a/tests/conftest.py b/tests/conftest.py index d0b693c..e0a7d65 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,9 @@ def _read_env_file() -> dict[str, str]: return result -def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: +def pytest_collection_modifyitems( + config: pytest.Config, items: list[pytest.Item] +) -> None: env_vars = _read_env_file() has_key = bool(os.environ.get("WRENN_API_KEY") or env_vars.get("WRENN_API_KEY")) if has_key: diff --git a/tests/test_capsule_features.py b/tests/test_capsule_features.py index 58b7be0..5c630da 100644 --- a/tests/test_capsule_features.py +++ b/tests/test_capsule_features.py @@ -1,6 +1,5 @@ from __future__ import annotations -import pytest import respx from wrenn.capsule import Capsule, _build_proxy_url @@ -95,7 +94,9 @@ class TestCapsuleStaticMethods: respx.get(f"{BASE}/v1/capsules/cl-1").respond( 200, json={"id": "cl-1", "status": "running"} ) - info = Capsule._static_get_info("cl-1", api_key="wrn_test1234567890abcdef12345678") + info = Capsule._static_get_info( + "cl-1", api_key="wrn_test1234567890abcdef12345678" + ) assert info.id == "cl-1" @@ -179,7 +180,6 @@ class TestExecutionModels: class TestDeprecationWarnings: def test_import_sandbox_from_wrenn_warns(self): - import importlib import sys import warnings diff --git a/tests/test_client.py b/tests/test_client.py index 00ba03b..08168a6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -246,9 +246,7 @@ class TestAsyncClient: @respx.mock async def test_async_capsules_list(self, async_client): async with async_client: - respx.get(f"{BASE}/v1/capsules").respond( - 200, json=[{"id": "sb-1"}] - ) + respx.get(f"{BASE}/v1/capsules").respond(200, json=[{"id": "sb-1"}]) boxes = await async_client.capsules.list() assert len(boxes) == 1 diff --git a/tests/test_filesystem_pty.py b/tests/test_filesystem_pty.py index 2ed5c51..62ed91e 100644 --- a/tests/test_filesystem_pty.py +++ b/tests/test_filesystem_pty.py @@ -305,9 +305,7 @@ class TestPtySessionIteration: ws = MagicMock() messages = [ json.dumps({"type": "started", "tag": "pty-abc12345", "pid": 1}), - json.dumps( - {"type": "output", "data": base64.b64encode(b"hello").decode()} - ), + json.dumps({"type": "output", "data": base64.b64encode(b"hello").decode()}), json.dumps({"type": "exit", "exit_code": 0}), ] ws.receive_text.side_effect = messages @@ -455,9 +453,7 @@ class TestAsyncPtySession: ws = AsyncMock() messages = [ json.dumps({"type": "started", "tag": "pty-xyz", "pid": 5}), - json.dumps( - {"type": "output", "data": base64.b64encode(b"hi").decode()} - ), + json.dumps({"type": "output", "data": base64.b64encode(b"hi").decode()}), json.dumps({"type": "exit", "exit_code": 0}), ] ws.receive_text.side_effect = messages diff --git a/tests/test_git.py b/tests/test_git.py index 29c9e12..e231834 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -4,14 +4,12 @@ import json import pytest import respx -from httpx import Response from wrenn._git import ( AsyncGit, FileStatus, Git, GitAuthError, - GitBranch, GitCommandError, GitError, GitStatus, @@ -120,9 +118,13 @@ class TestBuildClone: depth=5, ) assert args == [ - "git", "clone", - "--branch", "dev", "--single-branch", - "--depth", "5", + "git", + "clone", + "--branch", + "dev", + "--single-branch", + "--depth", + "5", "https://github.com/user/repo.git", "/tmp/repo", ] @@ -212,7 +214,9 @@ class TestBuildStatus: class TestBuildBranches: def test_args(self): assert build_branches() == [ - "git", "branch", "--format=%(refname:short)\t%(HEAD)" + "git", + "branch", + "--format=%(refname:short)\t%(HEAD)", ] @@ -237,7 +241,13 @@ class TestBuildBranchOps: class TestBuildRemote: def test_add(self): args = build_remote_add("origin", "https://example.com/repo.git") - assert args == ["git", "remote", "add", "origin", "https://example.com/repo.git"] + assert args == [ + "git", + "remote", + "add", + "origin", + "https://example.com/repo.git", + ] def test_add_with_fetch(self): args = build_remote_add("origin", "https://example.com/repo.git", fetch=True) @@ -248,7 +258,13 @@ class TestBuildRemote: def test_set_url(self): args = build_remote_set_url("origin", "https://new.url/repo.git") - assert args == ["git", "remote", "set-url", "origin", "https://new.url/repo.git"] + assert args == [ + "git", + "remote", + "set-url", + "origin", + "https://new.url/repo.git", + ] class TestBuildReset: @@ -445,21 +461,27 @@ class TestStripCredentials: class TestIsAuthError: - @pytest.mark.parametrize("msg", [ - "fatal: Authentication failed for 'https://...'", - "fatal: could not read Username", - "remote: Invalid username or password", - "fatal: terminal prompts disabled", - "Permission denied (publickey)", - ]) + @pytest.mark.parametrize( + "msg", + [ + "fatal: Authentication failed for 'https://...'", + "fatal: could not read Username", + "remote: Invalid username or password", + "fatal: terminal prompts disabled", + "Permission denied (publickey)", + ], + ) def test_auth_patterns(self, msg): assert is_auth_error(msg) is True - @pytest.mark.parametrize("msg", [ - "fatal: repository 'https://...' not found", - "error: pathspec 'foo' did not match any file(s)", - "", - ]) + @pytest.mark.parametrize( + "msg", + [ + "fatal: repository 'https://...' not found", + "error: pathspec 'foo' did not match any file(s)", + "", + ], + ) def test_non_auth_patterns(self, msg): assert is_auth_error(msg) is False @@ -495,7 +517,9 @@ class TestCheckResult: def test_auth_failure(self): result = CommandResult( - stdout="", stderr="fatal: Authentication failed for 'https://...'", exit_code=128 + stdout="", + stderr="fatal: Authentication failed for 'https://...'", + exit_code=128, ) with pytest.raises(GitAuthError) as exc_info: _check_result(result, op="clone") @@ -570,21 +594,27 @@ class TestGitStatus: assert s.is_clean is True def test_has_staged(self): - s = GitStatus(files=[ - FileStatus(path="a.py", index_status="M", work_tree_status=" "), - ]) + s = GitStatus( + files=[ + FileStatus(path="a.py", index_status="M", work_tree_status=" "), + ] + ) assert s.has_staged is True def test_has_untracked(self): - s = GitStatus(files=[ - FileStatus(path="a.py", index_status="?", work_tree_status="?"), - ]) + s = GitStatus( + files=[ + FileStatus(path="a.py", index_status="?", work_tree_status="?"), + ] + ) assert s.has_untracked is True def test_has_conflicts(self): - s = GitStatus(files=[ - FileStatus(path="a.py", index_status="U", work_tree_status="U"), - ]) + s = GitStatus( + files=[ + FileStatus(path="a.py", index_status="U", work_tree_status="U"), + ] + ) assert s.has_conflicts is True @@ -596,18 +626,22 @@ class TestGitStatus: class TestGitInit: @respx.mock def test_init(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stdout="Initialized empty Git repository in /repo/.git/\n" - )) + respx.post(EXEC_URL).respond( + 200, + json=_exec_response( + stdout="Initialized empty Git repository in /repo/.git/\n" + ), + ) git = _make_git() result = git.init("/repo") assert result.exit_code == 0 @respx.mock def test_init_failure(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stderr="fatal: cannot mkdir /readonly", exit_code=128 - )) + respx.post(EXEC_URL).respond( + 200, + json=_exec_response(stderr="fatal: cannot mkdir /readonly", exit_code=128), + ) git = _make_git() with pytest.raises(GitCommandError): git.init("/readonly") @@ -616,9 +650,9 @@ class TestGitInit: class TestGitClone: @respx.mock def test_clone_basic(self): - route = respx.post(EXEC_URL).respond(200, json=_exec_response( - stderr="Cloning into 'repo'...\n" - )) + route = respx.post(EXEC_URL).respond( + 200, json=_exec_response(stderr="Cloning into 'repo'...\n") + ) git = _make_git() result = git.clone("https://github.com/user/repo.git") assert result.exit_code == 0 @@ -627,10 +661,13 @@ class TestGitClone: @respx.mock def test_clone_auth_failure(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stderr="fatal: Authentication failed for 'https://...'", - exit_code=128, - )) + respx.post(EXEC_URL).respond( + 200, + json=_exec_response( + stderr="fatal: Authentication failed for 'https://...'", + exit_code=128, + ), + ) git = _make_git() with pytest.raises(GitAuthError): git.clone("https://github.com/private/repo.git") @@ -667,20 +704,23 @@ class TestGitAdd: class TestGitCommit: @respx.mock def test_commit(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stdout="[main abc1234] initial commit\n" - )) + respx.post(EXEC_URL).respond( + 200, json=_exec_response(stdout="[main abc1234] initial commit\n") + ) git = _make_git() result = git.commit("initial commit", cwd="/repo") assert result.exit_code == 0 @respx.mock def test_commit_nothing_to_commit(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stdout="nothing to commit, working tree clean\n", - stderr="", - exit_code=1, - )) + respx.post(EXEC_URL).respond( + 200, + json=_exec_response( + stdout="nothing to commit, working tree clean\n", + stderr="", + exit_code=1, + ), + ) git = _make_git() with pytest.raises(GitCommandError): git.commit("empty", cwd="/repo") @@ -702,12 +742,15 @@ class TestGitPushPull: assert result.exit_code == 0 -class TestGitStatus: +class TestGitStatusCommand: @respx.mock def test_status(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stdout="## main...origin/main [ahead 1]\n M file.py\n?? new.txt\n" - )) + respx.post(EXEC_URL).respond( + 200, + json=_exec_response( + stdout="## main...origin/main [ahead 1]\n M file.py\n?? new.txt\n" + ), + ) git = _make_git() status = git.status(cwd="/repo") assert isinstance(status, GitStatus) @@ -719,9 +762,9 @@ class TestGitStatus: class TestGitBranches: @respx.mock def test_branches(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stdout="main\t*\ndev\t \n" - )) + respx.post(EXEC_URL).respond( + 200, json=_exec_response(stdout="main\t*\ndev\t \n") + ) git = _make_git() branches = git.branches(cwd="/repo") assert len(branches) == 2 @@ -730,27 +773,27 @@ class TestGitBranches: @respx.mock def test_create_branch(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stderr="Switched to a new branch 'feat'\n" - )) + respx.post(EXEC_URL).respond( + 200, json=_exec_response(stderr="Switched to a new branch 'feat'\n") + ) git = _make_git() result = git.create_branch("feat", cwd="/repo") assert result.exit_code == 0 @respx.mock def test_checkout_branch(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stderr="Switched to branch 'main'\n" - )) + respx.post(EXEC_URL).respond( + 200, json=_exec_response(stderr="Switched to branch 'main'\n") + ) git = _make_git() result = git.checkout_branch("main", cwd="/repo") assert result.exit_code == 0 @respx.mock def test_delete_branch(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stdout="Deleted branch old (was abc1234).\n" - )) + respx.post(EXEC_URL).respond( + 200, json=_exec_response(stdout="Deleted branch old (was abc1234).\n") + ) git = _make_git() result = git.delete_branch("old", cwd="/repo") assert result.exit_code == 0 @@ -766,18 +809,18 @@ class TestGitRemote: @respx.mock def test_remote_get(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stdout="https://example.com/repo.git\n" - )) + respx.post(EXEC_URL).respond( + 200, json=_exec_response(stdout="https://example.com/repo.git\n") + ) git = _make_git() url = git.remote_get("origin", cwd="/repo") assert url == "https://example.com/repo.git" @respx.mock def test_remote_get_not_found(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stderr="fatal: No such remote 'nope'", exit_code=2 - )) + respx.post(EXEC_URL).respond( + 200, json=_exec_response(stderr="fatal: No such remote 'nope'", exit_code=2) + ) git = _make_git() url = git.remote_get("nope", cwd="/repo") assert url is None @@ -816,9 +859,7 @@ class TestGitConfig: @respx.mock def test_get_config_not_set(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stderr="", exit_code=1 - )) + respx.post(EXEC_URL).respond(200, json=_exec_response(stderr="", exit_code=1)) git = _make_git() val = git.get_config("nonexistent.key", scope="global") assert val is None @@ -897,9 +938,9 @@ class TestAsyncGit: @pytest.mark.asyncio @respx.mock async def test_async_init(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stdout="Initialized empty Git repository\n" - )) + respx.post(EXEC_URL).respond( + 200, json=_exec_response(stdout="Initialized empty Git repository\n") + ) git = _make_async_git() result = await git.init("/repo") assert result.exit_code == 0 @@ -907,9 +948,9 @@ class TestAsyncGit: @pytest.mark.asyncio @respx.mock async def test_async_status(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stdout="## main\n M file.py\n" - )) + respx.post(EXEC_URL).respond( + 200, json=_exec_response(stdout="## main\n M file.py\n") + ) git = _make_async_git() status = await git.status(cwd="/repo") assert isinstance(status, GitStatus) @@ -918,9 +959,10 @@ class TestAsyncGit: @pytest.mark.asyncio @respx.mock async def test_async_clone_auth_error(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stderr="fatal: Authentication failed", exit_code=128 - )) + respx.post(EXEC_URL).respond( + 200, + json=_exec_response(stderr="fatal: Authentication failed", exit_code=128), + ) git = _make_async_git() with pytest.raises(GitAuthError): await git.clone("https://github.com/private/repo.git") @@ -928,9 +970,9 @@ class TestAsyncGit: @pytest.mark.asyncio @respx.mock async def test_async_commit(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stdout="[main abc1234] test\n" - )) + respx.post(EXEC_URL).respond( + 200, json=_exec_response(stdout="[main abc1234] test\n") + ) git = _make_async_git() result = await git.commit("test", cwd="/repo") assert result.exit_code == 0 @@ -938,9 +980,9 @@ class TestAsyncGit: @pytest.mark.asyncio @respx.mock async def test_async_branches(self): - respx.post(EXEC_URL).respond(200, json=_exec_response( - stdout="main\t*\ndev\t \n" - )) + respx.post(EXEC_URL).respond( + 200, json=_exec_response(stdout="main\t*\ndev\t \n") + ) git = _make_async_git() branches = await git.branches(cwd="/repo") assert len(branches) == 2 @@ -957,9 +999,9 @@ class TestCommandPayloadWrapping: @respx.mock def test_simple_command(self): - route = respx.post(EXEC_URL).respond(200, json=_exec_response( - stdout="hello world\n" - )) + route = respx.post(EXEC_URL).respond( + 200, json=_exec_response(stdout="hello world\n") + ) git = _make_git() git.init("/repo") body = json.loads(route.calls[0].request.content) @@ -978,9 +1020,7 @@ class TestCommandPayloadWrapping: client = WrennClient(api_key="wrn_test1234567890abcdef12345678") 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")) commands.run("cat /etc/passwd | wc -l") body = json.loads(route.calls[0].request.content) assert body["cmd"] == "/bin/sh" @@ -1082,9 +1122,7 @@ class TestCommandPayloadWrapping: client = WrennClient(api_key="wrn_test1234567890abcdef12345678") 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"}) commands.run("tail -f /var/log/syslog", background=True) body = json.loads(route.calls[0].request.content) assert body["cmd"] == "/bin/sh" diff --git a/tests/test_integration.py b/tests/test_integration.py index 2286c1b..ff66983 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -179,9 +179,7 @@ class TestCommands: assert result.exit_code == 42 def test_run_with_envs(self): - result = self.capsule.commands.run( - "export MY_VAR=test_value && echo $MY_VAR" - ) + result = self.capsule.commands.run("export MY_VAR=test_value && echo $MY_VAR") assert "test_value" in result.stdout def test_run_with_cwd(self): @@ -195,9 +193,7 @@ class TestCommands: assert len(lines) == 3 def test_run_background(self): - handle = self.capsule.commands.run( - "sleep 30", background=True, tag="bg-test" - ) + handle = self.capsule.commands.run("sleep 30", background=True, tag="bg-test") assert isinstance(handle, CommandHandle) assert handle.pid > 0 assert handle.tag == "bg-test" @@ -206,9 +202,7 @@ class TestCommands: self.capsule.commands.kill(handle.pid) def test_list_processes(self): - handle = self.capsule.commands.run( - "sleep 30", background=True, tag="list-test" - ) + handle = self.capsule.commands.run("sleep 30", background=True, tag="list-test") try: time.sleep(0.5) processes = self.capsule.commands.list() @@ -222,9 +216,7 @@ class TestCommands: self.capsule.commands.kill(handle.pid) def test_kill_process(self): - handle = self.capsule.commands.run( - "sleep 30", background=True - ) + handle = self.capsule.commands.run("sleep 30", background=True) self.capsule.commands.kill(handle.pid) time.sleep(0.5) -- 2.49.0 From 2faf0dd0ae31291ea9ad007aa1da680e03b06987 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Thu, 23 Apr 2026 18:36:35 +0600 Subject: [PATCH 21/44] Updated woodpecker config --- .woodpecker/check.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.woodpecker/check.yml b/.woodpecker/check.yml index 0553fc4..23d9bba 100644 --- a/.woodpecker/check.yml +++ b/.woodpecker/check.yml @@ -1,4 +1,5 @@ when: + event: push branch: - main - dev -- 2.49.0 From aa9477ffe807a5ec29b564fd1370e7d42d161800 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Fri, 24 Apr 2026 00:01:20 +0600 Subject: [PATCH 22/44] Added doc generator for SDK --- .woodpecker/check.yml | 3 + Makefile | 4 + docs/reference.md | 4274 +++++++++++++++++++++++++++++++++++++++++ pydoc-markdown.yml | 12 + pyproject.toml | 1 + uv.lock | 368 ++++ 6 files changed, 4662 insertions(+) create mode 100644 docs/reference.md create mode 100644 pydoc-markdown.yml diff --git a/.woodpecker/check.yml b/.woodpecker/check.yml index 23d9bba..1cc4e69 100644 --- a/.woodpecker/check.yml +++ b/.woodpecker/check.yml @@ -3,6 +3,9 @@ when: branch: - main - dev + path: + - "src/**" + - "tests/**" steps: unit-tests: diff --git a/Makefile b/Makefile index 7b1b356..65b3a04 100644 --- a/Makefile +++ b/Makefile @@ -36,3 +36,7 @@ test-integration: uv run pytest tests/ -v -m "integration or not integration" check: lint test + +gen-docs: + mkdir -p docs + uv run pydoc-markdown > docs/reference.md diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..7e32f6c --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,4274 @@ + + +# wrenn + + + +# wrenn.client + + + +## CapsulesResource Objects + +```python +class CapsulesResource() +``` + +Sync capsule control-plane operations. + + + +#### create + +```python +def create(template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout_sec: int | None = None) -> CapsuleModel +``` + +Create a new capsule. + +**Arguments**: + +- `template` _str | None_ - Template name to boot from. +- `vcpus` _int | None_ - Number of virtual CPUs. +- `memory_mb` _int | None_ - Memory in MiB. +- `timeout_sec` _int | None_ - Inactivity TTL in seconds before + auto-pause. ``0`` disables auto-pause. + + +**Returns**: + +- `CapsuleModel` - The newly created capsule. + + + +#### list + +```python +def list() -> list[CapsuleModel] +``` + +List all capsules for the authenticated team. + +**Returns**: + +- `list[CapsuleModel]` - All capsules belonging to the team. + + + +#### get + +```python +def get(id: str) -> CapsuleModel +``` + +Get a capsule by ID. + +**Arguments**: + +- `id` _str_ - Capsule ID. + + +**Returns**: + +- `CapsuleModel` - Current state of the capsule. + + +**Raises**: + +- `WrennNotFoundError` - If no capsule with the given ID exists. + + + +#### destroy + +```python +def destroy(id: str) -> None +``` + +Destroy a capsule permanently. + +**Arguments**: + +- `id` _str_ - Capsule ID. + + +**Raises**: + +- `WrennNotFoundError` - If no capsule with the given ID exists. + + + +#### pause + +```python +def pause(id: str) -> CapsuleModel +``` + +Pause a running capsule. + +**Arguments**: + +- `id` _str_ - Capsule ID. + + +**Returns**: + +- `CapsuleModel` - Updated capsule state. + + +**Raises**: + +- `WrennNotFoundError` - If no capsule with the given ID exists. + + + +#### resume + +```python +def resume(id: str) -> CapsuleModel +``` + +Resume a paused capsule. + +**Arguments**: + +- `id` _str_ - Capsule ID. + + +**Returns**: + +- `CapsuleModel` - Updated capsule state. + + +**Raises**: + +- `WrennNotFoundError` - If no capsule with the given ID exists. + + + +#### ping + +```python +def ping(id: str) -> None +``` + +Reset the inactivity timer for a capsule. + +**Arguments**: + +- `id` _str_ - Capsule ID. + + +**Raises**: + +- `WrennNotFoundError` - If no capsule with the given ID exists. + + + +## AsyncCapsulesResource Objects + +```python +class AsyncCapsulesResource() +``` + +Async capsule control-plane operations. + + + +#### create + +```python +async def create(template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout_sec: int | None = None) -> CapsuleModel +``` + +Create a new capsule. + +**Arguments**: + +- `template` _str | None_ - Template name to boot from. +- `vcpus` _int | None_ - Number of virtual CPUs. +- `memory_mb` _int | None_ - Memory in MiB. +- `timeout_sec` _int | None_ - Inactivity TTL in seconds before + auto-pause. ``0`` disables auto-pause. + + +**Returns**: + +- `CapsuleModel` - The newly created capsule. + + + +#### list + +```python +async def list() -> list[CapsuleModel] +``` + +List all capsules for the authenticated team. + +**Returns**: + +- `list[CapsuleModel]` - All capsules belonging to the team. + + + +#### get + +```python +async def get(id: str) -> CapsuleModel +``` + +Get a capsule by ID. + +**Arguments**: + +- `id` _str_ - Capsule ID. + + +**Returns**: + +- `CapsuleModel` - Current state of the capsule. + + +**Raises**: + +- `WrennNotFoundError` - If no capsule with the given ID exists. + + + +#### destroy + +```python +async def destroy(id: str) -> None +``` + +Destroy a capsule permanently. + +**Arguments**: + +- `id` _str_ - Capsule ID. + + +**Raises**: + +- `WrennNotFoundError` - If no capsule with the given ID exists. + + + +#### pause + +```python +async def pause(id: str) -> CapsuleModel +``` + +Pause a running capsule. + +**Arguments**: + +- `id` _str_ - Capsule ID. + + +**Returns**: + +- `CapsuleModel` - Updated capsule state. + + +**Raises**: + +- `WrennNotFoundError` - If no capsule with the given ID exists. + + + +#### resume + +```python +async def resume(id: str) -> CapsuleModel +``` + +Resume a paused capsule. + +**Arguments**: + +- `id` _str_ - Capsule ID. + + +**Returns**: + +- `CapsuleModel` - Updated capsule state. + + +**Raises**: + +- `WrennNotFoundError` - If no capsule with the given ID exists. + + + +#### ping + +```python +async def ping(id: str) -> None +``` + +Reset the inactivity timer for a capsule. + +**Arguments**: + +- `id` _str_ - Capsule ID. + + +**Raises**: + +- `WrennNotFoundError` - If no capsule with the given ID exists. + + + +## SnapshotsResource Objects + +```python +class SnapshotsResource() +``` + +Sync snapshot operations. + + + +#### create + +```python +def create(capsule_id: str, + name: str | None = None, + overwrite: bool = False) -> Template +``` + +Create a snapshot template from a running capsule. + +**Arguments**: + +- `capsule_id` _str_ - ID of the capsule to snapshot. +- `name` _str | None_ - Name for the snapshot template. Auto-generated + if not provided. +- `overwrite` _bool_ - If ``True``, overwrite an existing template with + the same name. Defaults to ``False``. + + +**Returns**: + +- `Template` - The created snapshot template. + + + +#### list + +```python +def list(type: str | None = None) -> list[Template] +``` + +List snapshot templates. + +**Arguments**: + +- `type` _str | None_ - Filter by template type. Returns all templates + if not provided. + + +**Returns**: + +- `list[Template]` - Matching snapshot templates. + + + +#### delete + +```python +def delete(name: str) -> None +``` + +Delete a snapshot template by name. + +**Arguments**: + +- `name` _str_ - Template name to delete. + + +**Raises**: + +- `WrennNotFoundError` - If no template with the given name exists. + + + +## AsyncSnapshotsResource Objects + +```python +class AsyncSnapshotsResource() +``` + +Async snapshot operations. + + + +#### create + +```python +async def create(capsule_id: str, + name: str | None = None, + overwrite: bool = False) -> Template +``` + +Create a snapshot template from a running capsule. + +**Arguments**: + +- `capsule_id` _str_ - ID of the capsule to snapshot. +- `name` _str | None_ - Name for the snapshot template. Auto-generated + if not provided. +- `overwrite` _bool_ - If ``True``, overwrite an existing template with + the same name. Defaults to ``False``. + + +**Returns**: + +- `Template` - The created snapshot template. + + + +#### list + +```python +async def list(type: str | None = None) -> list[Template] +``` + +List snapshot templates. + +**Arguments**: + +- `type` _str | None_ - Filter by template type. Returns all templates + if not provided. + + +**Returns**: + +- `list[Template]` - Matching snapshot templates. + + + +#### delete + +```python +async def delete(name: str) -> None +``` + +Delete a snapshot template by name. + +**Arguments**: + +- `name` _str_ - Template name to delete. + + +**Raises**: + +- `WrennNotFoundError` - If no template with the given name exists. + + + +## WrennClient Objects + +```python +class WrennClient() +``` + +Synchronous client for the Wrenn API. + +Authenticates with an API key. + +**Arguments**: + +- `api_key` - API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var. +- `base_url` - Wrenn API base URL. + + + +#### http + +```python +@property +def http() -> httpx.Client +``` + +The underlying httpx.Client (for sub-objects that need direct access). + + + +#### close + +```python +def close() -> None +``` + +Close the underlying HTTP connection pool. + + + +## AsyncWrennClient Objects + +```python +class AsyncWrennClient() +``` + +Asynchronous client for the Wrenn API. + +Authenticates with an API key. + +**Arguments**: + +- `api_key` - API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var. +- `base_url` - Wrenn API base URL. Falls back to ``WRENN_BASE_URL`` env var. + + + +#### http + +```python +@property +def http() -> httpx.AsyncClient +``` + +The underlying httpx.AsyncClient. + + + +#### aclose + +```python +async def aclose() -> None +``` + +Close the underlying async HTTP connection pool. + + + +# wrenn.sandbox + + + +# wrenn.commands + + + +## CommandResult Objects + +```python +@dataclass +class CommandResult() +``` + +Result from a foreground command execution. + + + +## CommandHandle Objects + +```python +@dataclass +class CommandHandle() +``` + +Handle for a background process. + + + +## ProcessInfo Objects + +```python +@dataclass +class ProcessInfo() +``` + +Information about a running process. + + + +## StreamEvent Objects + +```python +class StreamEvent() +``` + +Base class for streaming exec events. + + + +## Commands Objects + +```python +class Commands() +``` + +Sync command execution interface. Accessed via ``capsule.commands``. + + + +#### run + +```python +def run(cmd: str, + *, + background: bool = False, + timeout: int | None = 30, + envs: dict[str, str] | None = None, + cwd: str | None = None, + tag: str | None = None) -> CommandResult | CommandHandle +``` + +Execute a shell command inside the capsule. + +**Arguments**: + +- `cmd` _str_ - Shell command string to execute. +- `background` _bool_ - If ``True``, launch the process in the + background and return a :class:`CommandHandle` immediately. + Defaults to ``False``. +- `timeout` _int | None_ - Seconds before the foreground command times + out. Ignored for background commands. Defaults to ``30``. +- `envs` _dict[str, str] | None_ - Additional environment variables + to set for the process. +- `cwd` _str | None_ - Working directory for the process. +- `tag` _str | None_ - Optional label attached to background processes + for later retrieval via :meth:`connect`. + + +**Returns**: + +- `CommandResult` - stdout, stderr, exit code, and duration for + foreground commands (``background=False``). + +- `CommandHandle` - PID and tag for background commands + (``background=True``). + + + +#### list + +```python +def list() -> list[ProcessInfo] +``` + +List all running background processes in the capsule. + +**Returns**: + +- `list[ProcessInfo]` - Running processes with their PID, tag, and + command information. + + + +#### kill + +```python +def kill(pid: int) -> None +``` + +Send SIGKILL to a background process. + +**Arguments**: + +- `pid` _int_ - PID of the process to kill. + + +**Raises**: + +- `WrennNotFoundError` - If no process with the given PID exists. + + + +#### connect + +```python +def connect(pid: int) -> Iterator[StreamEvent] +``` + +Connect to a running background process and stream its output. + +**Arguments**: + +- `pid` _int_ - PID of the background process to attach to. + + +**Yields**: + +- `StreamEvent` - Successive output events. Stops on + :class:`StreamExitEvent` or :class:`StreamErrorEvent`. + + + +#### stream + +```python +def stream(cmd: str, args: list[str] | None = None) -> Iterator[StreamEvent] +``` + +Execute a command via WebSocket, streaming output as events. + +**Arguments**: + +- `cmd` _str_ - Command to execute. +- `args` _list[str] | None_ - Additional arguments for the command. + When omitted, *cmd* is interpreted as a shell command + string and executed via ``/bin/sh -c``. + + +**Yields**: + +- `StreamEvent` - Successive events including :class:`StreamStartEvent`, + :class:`StreamStdoutEvent`, :class:`StreamStderrEvent`, + :class:`StreamExitEvent`, and :class:`StreamErrorEvent`. + + + +## AsyncCommands Objects + +```python +class AsyncCommands() +``` + +Async command execution interface. Accessed via ``capsule.commands``. + + + +#### run + +```python +async def run(cmd: str, + *, + background: bool = False, + timeout: int | None = 30, + envs: dict[str, str] | None = None, + cwd: str | None = None, + tag: str | None = None) -> CommandResult | CommandHandle +``` + +Execute a shell command inside the capsule. + +**Arguments**: + +- `cmd` _str_ - Shell command string to execute. +- `background` _bool_ - If ``True``, launch the process in the + background and return a :class:`CommandHandle` immediately. + Defaults to ``False``. +- `timeout` _int | None_ - Seconds before the foreground command times + out. Ignored for background commands. Defaults to ``30``. +- `envs` _dict[str, str] | None_ - Additional environment variables + to set for the process. +- `cwd` _str | None_ - Working directory for the process. +- `tag` _str | None_ - Optional label attached to background processes + for later retrieval via :meth:`connect`. + + +**Returns**: + +- `CommandResult` - stdout, stderr, exit code, and duration for + foreground commands (``background=False``). + +- `CommandHandle` - PID and tag for background commands + (``background=True``). + + + +#### list + +```python +async def list() -> list[ProcessInfo] +``` + +List all running background processes in the capsule. + +**Returns**: + +- `list[ProcessInfo]` - Running processes with their PID, tag, and + command information. + + + +#### kill + +```python +async def kill(pid: int) -> None +``` + +Send SIGKILL to a background process. + +**Arguments**: + +- `pid` _int_ - PID of the process to kill. + + +**Raises**: + +- `WrennNotFoundError` - If no process with the given PID exists. + + + +#### connect + +```python +async def connect(pid: int) -> AsyncIterator[StreamEvent] +``` + +Connect to a running background process and stream its output. + +**Arguments**: + +- `pid` _int_ - PID of the background process to attach to. + + +**Yields**: + +- `StreamEvent` - Successive output events. Stops on + :class:`StreamExitEvent` or :class:`StreamErrorEvent`. + + + +#### stream + +```python +async def stream(cmd: str, + args: list[str] | None = None) -> AsyncIterator[StreamEvent] +``` + +Execute a command via WebSocket, streaming output as events. + +**Arguments**: + +- `cmd` _str_ - Command to execute. +- `args` _list[str] | None_ - Additional arguments for the command. + When omitted, *cmd* is interpreted as a shell command + string and executed via ``/bin/sh -c``. + + +**Yields**: + +- `StreamEvent` - Successive events including :class:`StreamStartEvent`, + :class:`StreamStdoutEvent`, :class:`StreamStderrEvent`, + :class:`StreamExitEvent`, and :class:`StreamErrorEvent`. + + + +# wrenn.files + + + +## Files Objects + +```python +class Files() +``` + +Sync filesystem interface. Accessed via ``capsule.files``. + + + +#### read + +```python +def read(path: str) -> str +``` + +Read a file as a UTF-8 string. + +**Arguments**: + +- `path` _str_ - Absolute path to the file inside the capsule. + + +**Returns**: + +- `str` - File contents decoded as UTF-8. + + +**Raises**: + +- `WrennNotFoundError` - If the path does not exist. + + + +#### read\_bytes + +```python +def read_bytes(path: str) -> bytes +``` + +Read a file as raw bytes. + +**Arguments**: + +- `path` _str_ - Absolute path to the file inside the capsule. + + +**Returns**: + +- `bytes` - Raw file contents. + + +**Raises**: + +- `WrennNotFoundError` - If the path does not exist. + + + +#### write + +```python +def write(path: str, data: str | bytes) -> None +``` + +Write data to a file inside the capsule. + +Creates parent directories if they do not exist. + +**Arguments**: + +- `path` _str_ - Absolute destination path inside the capsule. +- `data` _str | bytes_ - Content to write. Strings are UTF-8 encoded. + + + +#### list + +```python +def list(path: str, depth: int = 1) -> list[FileEntry] +``` + +List directory contents. + +**Arguments**: + +- `path` _str_ - Absolute path to the directory inside the capsule. +- `depth` _int_ - Recursion depth. ``1`` lists only immediate children. + Defaults to ``1``. + + +**Returns**: + +- `list[FileEntry]` - Entries in the directory. + + +**Raises**: + +- `WrennNotFoundError` - If the path does not exist. + + + +#### exists + +```python +def exists(path: str) -> bool +``` + +Check whether a path exists inside the capsule. + +**Arguments**: + +- `path` _str_ - Absolute path to check. + + +**Returns**: + +- `bool` - ``True`` if the path exists. + + + +#### make\_dir + +```python +def make_dir(path: str) -> FileEntry +``` + +Create a directory (with parents). Idempotent. + +**Arguments**: + +- `path` _str_ - Absolute path of the directory to create. + + +**Returns**: + +- `FileEntry` - The created (or already-existing) directory entry. + + + +#### remove + +```python +def remove(path: str) -> None +``` + +Remove a file or directory recursively. + +**Arguments**: + +- `path` _str_ - Absolute path to remove. + + +**Raises**: + +- `WrennNotFoundError` - If the path does not exist. + + + +#### upload\_stream + +```python +def upload_stream(path: str, stream: Iterator[bytes]) -> None +``` + +Stream a large file into the capsule. + +Prefer this over :meth:`write` when the file is too large to hold in +memory. + +**Arguments**: + +- `path` _str_ - Absolute destination path inside the capsule. +- `stream` _Iterator[bytes]_ - Iterable of byte chunks to upload. + + + +#### download\_stream + +```python +def download_stream(path: str) -> Iterator[bytes] +``` + +Stream a large file out of the capsule. + +Prefer this over :meth:`read_bytes` when the file is too large to hold +in memory. + +**Arguments**: + +- `path` _str_ - Absolute path to the file inside the capsule. + + +**Yields**: + +- `bytes` - Successive byte chunks of the file. + + +**Raises**: + +- `WrennNotFoundError` - If the path does not exist. + + + +## AsyncFiles Objects + +```python +class AsyncFiles() +``` + +Async filesystem interface. Accessed via ``capsule.files``. + + + +#### read + +```python +async def read(path: str) -> str +``` + +Read a file as a UTF-8 string. + +**Arguments**: + +- `path` _str_ - Absolute path to the file inside the capsule. + + +**Returns**: + +- `str` - File contents decoded as UTF-8. + + +**Raises**: + +- `WrennNotFoundError` - If the path does not exist. + + + +#### read\_bytes + +```python +async def read_bytes(path: str) -> bytes +``` + +Read a file as raw bytes. + +**Arguments**: + +- `path` _str_ - Absolute path to the file inside the capsule. + + +**Returns**: + +- `bytes` - Raw file contents. + + +**Raises**: + +- `WrennNotFoundError` - If the path does not exist. + + + +#### write + +```python +async def write(path: str, data: str | bytes) -> None +``` + +Write data to a file inside the capsule. + +Creates parent directories if they do not exist. + +**Arguments**: + +- `path` _str_ - Absolute destination path inside the capsule. +- `data` _str | bytes_ - Content to write. Strings are UTF-8 encoded. + + + +#### list + +```python +async def list(path: str, depth: int = 1) -> list[FileEntry] +``` + +List directory contents. + +**Arguments**: + +- `path` _str_ - Absolute path to the directory inside the capsule. +- `depth` _int_ - Recursion depth. ``1`` lists only immediate children. + Defaults to ``1``. + + +**Returns**: + +- `list[FileEntry]` - Entries in the directory. + + +**Raises**: + +- `WrennNotFoundError` - If the path does not exist. + + + +#### exists + +```python +async def exists(path: str) -> bool +``` + +Check whether a path exists inside the capsule. + +**Arguments**: + +- `path` _str_ - Absolute path to check. + + +**Returns**: + +- `bool` - ``True`` if the path exists. + + + +#### make\_dir + +```python +async def make_dir(path: str) -> FileEntry +``` + +Create a directory (with parents). Idempotent. + +**Arguments**: + +- `path` _str_ - Absolute path of the directory to create. + + +**Returns**: + +- `FileEntry` - The created (or already-existing) directory entry. + + + +#### remove + +```python +async def remove(path: str) -> None +``` + +Remove a file or directory recursively. + +**Arguments**: + +- `path` _str_ - Absolute path to remove. + + +**Raises**: + +- `WrennNotFoundError` - If the path does not exist. + + + +#### upload\_stream + +```python +async def upload_stream(path: str, stream: AsyncIterator[bytes]) -> None +``` + +Stream a large file into the capsule. + +Prefer this over :meth:`write` when the file is too large to hold in +memory. + +**Arguments**: + +- `path` _str_ - Absolute destination path inside the capsule. +- `stream` _AsyncIterator[bytes]_ - Async iterable of byte chunks to + upload. + + + +#### download\_stream + +```python +async def download_stream(path: str) -> AsyncIterator[bytes] +``` + +Stream a large file out of the capsule. + +Prefer this over :meth:`read_bytes` when the file is too large to hold +in memory. + +**Arguments**: + +- `path` _str_ - Absolute path to the file inside the capsule. + + +**Yields**: + +- `bytes` - Successive byte chunks of the file. + + +**Raises**: + +- `WrennNotFoundError` - If the path does not exist. + + + +# wrenn.code\_interpreter.models + + + +## ExecutionError Objects + +```python +@dataclass +class ExecutionError() +``` + +Error raised during code execution. + +**Attributes**: + +- `name` - Exception class name (e.g. ``"NameError"``). +- `value` - Exception message. +- `traceback` - Full traceback string. + + + +## Logs Objects + +```python +@dataclass +class Logs() +``` + +Captured stdout/stderr streams. + +Each element in the list is one chunk of text as it arrived from +the kernel. + + + +## Result Objects + +```python +@dataclass +class Result() +``` + +A single rich output from code execution. + +Jupyter cells can produce multiple outputs — one ``execute_result`` +(the expression value) and zero or more ``display_data`` messages +(from ``plt.show()``, ``display()``, etc.). Each becomes a +``Result``. + +Known MIME types are unpacked into named attributes; anything else +lands in :pyattr:`extra`. + + + +#### text + +``text/plain`` representation. + + + +#### html + +``text/html`` representation. + + + +#### markdown + +``text/markdown`` representation. + + + +#### svg + +``image/svg+xml`` representation. + + + +#### png + +``image/png`` — base64-encoded. + + + +#### jpeg + +``image/jpeg`` — base64-encoded. + + + +#### pdf + +``application/pdf`` — base64-encoded. + + + +#### latex + +``text/latex`` representation. + + + +#### json + +``application/json`` representation. + + + +#### javascript + +``application/javascript`` representation. + + + +#### extra + +MIME types not covered by the named fields above. + + + +#### is\_main\_result + +``True`` when this came from an ``execute_result`` message +(i.e. the value of the last expression in the cell). ``False`` +for ``display_data`` outputs. + + + +#### from\_bundle + +```python +@classmethod +def from_bundle(cls, + bundle: dict[str, str], + *, + is_main_result: bool = False) -> Result +``` + +Build a ``Result`` from a Jupyter MIME bundle dict. + + + +#### formats + +```python +def formats() -> list[str] +``` + +Return names of non-``None`` MIME-type fields. + + + +## Execution Objects + +```python +@dataclass +class Execution() +``` + +Complete result of a ``run_code`` call. + +**Attributes**: + +- `results` - All rich outputs produced by the cell — charts, tables, + images, expression values, etc. +- `logs` - Captured stdout/stderr text. +- `error` - Populated when the cell raised an exception. +- `execution_count` - Jupyter execution counter (the ``[N]`` number). + + + +#### text + +```python +@property +def text() -> str | None +``` + +Convenience — ``text/plain`` of the main ``execute_result``, +or ``None`` if the cell had no expression value. + + + +# wrenn.code\_interpreter.async\_capsule + + + +## AsyncCapsule Objects + +```python +class AsyncCapsule(BaseAsyncCapsule) +``` + +Async code interpreter capsule with ``run_code`` support. + +Uses ``code-runner-beta`` template by default:: + +from wrenn.code_interpreter import AsyncCapsule + +capsule = await AsyncCapsule.create() +result = await capsule.run_code("print('hello')") + + + +#### create + +```python +@classmethod +async def create(cls, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + wait: bool = False, + api_key: str | None = None, + base_url: str | None = None) -> AsyncCapsule +``` + +Create a new async code interpreter capsule. + +**Arguments**: + +- `template` _str | None_ - Template to boot from. Defaults to + ``"code-runner-beta"``. +- `vcpus` _int | None_ - Number of virtual CPUs. +- `memory_mb` _int | None_ - Memory in MiB. +- `timeout` _int | None_ - Inactivity TTL in seconds before auto-pause. +- `wait` _bool_ - Await until the capsule reaches ``running`` status. +- `api_key` _str | None_ - Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. +- `base_url` _str | None_ - API base URL override. + + +**Returns**: + +- `AsyncCapsule` - A new async code interpreter capsule instance. + + + +#### run\_code + +```python +async def run_code( + code: str, + language: str = "python", + timeout: float = 30, + jupyter_timeout: float = 30, + on_result: Callable[[Result], Any] | None = None, + on_stdout: Callable[[str], Any] | None = None, + on_stderr: Callable[[str], Any] | None = None, + on_error: Callable[[ExecutionError], Any] | None = None) -> Execution +``` + +Execute code in a persistent Jupyter kernel (async). + +**Arguments**: + +- `code` - Code string to execute. +- `language` - Execution backend language. Currently only ``"python"``. +- `timeout` - Maximum seconds to wait for execution to complete. +- `jupyter_timeout` - Maximum seconds to wait for Jupyter to become + available. +- `on_result` - Called for each rich output (charts, images, expression + values). +- `on_stdout` - Called for each stdout chunk. +- `on_stderr` - Called for each stderr chunk. +- `on_error` - Called when the cell raises an exception. + + +**Returns**: + + An :class:`Execution` with ``.results``, ``.logs``, ``.error``, + and a convenience ``.text`` property. + + + +# wrenn.code\_interpreter + + + +# wrenn.code\_interpreter.capsule + + + +## Capsule Objects + +```python +class Capsule(BaseCapsule) +``` + +Code interpreter capsule with ``run_code`` support. + +Uses ``code-runner-beta`` template by default:: + +from wrenn.code_interpreter import Capsule + +capsule = Capsule() +result = capsule.run_code("print('hello')") +print(result.logs.stdout) # ["hello\n"] + + + +#### \_\_init\_\_ + +```python +def __init__(template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + api_key: str | None = None, + base_url: str | None = None, + **kwargs) -> None +``` + +Create a code interpreter capsule. + +**Arguments**: + +- `template` _str | None_ - Template to boot from. Defaults to + ``"code-runner-beta"``. +- `vcpus` _int | None_ - Number of virtual CPUs. +- `memory_mb` _int | None_ - Memory in MiB. +- `timeout` _int | None_ - Inactivity TTL in seconds before auto-pause. +- `api_key` _str | None_ - Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. +- `base_url` _str | None_ - API base URL override. + + + +#### create + +```python +@classmethod +def create(cls, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + wait: bool = False, + api_key: str | None = None, + base_url: str | None = None) -> Capsule +``` + +Create a new code interpreter capsule. + +**Arguments**: + +- `template` _str | None_ - Template to boot from. Defaults to + ``"code-runner-beta"``. +- `vcpus` _int | None_ - Number of virtual CPUs. +- `memory_mb` _int | None_ - Memory in MiB. +- `timeout` _int | None_ - Inactivity TTL in seconds before auto-pause. +- `wait` _bool_ - Block until the capsule reaches ``running`` status. +- `api_key` _str | None_ - Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. +- `base_url` _str | None_ - API base URL override. + + +**Returns**: + +- `Capsule` - A new code interpreter capsule instance. + + + +#### run\_code + +```python +def run_code( + code: str, + language: str = "python", + timeout: float = 30, + jupyter_timeout: float = 30, + on_result: Callable[[Result], Any] | None = None, + on_stdout: Callable[[str], Any] | None = None, + on_stderr: Callable[[str], Any] | None = None, + on_error: Callable[[ExecutionError], Any] | None = None) -> Execution +``` + +Execute code in a persistent Jupyter kernel. + +Variables, imports, and function definitions survive across calls. + +**Arguments**: + +- `code` - Code string to execute. +- `language` - Execution backend language. Currently only ``"python"``. +- `timeout` - Maximum seconds to wait for execution to complete. +- `jupyter_timeout` - Maximum seconds to wait for Jupyter to become + available. +- `on_result` - Called for each rich output (charts, images, expression + values). +- `on_stdout` - Called for each stdout chunk. +- `on_stderr` - Called for each stderr chunk. +- `on_error` - Called when the cell raises an exception. + + +**Returns**: + + An :class:`Execution` with ``.results``, ``.logs``, ``.error``, + and a convenience ``.text`` property. + + + +# wrenn.exceptions + + + +## WrennError Objects + +```python +class WrennError(Exception) +``` + +Base exception for all Wrenn SDK errors. + +All SDK exceptions inherit from this class, so you can catch +``WrennError`` to handle any API error generically. + +**Attributes**: + +- `code` _str_ - Machine-readable error code from the API + (e.g. ``"not_found"``). +- `message` _str_ - Human-readable error description. +- `status_code` _int_ - HTTP status code of the response. + + + +#### \_\_init\_\_ + +```python +def __init__(code: str, message: str, status_code: int) -> None +``` + +Initialize a WrennError. + +**Arguments**: + +- `code` _str_ - Machine-readable error code. +- `message` _str_ - Human-readable error description. +- `status_code` _int_ - HTTP status code of the response. + + + +## WrennValidationError Objects + +```python +class WrennValidationError(WrennError) +``` + +400 — Invalid request parameters. + + + +## WrennAuthenticationError Objects + +```python +class WrennAuthenticationError(WrennError) +``` + +401 — Invalid or missing authentication. + + + +## WrennForbiddenError Objects + +```python +class WrennForbiddenError(WrennError) +``` + +403 — Authenticated but not authorized. + + + +## WrennNotFoundError Objects + +```python +class WrennNotFoundError(WrennError) +``` + +404 — Resource not found. + + + +## WrennConflictError Objects + +```python +class WrennConflictError(WrennError) +``` + +409 — State conflict (e.g. invalid_state). + + + +## WrennHostHasCapsulesError Objects + +```python +class WrennHostHasCapsulesError(WrennConflictError) +``` + +409 — Host still has running capsules. + +**Attributes**: + +- `capsule_ids` _list[str]_ - IDs of the capsules still running on the host. + + + +#### \_\_init\_\_ + +```python +def __init__(code: str, message: str, status_code: int, + capsule_ids: list[str]) -> None +``` + +Initialize a WrennHostHasCapsulesError. + +**Arguments**: + +- `code` _str_ - Machine-readable error code. +- `message` _str_ - Human-readable error description. +- `status_code` _int_ - HTTP status code of the response. +- `capsule_ids` _list[str]_ - IDs of capsules still on the host. + + + +## WrennHostUnavailableError Objects + +```python +class WrennHostUnavailableError(WrennError) +``` + +503 — No suitable host available. + + + +## WrennAgentError Objects + +```python +class WrennAgentError(WrennError) +``` + +502 — Host agent returned an error. + + + +## WrennInternalError Objects + +```python +class WrennInternalError(WrennError) +``` + +500 — Unexpected server error. + + + +# wrenn.async\_capsule + + + +## AsyncCapsule Objects + +```python +class AsyncCapsule() +``` + +Async Wrenn capsule with e2b-compatible interface. + +Create via classmethod:: + +capsule = await AsyncCapsule.create(template="minimal") + +Use as async context manager:: + +async with await AsyncCapsule.create() as capsule: +await capsule.commands.run("echo hello") + + + +#### capsule\_id + +```python +@property +def capsule_id() -> str +``` + +The capsule's unique identifier. + +**Returns**: + +- `str` - Capsule ID assigned by the Wrenn API. + + + +#### info + +```python +@property +def info() -> CapsuleModel | None +``` + +Cached capsule metadata from the last API call. + +**Returns**: + + CapsuleModel | None: The last-fetched capsule model, or ``None`` + if the capsule was connected without an initial fetch. + + + +#### create + +```python +@classmethod +async def create(cls, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + wait: bool = False, + api_key: str | None = None, + base_url: str | None = None) -> AsyncCapsule +``` + +Create a new capsule. + +**Arguments**: + +- `template` _str | None_ - Template name to boot from. +- `vcpus` _int | None_ - Number of virtual CPUs. +- `memory_mb` _int | None_ - Memory in MiB. +- `timeout` _int | None_ - Inactivity TTL in seconds before auto-pause. +- `wait` _bool_ - Await until the capsule reaches ``running`` status. +- `api_key` _str | None_ - Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. +- `base_url` _str | None_ - API base URL override. + + +**Returns**: + +- `AsyncCapsule` - A new capsule instance. + + + +#### connect + +```python +@classmethod +async def connect(cls, + capsule_id: str, + *, + api_key: str | None = None, + base_url: str | None = None) -> AsyncCapsule +``` + +Connect to an existing capsule, resuming it if paused. + +**Arguments**: + +- `capsule_id` _str_ - ID of the capsule to connect to. +- `api_key` _str | None_ - Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. +- `base_url` _str | None_ - API base URL override. + + +**Returns**: + +- `AsyncCapsule` - A capsule instance bound to the existing capsule. + + +**Raises**: + +- `WrennNotFoundError` - If no capsule with the given ID exists. + + + +#### ping + +```python +async def ping() -> None +``` + +Reset the capsule inactivity timer. + +Call this to prevent the capsule from being auto-paused when the +inactivity TTL is set. + + + +#### wait\_ready + +```python +async def wait_ready(timeout: float = 30, interval: float = 0.5) -> None +``` + +Await until the capsule status is ``running``. + +**Arguments**: + +- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``. +- `interval` _float_ - Polling interval in seconds. Defaults to ``0.5``. + + +**Raises**: + +- `TimeoutError` - If the capsule does not reach ``running`` state + within ``timeout`` seconds. +- `RuntimeError` - If the capsule enters an error, stopped, or paused + state while waiting. + + + +#### is\_running + +```python +async def is_running() -> bool +``` + +Check whether the capsule is currently running. + +Makes a live API call to fetch current status. + +**Returns**: + +- `bool` - ``True`` if the capsule status is ``running``. + + + +#### list + +```python +@classmethod +async def list(cls, + *, + api_key: str | None = None, + base_url: str | None = None) -> list[CapsuleModel] +``` + +List all capsules belonging to the team. + +**Arguments**: + +- `api_key` _str | None_ - Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. +- `base_url` _str | None_ - API base URL override. + + +**Returns**: + +- `list[CapsuleModel]` - All capsules for the authenticated team. + + + +#### pty + +```python +@asynccontextmanager +async def pty(cmd: str = "/bin/bash", + args: list[str] | None = None, + cols: int = 80, + rows: int = 24, + envs: dict[str, str] | None = None, + cwd: str | None = None) -> AsyncIterator[AsyncPtySession] +``` + +Open an async interactive PTY session backed by a WebSocket. + +Use as an async context manager and async iterate over +:class:`PtyEvent` objects:: + +async with capsule.pty() as term: +await term.write(b"echo hello\n") +async for event in term: +if event.type == "output": +print(event.data.decode()) + +**Arguments**: + +- `cmd` _str_ - Command to run inside the PTY. Defaults to + ``"/bin/bash"``. +- `args` _list[str] | None_ - Additional arguments for ``cmd``. +- `cols` _int_ - Initial terminal column count. Defaults to ``80``. +- `rows` _int_ - Initial terminal row count. Defaults to ``24``. +- `envs` _dict[str, str] | None_ - Additional environment variables + to inject into the process. +- `cwd` _str | None_ - Working directory for the process. + + +**Yields**: + +- `AsyncPtySession` - An interactive async PTY session. + + + +#### pty\_connect + +```python +@asynccontextmanager +async def pty_connect(tag: str) -> AsyncIterator[AsyncPtySession] +``` + +Reconnect to an existing PTY session by tag. + +**Arguments**: + +- `tag` _str_ - Session tag returned in the ``started`` PTY event. + + +**Yields**: + +- `AsyncPtySession` - The reconnected async PTY session. + + + +#### get\_url + +```python +def get_url(port: int) -> str +``` + +Get the proxy URL for a port exposed inside this capsule. + +**Arguments**: + +- `port` _int_ - Port number to proxy. + + +**Returns**: + +- `str` - A ``wss://`` (or ``ws://``) URL that proxies to the given + port inside the capsule. + + + +#### create\_snapshot + +```python +async def create_snapshot(name: str | None = None, + overwrite: bool = False) -> Template +``` + +Create a snapshot template from this capsule's current state. + +**Arguments**: + +- `name` _str | None_ - Name for the snapshot template. Auto-generated + if not provided. +- `overwrite` _bool_ - If ``True``, overwrite an existing template with + the same name. Defaults to ``False``. + + +**Returns**: + +- `Template` - The created snapshot template. + + + +# wrenn.pty + + + +## PtySession Objects + +```python +class PtySession() +``` + +Interactive PTY session backed by a WebSocket. + +Use as a context manager and iterate over events:: + +with sb.pty(cmd="/bin/bash") as term: +term.write(b"ls -la\n") +for event in term: +if event.type == "output": +sys.stdout.buffer.write(event.data) +elif event.type == "exit": +break + + + +#### tag + +```python +@property +def tag() -> str | None +``` + +Session tag. Available after the ``started`` event. + + + +#### pid + +```python +@property +def pid() -> int | None +``` + +Process PID. Available after the ``started`` event. + + + +#### write + +```python +def write(data: bytes) -> None +``` + +Send raw bytes to the PTY stdin. + +**Arguments**: + +- `data` - Raw bytes to send. Base64-encoded internally. + + + +#### resize + +```python +def resize(cols: int, rows: int) -> None +``` + +Resize the PTY terminal. + +**Arguments**: + +- `cols` - New column count. Must be > 0. +- `rows` - New row count. Must be > 0. + + +**Raises**: + +- `ValueError` - If cols or rows is 0. + + + +#### kill + +```python +def kill() -> None +``` + +Send SIGKILL to the PTY process. + + + +## AsyncPtySession Objects + +```python +class AsyncPtySession() +``` + +Async interactive PTY session backed by a WebSocket. + +Use as an async context manager and async iterate over events:: + +async with sb.pty(cmd="/bin/bash") as term: +await term.write(b"ls -la\n") +async for event in term: +if event.type == "output": +sys.stdout.buffer.write(event.data) +elif event.type == "exit": +break + + + +#### tag + +```python +@property +def tag() -> str | None +``` + +Session tag. Available after the ``started`` event. + + + +#### pid + +```python +@property +def pid() -> int | None +``` + +Process PID. Available after the ``started`` event. + + + +#### write + +```python +async def write(data: bytes) -> None +``` + +Send raw bytes to the PTY stdin. + +**Arguments**: + +- `data` - Raw bytes to send. Base64-encoded internally. + + + +#### resize + +```python +async def resize(cols: int, rows: int) -> None +``` + +Resize the PTY terminal. + +**Arguments**: + +- `cols` - New column count. Must be > 0. +- `rows` - New row count. Must be > 0. + + +**Raises**: + +- `ValueError` - If cols or rows is 0. + + + +#### kill + +```python +async def kill() -> None +``` + +Send SIGKILL to the PTY process. + + + +# wrenn.models.\_generated + + + +## Peaks Objects + +```python +class Peaks(BaseModel) +``` + +Maximum values over the last 30 days. + + + +## Series Objects + +```python +class Series(BaseModel) +``` + +Parallel arrays for chart rendering. + + + +## Encoding Objects + +```python +class Encoding(StrEnum) +``` + +Output encoding. "base64" when stdout/stderr contain binary data. + + + +## Type2 Objects + +```python +class Type2(StrEnum) +``` + +Host type. Regular hosts are shared; BYOC hosts belong to a team. + + + +# wrenn.models + + + +# wrenn.capsule + + + +## Capsule Objects + +```python +class Capsule() +``` + +A Wrenn capsule (sandbox) with e2b-compatible interface. + +Create directly:: + +capsule = Capsule(api_key="wrn_...") +capsule = Capsule(template="minimal") # reads WRENN_API_KEY env + +Or via classmethod:: + +capsule = Capsule.create(template="minimal") + +Use as context manager for automatic cleanup:: + +with Capsule() as capsule: +capsule.commands.run("echo hello") + + + +#### \_\_init\_\_ + +```python +def __init__(template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + wait: bool = False, + api_key: str | None = None, + base_url: str | None = None, + _capsule_id: str | None = None, + _client: WrennClient | None = None, + _info: CapsuleModel | None = None) -> None +``` + +Create and start a new capsule. + +**Arguments**: + +- `template` _str | None_ - Template name to boot from. Defaults to + the server-side default (``"minimal"``). +- `vcpus` _int | None_ - Number of virtual CPUs. Defaults to the + server-side default. +- `memory_mb` _int | None_ - Memory in MiB. Defaults to the + server-side default. +- `timeout` _int | None_ - Inactivity TTL in seconds before the capsule + is auto-paused. ``0`` disables auto-pause. +- `wait` _bool_ - If ``True``, block until the capsule status is + ``running`` before returning. +- `api_key` _str | None_ - Wrenn API key (``wrn_...``). Falls back to + the ``WRENN_API_KEY`` environment variable. +- `base_url` _str | None_ - Wrenn API base URL. Falls back to + ``WRENN_BASE_URL`` or the default production endpoint. + + + +#### capsule\_id + +```python +@property +def capsule_id() -> str +``` + +The capsule's unique identifier. + +**Returns**: + +- `str` - Capsule ID assigned by the Wrenn API. + + + +#### info + +```python +@property +def info() -> CapsuleModel | None +``` + +Cached capsule metadata from the last API call. + +**Returns**: + + CapsuleModel | None: The last-fetched capsule model, or ``None`` + if the capsule was connected without an initial fetch. + + + +#### create + +```python +@classmethod +def create(cls, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + wait: bool = False, + api_key: str | None = None, + base_url: str | None = None) -> Capsule +``` + +Create a new capsule. + +Equivalent to calling ``Capsule(...)`` directly. + +**Arguments**: + +- `template` _str | None_ - Template name to boot from. +- `vcpus` _int | None_ - Number of virtual CPUs. +- `memory_mb` _int | None_ - Memory in MiB. +- `timeout` _int | None_ - Inactivity TTL in seconds before auto-pause. +- `wait` _bool_ - Block until the capsule reaches ``running`` status. +- `api_key` _str | None_ - Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. +- `base_url` _str | None_ - API base URL override. + + +**Returns**: + +- `Capsule` - A new capsule instance. + + + +#### connect + +```python +@classmethod +def connect(cls, + capsule_id: str, + *, + api_key: str | None = None, + base_url: str | None = None) -> Capsule +``` + +Connect to an existing capsule, resuming it if paused. + +**Arguments**: + +- `capsule_id` _str_ - ID of the capsule to connect to. +- `api_key` _str | None_ - Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. +- `base_url` _str | None_ - API base URL override. + + +**Returns**: + +- `Capsule` - A capsule instance bound to the existing capsule. + + +**Raises**: + +- `WrennNotFoundError` - If no capsule with the given ID exists. + + + +#### ping + +```python +def ping() -> None +``` + +Reset the capsule inactivity timer. + +Call this to prevent the capsule from being auto-paused when the +inactivity TTL is set. + + + +#### wait\_ready + +```python +def wait_ready(timeout: float = 30, interval: float = 0.5) -> None +``` + +Block until the capsule status is ``running``. + +**Arguments**: + +- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``. +- `interval` _float_ - Polling interval in seconds. Defaults to ``0.5``. + + +**Raises**: + +- `TimeoutError` - If the capsule does not reach ``running`` state + within ``timeout`` seconds. +- `RuntimeError` - If the capsule enters an error, stopped, or paused + state while waiting. + + + +#### is\_running + +```python +def is_running() -> bool +``` + +Check whether the capsule is currently running. + +Makes a live API call to fetch current status. + +**Returns**: + +- `bool` - ``True`` if the capsule status is ``running``. + + + +#### list + +```python +@classmethod +def list(cls, + *, + api_key: str | None = None, + base_url: str | None = None) -> list[CapsuleModel] +``` + +List all capsules belonging to the team. + +**Arguments**: + +- `api_key` _str | None_ - Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. +- `base_url` _str | None_ - API base URL override. + + +**Returns**: + +- `list[CapsuleModel]` - All capsules for the authenticated team. + + + +#### pty + +```python +@contextmanager +def pty(cmd: str = "/bin/bash", + args: list[str] | None = None, + cols: int = 80, + rows: int = 24, + envs: dict[str, str] | None = None, + cwd: str | None = None) -> Iterator[PtySession] +``` + +Open an interactive PTY session backed by a WebSocket. + +Use as a context manager and iterate over :class:`PtyEvent` objects:: + +with capsule.pty() as term: +term.write(b"echo hello\n") +for event in term: +if event.type == "output": +print(event.data.decode()) + +**Arguments**: + +- `cmd` _str_ - Command to run inside the PTY. Defaults to + ``"/bin/bash"``. +- `args` _list[str] | None_ - Additional arguments for ``cmd``. +- `cols` _int_ - Initial terminal column count. Defaults to ``80``. +- `rows` _int_ - Initial terminal row count. Defaults to ``24``. +- `envs` _dict[str, str] | None_ - Additional environment variables to + inject into the process. +- `cwd` _str | None_ - Working directory for the process. + + +**Yields**: + +- `PtySession` - An interactive PTY session. + + + +#### pty\_connect + +```python +@contextmanager +def pty_connect(tag: str) -> Iterator[PtySession] +``` + +Reconnect to an existing PTY session by tag. + +**Arguments**: + +- `tag` _str_ - Session tag returned in the ``started`` PTY event. + + +**Yields**: + +- `PtySession` - The reconnected PTY session. + + + +#### get\_url + +```python +def get_url(port: int) -> str +``` + +Get the proxy URL for a port exposed inside this capsule. + +**Arguments**: + +- `port` _int_ - Port number to proxy. + + +**Returns**: + +- `str` - A ``wss://`` (or ``ws://``) URL that proxies to the given + port inside the capsule. + + + +#### create\_snapshot + +```python +def create_snapshot(name: str | None = None, + overwrite: bool = False) -> Template +``` + +Create a snapshot template from this capsule's current state. + +**Arguments**: + +- `name` _str | None_ - Name for the snapshot template. Auto-generated + if not provided. +- `overwrite` _bool_ - If ``True``, overwrite an existing template with + the same name. Defaults to ``False``. + + +**Returns**: + +- `Template` - The created snapshot template. + + + +# wrenn.\_config + + + +## ConnectionConfig Objects + +```python +@dataclass(frozen=True) +class ConnectionConfig() +``` + +Resolved credentials and base URL for Wrenn API calls. + + + +# wrenn.\_git.\_auth + + + +#### embed\_credentials + +```python +def embed_credentials(url: str, username: str, password: str) -> str +``` + +Embed HTTP(S) credentials into a git URL. + +**Arguments**: + +- `url` - Git repository URL. +- `username` - Username for authentication. +- `password` - Password or personal access token. + + +**Returns**: + + URL with ``username:password@`` embedded in the netloc. + + +**Raises**: + +- `ValueError` - If the URL scheme is not ``http`` or ``https``. + + + +#### strip\_credentials + +```python +def strip_credentials(url: str) -> str +``` + +Remove embedded credentials from a git URL. + +**Arguments**: + +- `url` - Git repository URL, possibly with credentials. + + +**Returns**: + + URL with credentials removed. Non-HTTP(S) URLs are returned + unchanged. + + + +#### is\_auth\_error + +```python +def is_auth_error(stderr: str) -> bool +``` + +Check whether git stderr indicates an authentication failure. + +**Arguments**: + +- `stderr` - Combined stderr output from a git command. + + +**Returns**: + + ``True`` if any known auth-failure pattern is found. + + + +#### build\_credential\_approve\_cmd + +```python +def build_credential_approve_cmd(username: str, + password: str, + host: str = "github.com", + protocol: str = "https") -> str +``` + +Build a shell command that pipes credentials into ``git credential approve``. + +**Arguments**: + +- `username` - Git username. +- `password` - Password or personal access token. +- `host` - Target host. Defaults to ``"github.com"``. +- `protocol` - Protocol. Defaults to ``"https"``. + + +**Returns**: + + A shell command string safe to pass to ``commands.run()``. + + + +# wrenn.\_git.\_cmd + +Pure functions that build git argument lists and parse git output. + +No I/O, no network, no imports from ``wrenn``. Every ``build_*`` function +returns a ``list[str]`` suitable for ``shlex.join()``. Every ``parse_*`` +function takes raw stdout and returns a typed structure. + + + +## FileStatus Objects + +```python +@dataclass +class FileStatus() +``` + +A single entry from ``git status --porcelain=v1``. + +**Attributes**: + +- `path` _str_ - File path relative to the repository root. +- `index_status` _str_ - Index (staged) status character. +- `work_tree_status` _str_ - Working-tree status character. +- `renamed_from` _str | None_ - Original path when status is a rename. + + + +#### staged + +```python +@property +def staged() -> bool +``` + +Whether the change is staged in the index. + + + +#### status + +```python +@property +def status() -> str +``` + +Normalized human-readable status label. + + + +## GitStatus Objects + +```python +@dataclass +class GitStatus() +``` + +Parsed output of ``git status --porcelain=v1 --branch``. + +**Attributes**: + +- `branch` _str | None_ - Current branch name, or ``None`` if detached. +- `upstream` _str | None_ - Upstream tracking branch. +- `ahead` _int_ - Commits ahead of upstream. +- `behind` _int_ - Commits behind upstream. +- `detached` _bool_ - Whether HEAD is detached. +- `files` _list[FileStatus]_ - Per-file status entries. + + + +#### is\_clean + +```python +@property +def is_clean() -> bool +``` + +``True`` when there are no changed or untracked files. + + + +#### has\_staged + +```python +@property +def has_staged() -> bool +``` + +``True`` when at least one file has staged changes. + + + +#### has\_untracked + +```python +@property +def has_untracked() -> bool +``` + +``True`` when at least one file is untracked. + + + +#### has\_conflicts + +```python +@property +def has_conflicts() -> bool +``` + +``True`` when at least one file has merge conflicts. + + + +## GitBranch Objects + +```python +@dataclass +class GitBranch() +``` + +A single branch entry. + +**Attributes**: + +- `name` _str_ - Branch name (short ref). +- `is_current` _bool_ - Whether this is the checked-out branch. + + + +#### build\_clone + +```python +def build_clone(url: str, + dest: str | None = None, + *, + branch: str | None = None, + depth: int | None = None) -> list[str] +``` + +Build ``git clone`` arguments. + + + +#### build\_init + +```python +def build_init(path: str = ".", + *, + bare: bool = False, + initial_branch: str | None = None) -> list[str] +``` + +Build ``git init`` arguments. + + + +#### build\_add + +```python +def build_add(paths: list[str] | None = None, + *, + all: bool = False) -> list[str] +``` + +Build ``git add`` arguments. + + + +#### build\_commit + +```python +def build_commit(message: str, + *, + allow_empty: bool = False, + author_name: str | None = None, + author_email: str | None = None) -> list[str] +``` + +Build ``git commit`` arguments. + + + +#### build\_push + +```python +def build_push(remote: str = "origin", + branch: str | None = None, + *, + force: bool = False, + set_upstream: bool = False) -> list[str] +``` + +Build ``git push`` arguments. + + + +#### build\_pull + +```python +def build_pull(remote: str = "origin", + branch: str | None = None, + *, + rebase: bool = False, + ff_only: bool = False) -> list[str] +``` + +Build ``git pull`` arguments. + + + +#### build\_status + +```python +def build_status() -> list[str] +``` + +Build ``git status`` arguments for porcelain parsing. + + + +#### build\_branches + +```python +def build_branches() -> list[str] +``` + +Build ``git branch`` arguments for structured parsing. + + + +#### build\_create\_branch + +```python +def build_create_branch(name: str, + *, + start_point: str | None = None) -> list[str] +``` + +Build ``git checkout -b`` arguments. + + + +#### build\_checkout + +```python +def build_checkout(name: str) -> list[str] +``` + +Build ``git checkout`` arguments. + + + +#### build\_delete\_branch + +```python +def build_delete_branch(name: str, *, force: bool = False) -> list[str] +``` + +Build ``git branch -d/-D`` arguments. + + + +#### build\_remote\_add + +```python +def build_remote_add(name: str, url: str, *, fetch: bool = False) -> list[str] +``` + +Build ``git remote add`` arguments. + + + +#### build\_remote\_get\_url + +```python +def build_remote_get_url(name: str = "origin") -> list[str] +``` + +Build ``git remote get-url`` arguments. + + + +#### build\_remote\_set\_url + +```python +def build_remote_set_url(name: str, url: str) -> list[str] +``` + +Build ``git remote set-url`` arguments. + + + +#### build\_reset + +```python +def build_reset(*, + mode: str | None = None, + ref: str | None = None, + paths: list[str] | None = None) -> list[str] +``` + +Build ``git reset`` arguments. + +**Arguments**: + +- `mode` - Reset mode (``soft``, ``mixed``, ``hard``, ``merge``, ``keep``). +- `ref` - Commit, branch, or ref to reset to. +- `paths` - Paths to reset (mutually exclusive with ``mode``). + + + +#### build\_restore + +```python +def build_restore(paths: list[str], + *, + staged: bool = False, + worktree: bool = False, + source: str | None = None) -> list[str] +``` + +Build ``git restore`` arguments. + +**Arguments**: + +- `paths` - Paths to restore. +- `staged` - Restore the index (unstage). +- `worktree` - Restore working-tree files. +- `source` - Commit or ref to restore from. + + + +#### build\_config\_set + +```python +def build_config_set(key: str, + value: str, + *, + scope: str = "local", + repo_path: str | None = None) -> list[str] +``` + +Build ``git config`` set arguments. + + + +#### build\_config\_get + +```python +def build_config_get(key: str, + *, + scope: str = "local", + repo_path: str | None = None) -> list[str] +``` + +Build ``git config --get`` arguments. + + + +#### build\_has\_upstream + +```python +def build_has_upstream() -> list[str] +``` + +Build arguments to check if current branch has upstream tracking. + + + +#### parse\_status + +```python +def parse_status(stdout: str) -> GitStatus +``` + +Parse ``git status --porcelain=v1 --branch`` output. + +**Arguments**: + +- `stdout` - Raw stdout from the git status command. + + +**Returns**: + + Parsed :class:`GitStatus`. + + + +#### parse\_branches + +```python +def parse_branches(stdout: str) -> list[GitBranch] +``` + +Parse ``git branch --format=%(refname:short)\t%(HEAD)`` output. + +**Arguments**: + +- `stdout` - Raw stdout from the git branch command. + + +**Returns**: + + List of :class:`GitBranch`. + + + +# wrenn.\_git.exceptions + + + +## GitError Objects + +```python +class GitError(Exception) +``` + +Base exception for all git operations inside a capsule. + +Not a subclass of :class:`WrennError` because git errors originate +from a process exit code, not an HTTP response. + +**Attributes**: + +- `message` _str_ - Human-readable error description. +- `stderr` _str_ - Raw stderr output from the git process. +- `exit_code` _int_ - Process exit code. + + + +## GitCommandError Objects + +```python +class GitCommandError(GitError) +``` + +A git command exited with a non-zero exit code. + + + +## GitAuthError Objects + +```python +class GitAuthError(GitError) +``` + +Authentication failed when communicating with a remote. + + + +# wrenn.\_git + +Git operations inside a Wrenn capsule. + +Provides :class:`Git` (sync) and :class:`AsyncGit` (async) interfaces +accessed via ``capsule.git``. All operations execute the real ``git`` +binary inside the capsule through :class:`~wrenn.commands.Commands`. + + + +## Git Objects + +```python +class Git() +``` + +Sync git interface. Accessed via ``capsule.git``. + +Executes the real ``git`` binary inside the capsule through +:meth:`Commands.run`. Methods raise :class:`GitCommandError` (or +:class:`GitAuthError`) on non-zero exit codes. + + + +#### clone + +```python +def clone(url: str, + dest: str | None = None, + *, + branch: str | None = None, + depth: int | None = None, + username: str | None = None, + password: str | None = None, + dangerously_store_credentials: bool = False, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 300) -> CommandResult +``` + +Clone a remote repository into the capsule. + +**Arguments**: + +- `url` - Remote repository URL. +- `dest` - Destination path. Defaults to the repository name + derived from the URL. +- `branch` - Branch or tag to check out. +- `depth` - Create a shallow clone with this many commits. +- `username` - Username for HTTP(S) authentication. +- `password` - Password or token for HTTP(S) authentication. +- `dangerously_store_credentials` - If ``True``, leave credentials + embedded in the remote URL after cloning. +- `cwd` - Working directory for the command. +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. Defaults to ``300``. + + +**Returns**: + + Command result with stdout, stderr, exit_code, and duration. + + +**Raises**: + +- `GitAuthError` - If the remote rejected authentication. +- `GitCommandError` - If clone failed for another reason. +- `ValueError` - If *password* is provided without *username*. + + + +#### init + +```python +def init(path: str = ".", + *, + bare: bool = False, + initial_branch: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Initialize a new git repository. + +**Arguments**: + +- `path` - Destination path for the repository. +- `bare` - Create a bare repository. +- `initial_branch` - Name for the initial branch (e.g. ``"main"``). +- `cwd` - Working directory for the command. +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Returns**: + + Command result. + + +**Raises**: + +- `GitCommandError` - If init failed. + + + +#### add + +```python +def add(paths: list[str] | None = None, + *, + all: bool = False, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Stage files for commit. + +**Arguments**: + +- `paths` - Specific files to stage. If ``None``, stages the + current directory (or all with ``all=True``). +- `all` - Stage all changes including untracked files. +- `cwd` - Working directory (repository root). +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Returns**: + + Command result. + + +**Raises**: + +- `GitCommandError` - If add failed. + + + +#### commit + +```python +def commit(message: str, + *, + allow_empty: bool = False, + author_name: str | None = None, + author_email: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Create a commit. + +**Arguments**: + +- `message` - Commit message. +- `allow_empty` - Allow creating a commit with no changes. +- `author_name` - Override the commit author name. +- `author_email` - Override the commit author email. +- `cwd` - Working directory (repository root). +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Returns**: + + Command result. + + +**Raises**: + +- `GitCommandError` - If commit failed. + + + +#### push + +```python +def push(remote: str = "origin", + branch: str | None = None, + *, + force: bool = False, + set_upstream: bool = False, + username: str | None = None, + password: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 60) -> CommandResult +``` + +Push commits to a remote. + +**Arguments**: + +- `remote` - Remote name. Defaults to ``"origin"``. +- `branch` - Branch to push. Defaults to the current branch. +- `force` - Force-push. +- `set_upstream` - Set upstream tracking reference. +- `username` - Username for HTTP(S) authentication. +- `password` - Password or token for HTTP(S) authentication. +- `cwd` - Working directory (repository root). +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Returns**: + + Command result. + + +**Raises**: + +- `GitAuthError` - If authentication failed. +- `GitCommandError` - If push failed. + + + +#### pull + +```python +def pull(remote: str = "origin", + branch: str | None = None, + *, + rebase: bool = False, + ff_only: bool = False, + username: str | None = None, + password: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 60) -> CommandResult +``` + +Pull changes from a remote. + +**Arguments**: + +- `remote` - Remote name. Defaults to ``"origin"``. +- `branch` - Branch to pull. +- `rebase` - Rebase instead of merge. +- `ff_only` - Only allow fast-forward merges. +- `username` - Username for HTTP(S) authentication. +- `password` - Password or token for HTTP(S) authentication. +- `cwd` - Working directory (repository root). +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Returns**: + + Command result. + + +**Raises**: + +- `GitAuthError` - If authentication failed. +- `GitCommandError` - If pull failed. + + + +#### status + +```python +def status(*, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> GitStatus +``` + +Get repository status. + +**Arguments**: + +- `cwd` - Working directory (repository root). +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Returns**: + + Parsed :class:`GitStatus` with branch info and file changes. + + +**Raises**: + +- `GitCommandError` - If the command failed. + + + +#### branches + +```python +def branches(*, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> list[GitBranch] +``` + +List local branches. + +**Arguments**: + +- `cwd` - Working directory (repository root). +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Returns**: + + List of :class:`GitBranch`. + + +**Raises**: + +- `GitCommandError` - If the command failed. + + + +#### create\_branch + +```python +def create_branch(name: str, + *, + start_point: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Create and check out a new branch. + +**Arguments**: + +- `name` - Branch name. +- `start_point` - Commit or ref to branch from. +- `cwd` - Working directory (repository root). +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Returns**: + + Command result. + + +**Raises**: + +- `GitCommandError` - If the command failed. + + + +#### checkout\_branch + +```python +def checkout_branch(name: str, + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Check out an existing branch. + +**Arguments**: + +- `name` - Branch name. +- `cwd` - Working directory (repository root). +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Returns**: + + Command result. + + +**Raises**: + +- `GitCommandError` - If the command failed. + + + +#### delete\_branch + +```python +def delete_branch(name: str, + *, + force: bool = False, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Delete a branch. + +**Arguments**: + +- `name` - Branch name. +- `force` - Force-delete with ``-D``. +- `cwd` - Working directory (repository root). +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Returns**: + + Command result. + + +**Raises**: + +- `GitCommandError` - If the command failed. + + + +#### remote\_add + +```python +def remote_add(name: str, + url: str, + *, + fetch: bool = False, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Add a remote. + +**Arguments**: + +- `name` - Remote name (e.g. ``"origin"``). +- `url` - Remote URL. +- `fetch` - Fetch after adding. +- `cwd` - Working directory (repository root). +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Returns**: + + Command result. + + +**Raises**: + +- `GitCommandError` - If the command failed. + + + +#### remote\_get + +```python +def remote_get(name: str = "origin", + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> str | None +``` + +Get the URL of a remote. + +Returns ``None`` if the remote does not exist rather than raising. + +**Arguments**: + +- `name` - Remote name. Defaults to ``"origin"``. +- `cwd` - Working directory (repository root). +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Returns**: + + Remote URL or ``None``. + + + +#### reset + +```python +def reset(*, + mode: str | None = None, + ref: str | None = None, + paths: list[str] | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Reset the current HEAD. + +**Arguments**: + +- `mode` - Reset mode (``soft``, ``mixed``, ``hard``, ``merge``, + ``keep``). +- `ref` - Commit, branch, or ref to reset to. +- `paths` - Paths to reset. +- `cwd` - Working directory (repository root). +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Returns**: + + Command result. + + +**Raises**: + +- `GitCommandError` - If the command failed. + + + +#### restore + +```python +def restore(paths: list[str], + *, + staged: bool = False, + worktree: bool = False, + source: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Restore working-tree files or unstage changes. + +**Arguments**: + +- `paths` - Paths to restore. +- `staged` - Restore the index (unstage). +- `worktree` - Restore working-tree files. +- `source` - Commit or ref to restore from. +- `cwd` - Working directory (repository root). +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Returns**: + + Command result. + + +**Raises**: + +- `GitCommandError` - If the command failed. + + + +#### set\_config + +```python +def set_config(key: str, + value: str, + *, + scope: str = "local", + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Set a git config value. + +**Arguments**: + +- `key` - Config key (e.g. ``"user.name"``). +- `value` - Config value. +- `scope` - Config scope: ``"local"``, ``"global"``, or + ``"system"``. +- `cwd` - Working directory (repository root). Required when + scope is ``"local"``. +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Returns**: + + Command result. + + +**Raises**: + +- `GitCommandError` - If the command failed. + + + +#### get\_config + +```python +def get_config(key: str, + *, + scope: str = "local", + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> str | None +``` + +Get a git config value. + +Returns ``None`` if the key is not set rather than raising. + +**Arguments**: + +- `key` - Config key (e.g. ``"user.name"``). +- `scope` - Config scope: ``"local"``, ``"global"``, or + ``"system"``. +- `cwd` - Working directory (repository root). Required when + scope is ``"local"``. +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Returns**: + + Config value or ``None``. + + + +#### configure\_user + +```python +def configure_user(name: str, + email: str, + *, + scope: str = "global", + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> None +``` + +Configure git user name and email. + +**Arguments**: + +- `name` - Git user name. +- `email` - Git user email. +- `scope` - Config scope. Defaults to ``"global"``. +- `cwd` - Working directory (repository root). Required when + scope is ``"local"``. +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Raises**: + +- `ValueError` - If *name* or *email* is empty. +- `GitCommandError` - If a config command failed. + + + +#### dangerously\_authenticate + +```python +def dangerously_authenticate(username: str, + password: str, + host: str = "github.com", + protocol: str = "https", + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> None +``` + +Persist git credentials via the credential store. + +.. warning:: + +Credentials are written in plain text to the capsule +filesystem and are accessible to any process running inside +the capsule. Prefer per-operation ``username``/``password`` +parameters on :meth:`clone`, :meth:`push`, and :meth:`pull` +instead. + +**Arguments**: + +- `username` - Git username. +- `password` - Password or personal access token. +- `host` - Target host. Defaults to ``"github.com"``. +- `protocol` - Protocol. Defaults to ``"https"``. +- `cwd` - Working directory. +- `envs` - Extra environment variables. +- `timeout` - Command timeout in seconds. + + +**Raises**: + +- `ValueError` - If *username* or *password* is empty. +- `GitCommandError` - If a command failed. + + + +## AsyncGit Objects + +```python +class AsyncGit() +``` + +Async git interface. Accessed via ``capsule.git``. + +Async mirror of :class:`Git`. See that class for full method +documentation. + + + +#### clone + +```python +async def clone(url: str, + dest: str | None = None, + *, + branch: str | None = None, + depth: int | None = None, + username: str | None = None, + password: str | None = None, + dangerously_store_credentials: bool = False, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 300) -> CommandResult +``` + +Clone a remote repository into the capsule. + + + +#### init + +```python +async def init(path: str = ".", + *, + bare: bool = False, + initial_branch: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Initialize a new git repository. + + + +#### add + +```python +async def add(paths: list[str] | None = None, + *, + all: bool = False, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Stage files for commit. + + + +#### commit + +```python +async def commit(message: str, + *, + allow_empty: bool = False, + author_name: str | None = None, + author_email: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Create a commit. + + + +#### push + +```python +async def push(remote: str = "origin", + branch: str | None = None, + *, + force: bool = False, + set_upstream: bool = False, + username: str | None = None, + password: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 60) -> CommandResult +``` + +Push commits to a remote. + + + +#### pull + +```python +async def pull(remote: str = "origin", + branch: str | None = None, + *, + rebase: bool = False, + ff_only: bool = False, + username: str | None = None, + password: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 60) -> CommandResult +``` + +Pull changes from a remote. + + + +#### status + +```python +async def status(*, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> GitStatus +``` + +Get repository status. + + + +#### branches + +```python +async def branches(*, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> list[GitBranch] +``` + +List local branches. + + + +#### create\_branch + +```python +async def create_branch(name: str, + *, + start_point: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Create and check out a new branch. + + + +#### checkout\_branch + +```python +async def checkout_branch(name: str, + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Check out an existing branch. + + + +#### delete\_branch + +```python +async def delete_branch(name: str, + *, + force: bool = False, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Delete a branch. + + + +#### remote\_add + +```python +async def remote_add(name: str, + url: str, + *, + fetch: bool = False, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Add a remote. + + + +#### remote\_get + +```python +async def remote_get(name: str = "origin", + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> str | None +``` + +Get the URL of a remote. Returns ``None`` if not found. + + + +#### reset + +```python +async def reset(*, + mode: str | None = None, + ref: str | None = None, + paths: list[str] | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Reset the current HEAD. + + + +#### restore + +```python +async def restore(paths: list[str], + *, + staged: bool = False, + worktree: bool = False, + source: str | None = None, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Restore working-tree files or unstage changes. + + + +#### set\_config + +```python +async def set_config(key: str, + value: str, + *, + scope: str = "local", + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> CommandResult +``` + +Set a git config value. + + + +#### get\_config + +```python +async def get_config(key: str, + *, + scope: str = "local", + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> str | None +``` + +Get a git config value. Returns ``None`` if not set. + + + +#### configure\_user + +```python +async def configure_user(name: str, + email: str, + *, + scope: str = "global", + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> None +``` + +Configure git user name and email. + + + +#### dangerously\_authenticate + +```python +async def dangerously_authenticate(username: str, + password: str, + host: str = "github.com", + protocol: str = "https", + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30) -> None +``` + +Persist git credentials via the credential store. + +.. warning:: + +Credentials are written in plain text to the capsule +filesystem. Prefer per-operation ``username``/``password`` +parameters instead. + diff --git a/pydoc-markdown.yml b/pydoc-markdown.yml new file mode 100644 index 0000000..ab816e4 --- /dev/null +++ b/pydoc-markdown.yml @@ -0,0 +1,12 @@ +loaders: + - type: python + search_path: [src] + +processors: + - type: google # Use Google-style docstring parser + - type: filter + - type: crossref + +renderer: + type: markdown + escape_html_in_docstring: false diff --git a/pyproject.toml b/pyproject.toml index 98570b7..a235194 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ build-backend = "hatchling.build" dev = [ "datamodel-code-generator[ruff]>=0.56.0", "mypy>=1.20.0", + "pydoc-markdown>=4.8.2", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", "respx>=0.23.1", diff --git a/uv.lock b/uv.lock index 985de91..bc040cd 100644 --- a/uv.lock +++ b/uv.lock @@ -72,6 +72,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "click" version = "8.3.2" @@ -93,6 +150,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "databind" +version = "4.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "nr-date" }, + { name = "nr-stream" }, + { name = "typeapi" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/9e/835a5211eeb7228a0e3870d54def48dd7951dbd951f51b30900020a5f9fc/databind-4.5.4.tar.gz", hash = "sha256:342e170a219b1661e5c1b20778b532aecfa67e46560ba75beb7e2c6faa2150b5", size = 43193, upload-time = "2026-04-02T22:21:47.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/db/3b8eb860b5baef89b72c7aadcc5072e1648ca0c98d6ba4b9e4eabbdc2cf5/databind-4.5.4-py3-none-any.whl", hash = "sha256:78467f874a3e80bcd1d3de349167587a0d369831bc64c03798520be86074f96e", size = 52381, upload-time = "2026-04-02T22:21:45.389Z" }, +] + +[[package]] +name = "databind-core" +version = "4.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "databind" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/dc/b63a6f6a404146e8e3f1226c9243a5cb30784a1f75218d014cafce9a411f/databind_core-4.5.4.tar.gz", hash = "sha256:a7a47af183d4a8046c893fc19fa9c085f287a15e57a05e58345016086ce0f807", size = 974, upload-time = "2026-04-02T22:21:56.588Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/cf/1d1f4d37b4112f26ea5086d54200837d1dcbddaa536f3a70bb1d8b48ed9a/databind_core-4.5.4-py3-none-any.whl", hash = "sha256:25482c352f4f6fcade7c106c665553a18febeccda2972c00cf5af19f473960ab", size = 1666, upload-time = "2026-04-02T22:21:55.504Z" }, +] + +[[package]] +name = "databind-json" +version = "4.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "databind" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/72/9af59950a23ff6a03062acd85879de289595168ec43d6cec57253d00497c/databind_json-4.5.4.tar.gz", hash = "sha256:2c714f9c3039a81e42fc3826e47d7826ef020d93131c34daf4c9ae0483108e4d", size = 966, upload-time = "2026-04-02T22:22:05.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/d4/e00d531202314e29d90c9496f6b4730e3647128b9866180b8ce8ebb79394/databind_json-4.5.4-py3-none-any.whl", hash = "sha256:22e6faaeb6f2ec5cf815fd597a539dfe1f4846b80b618760112f4fe59a0898cc", size = 1664, upload-time = "2026-04-02T22:22:04.204Z" }, +] + [[package]] name = "datamodel-code-generator" version = "0.56.0" @@ -117,6 +214,18 @@ ruff = [ { name = "ruff" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -126,6 +235,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] +[[package]] +name = "docspec" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "databind-core" }, + { name = "databind-json" }, + { name = "deprecated" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/39/7a71382107445b2cd50c67c6194e3e584f19748a817c3b29e8be8a14f00f/docspec-2.2.1.tar.gz", hash = "sha256:4854e77edc0e2de40e785e57e95880f7095a05fe978f8b54cef7a269586e15ff", size = 8646, upload-time = "2023-05-28T11:24:18.68Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/aa/0c9d71cc9d450afd3993d09835e2910810a45b0703f585e1aee1d9b78969/docspec-2.2.1-py3-none-any.whl", hash = "sha256:7538f750095a9688c6980ff9a4e029a823a500f64bd00b6b4bdb27951feb31cb", size = 9844, upload-time = "2023-05-28T11:24:15.419Z" }, +] + +[[package]] +name = "docspec-python" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "black" }, + { name = "docspec" }, + { name = "nr-util" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/ea/e6d9d9c2f805c6ac8072d0e3ee5b1da2dd61886c662327df937dec9f282c/docspec_python-2.2.2.tar.gz", hash = "sha256:429be834d09549461b95bf45eb53c16859f3dfb3e9220408b3bfb12812ccb3fb", size = 22154, upload-time = "2025-05-06T12:40:33.286Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/c2/b3226746fb6b91893da270a60e77bb420d59cf33a7b9a4e719a236955971/docspec_python-2.2.2-py3-none-any.whl", hash = "sha256:caa32dc1e8c470af8a5ecad67cca614e68c1563ac01dab0c0486c4d7f709d6b1", size = 15988, upload-time = "2025-05-06T12:40:31.554Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/ce/5d6a3782b9f88097ce3e579265015db3372ae78d12f67629b863a9208c96/docstring_parser-0.11.tar.gz", hash = "sha256:93b3f8f481c7d24e37c5d9f30293c89e2933fa209421c8abd731dd3ef0715ecb", size = 22775, upload-time = "2021-09-30T07:44:10.288Z" } + [[package]] name = "email-validator" version = "2.3.0" @@ -405,6 +548,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nr-date" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/92/08110dd3d7ff5e2b852a220752eb6c40183839f5b7cc91f9f38dd2298e7d/nr_date-2.1.0.tar.gz", hash = "sha256:0643aea13bcdc2a8bc56af9d5e6a89ef244c9744a1ef00cdc735902ba7f7d2e6", size = 8789, upload-time = "2023-08-16T13:46:04.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/10/1d2b00172537c1522fe64bbc6fb16b015632a02f7b3864e788ccbcb4dd85/nr_date-2.1.0-py3-none-any.whl", hash = "sha256:bd672a9dfbdcf7c4b9289fea6750c42490eaee08036a72059dcc78cb236ed568", size = 10496, upload-time = "2023-08-16T13:46:02.627Z" }, +] + +[[package]] +name = "nr-stream" +version = "1.1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/37/e4d36d852c441233c306c5fbd98147685dce3ac9b0a8bbf4a587d0ea29ea/nr_stream-1.1.5.tar.gz", hash = "sha256:eb0216c6bfc61a46d4568dba3b588502c610ec8ddef4ac98f3932a2bd7264f65", size = 10053, upload-time = "2023-02-14T22:44:09.074Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/e1/f93485fe09aa36c0e1a3b76363efa1791241f7f863a010f725c95e8a74fe/nr_stream-1.1.5-py3-none-any.whl", hash = "sha256:47e12150b331ad2cb729cfd9d2abd281c9949809729ba461c6aa87dd9927b2d4", size = 10448, upload-time = "2023-02-14T22:44:07.72Z" }, +] + +[[package]] +name = "nr-util" +version = "0.8.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/0c/078c567d95e25564bc1ede3c2cf6ce1c91f50648c83786354b47224326da/nr.util-0.8.12.tar.gz", hash = "sha256:a4549c2033d99d2f0379b3f3d233fd2a8ade286bbf0b3ad0cc7cea16022214f4", size = 63707, upload-time = "2022-06-20T13:29:29.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/58/eab08df9dbd69d9e21fc5e7be6f67454f386336ec71e6b64e378a2dddea4/nr.util-0.8.12-py3-none-any.whl", hash = "sha256:91da02ac9795eb8e015372275c1efe54bac9051231ee9b0e7e6f96b0b4e7d2bb", size = 90319, upload-time = "2022-06-20T13:29:27.312Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -509,6 +683,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] +[[package]] +name = "pydoc-markdown" +version = "4.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "databind-core" }, + { name = "databind-json" }, + { name = "docspec" }, + { name = "docspec-python" }, + { name = "docstring-parser" }, + { name = "jinja2" }, + { name = "nr-util" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tomli" }, + { name = "tomli-w" }, + { name = "watchdog" }, + { name = "yapf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/8a/2c7f7ad656d22371a596d232fc140327b958d7f1d491b889632ea0cb7e87/pydoc_markdown-4.8.2.tar.gz", hash = "sha256:fb6c927e31386de17472d42f9bd3d3be2905977d026f6216881c65145aa67f0b", size = 44506, upload-time = "2023-06-26T12:37:01.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/5a/ce0b056d9a95fd0c06a6cfa5972477d79353392d19230c748a7ba5a9df04/pydoc_markdown-4.8.2-py3-none-any.whl", hash = "sha256:203f74119e6bb2f9deba43d452422de7c8ec31955b61e0620fa4dd8c2611715f", size = 67830, upload-time = "2023-06-26T12:36:59.502Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -606,6 +805,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + [[package]] name = "respx" version = "0.23.1" @@ -643,6 +857,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "typeapi" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/92/5a23ad34aa877edf00906166e339bfdc571543ea183ea7ab727bb01516c7/typeapi-2.3.0.tar.gz", hash = "sha256:a60d11f72c5ec27338cfd1c807f035b0b16ed2e3b798fb1c1d34fc5589f544be", size = 122687, upload-time = "2025-10-23T13:44:11.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/84/021bbeb7edb990dd6875cb6ab08d32faaa49fec63453d863730260a01f9e/typeapi-2.3.0-py3-none-any.whl", hash = "sha256:576b7dcb94412e91c5cae107a393674f8f99c10a24beb8be2302e3fed21d5cc2", size = 26858, upload-time = "2025-10-23T13:44:09.833Z" }, +] + [[package]] name = "typeguard" version = "4.5.1" @@ -676,6 +947,89 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, + { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + [[package]] name = "wrenn" version = "0.1.0" @@ -691,6 +1045,7 @@ dependencies = [ dev = [ { name = "datamodel-code-generator", extra = ["ruff"] }, { name = "mypy" }, + { name = "pydoc-markdown" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "respx" }, @@ -709,6 +1064,7 @@ requires-dist = [ dev = [ { name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" }, { name = "mypy", specifier = ">=1.20.0" }, + { name = "pydoc-markdown", specifier = ">=4.8.2" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "respx", specifier = ">=0.23.1" }, @@ -726,3 +1082,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b wheels = [ { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, ] + +[[package]] +name = "yapf" +version = "0.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" }, +] -- 2.49.0 From 213af4aee7889b8856a643d04759f654faf8ec8e Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sat, 2 May 2026 04:44:26 +0600 Subject: [PATCH 23/44] Increased timeout for long running API calls and updated typehints --- .pre-commit-config.yaml | 25 +++++++++++++++++++++ src/wrenn/async_capsule.py | 8 ++++--- src/wrenn/capsule.py | 11 ++++----- src/wrenn/client.py | 15 +++++++++---- src/wrenn/code_interpreter/async_capsule.py | 2 +- src/wrenn/code_interpreter/capsule.py | 2 +- src/wrenn/commands.py | 19 +++++++++++----- 7 files changed, 62 insertions(+), 20 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8dc6f53 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.10 + hooks: + - id: ruff + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.20.0 + hooks: + - id: mypy + additional_dependencies: + - pydantic>=2.12.5 + - httpx>=0.28.1 + - httpx-ws>=0.9.0 + - email-validator>=2.3.0 + + - repo: local + hooks: + - id: unit-tests + name: unit tests + entry: uv run pytest -m "not integration" -x -q + language: system + pass_filenames: false + always_run: true diff --git a/src/wrenn/async_capsule.py b/src/wrenn/async_capsule.py index 3e92de7..509f53a 100644 --- a/src/wrenn/async_capsule.py +++ b/src/wrenn/async_capsule.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import builtins import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -102,6 +103,7 @@ class AsyncCapsule: memory_mb=memory_mb, timeout_sec=timeout, ) + assert info.id is not None capsule = cls( _capsule_id=info.id, _client=client, @@ -284,7 +286,7 @@ class AsyncCapsule: async def pty( self, cmd: str = "/bin/bash", - args: list[str] | None = None, + args: builtins.list[str] | None = None, cols: int = 80, rows: int = 24, envs: dict[str, str] | None = None, @@ -316,7 +318,7 @@ class AsyncCapsule: """ async with httpx_ws.aconnect_ws( f"/v1/capsules/{self._id}/pty", client=self._client.http - ) as ws: + ) as ws: # type: httpx_ws.AsyncWebSocketSession session = AsyncPtySession(ws, self._id) await session._send_start( cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd @@ -335,7 +337,7 @@ class AsyncCapsule: """ async with httpx_ws.aconnect_ws( f"/v1/capsules/{self._id}/pty", client=self._client.http - ) as ws: + ) as ws: # type: httpx_ws.AsyncWebSocketSession session = AsyncPtySession(ws, self._id) await session._send_connect(tag) yield session diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py index 400409f..a5ed36a 100644 --- a/src/wrenn/capsule.py +++ b/src/wrenn/capsule.py @@ -1,5 +1,6 @@ from __future__ import annotations +import builtins import time from collections.abc import Iterator from contextlib import contextmanager @@ -94,9 +95,8 @@ 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._id: str = _capsule_id self._client = _client self._info = _info else: @@ -108,6 +108,7 @@ class Capsule: memory_mb=memory_mb, timeout_sec=timeout, ) + assert self._info.id is not None self._id = self._info.id self.commands = Commands(self._id, self._client.http) @@ -360,7 +361,7 @@ class Capsule: def pty( self, cmd: str = "/bin/bash", - args: list[str] | None = None, + args: builtins.list[str] | None = None, cols: int = 80, rows: int = 24, envs: dict[str, str] | None = None, @@ -391,7 +392,7 @@ class Capsule: """ with httpx_ws.connect_ws( f"/v1/capsules/{self._id}/pty", client=self._client.http - ) as ws: + ) as ws: # type: httpx_ws.WebSocketSession session = PtySession(ws, self._id) session._send_start( cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd @@ -410,7 +411,7 @@ class Capsule: """ with httpx_ws.connect_ws( f"/v1/capsules/{self._id}/pty", client=self._client.http - ) as ws: + ) as ws: # type: httpx_ws.WebSocketSession session = PtySession(ws, self._id) session._send_connect(tag) yield session diff --git a/src/wrenn/client.py b/src/wrenn/client.py index c927396..c51b190 100644 --- a/src/wrenn/client.py +++ b/src/wrenn/client.py @@ -6,6 +6,7 @@ import httpx from wrenn._config import DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL from wrenn.exceptions import handle_response + from wrenn.models import ( Template, ) @@ -13,6 +14,8 @@ from wrenn.models import ( Capsule as CapsuleModel, ) +_LONG_TIMEOUT = httpx.Timeout(60.0) + def _resolve_api_key(api_key: str | None) -> str: resolved = api_key or os.environ.get(ENV_API_KEY) @@ -108,7 +111,7 @@ class CapsulesResource: Raises: WrennNotFoundError: If no capsule with the given ID exists. """ - resp = self._http.post(f"/v1/capsules/{id}/pause") + resp = self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT) return CapsuleModel.model_validate(handle_response(resp)) def resume(self, id: str) -> CapsuleModel: @@ -224,7 +227,7 @@ class AsyncCapsulesResource: Raises: WrennNotFoundError: If no capsule with the given ID exists. """ - resp = await self._http.post(f"/v1/capsules/{id}/pause") + resp = await self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT) return CapsuleModel.model_validate(handle_response(resp)) async def resume(self, id: str) -> CapsuleModel: @@ -285,7 +288,9 @@ class SnapshotsResource: params: dict = {} if overwrite: params["overwrite"] = "true" - resp = self._http.post("/v1/snapshots", json=payload, params=params) + resp = self._http.post( + "/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT + ) return Template.model_validate(handle_response(resp)) def list(self, type: str | None = None) -> list[Template]: @@ -347,7 +352,9 @@ class AsyncSnapshotsResource: params: dict = {} if overwrite: params["overwrite"] = "true" - resp = await self._http.post("/v1/snapshots", json=payload, params=params) + resp = await self._http.post( + "/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT + ) return Template.model_validate(handle_response(resp)) async def list(self, type: str | None = None) -> list[Template]: diff --git a/src/wrenn/code_interpreter/async_capsule.py b/src/wrenn/code_interpreter/async_capsule.py index fb99752..f61937c 100644 --- a/src/wrenn/code_interpreter/async_capsule.py +++ b/src/wrenn/code_interpreter/async_capsule.py @@ -207,7 +207,7 @@ class AsyncCapsule(BaseAsyncCapsule): deadline = time.monotonic() + timeout headers = {"X-API-Key": self._client._api_key} - async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: + async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.AsyncWebSocketSession await ws.send_text(json.dumps(msg)) while time.monotonic() < deadline: time_left = deadline - time.monotonic() diff --git a/src/wrenn/code_interpreter/capsule.py b/src/wrenn/code_interpreter/capsule.py index 1b1f7ea..344c3f3 100644 --- a/src/wrenn/code_interpreter/capsule.py +++ b/src/wrenn/code_interpreter/capsule.py @@ -233,7 +233,7 @@ class Capsule(BaseCapsule): deadline = time.monotonic() + timeout headers = {"X-API-Key": self._client._api_key} - with httpx_ws.connect_ws(ws_url, headers=headers) as ws: + with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.WebSocketSession ws.send_text(json.dumps(msg)) while time.monotonic() < deadline: time_left = deadline - time.monotonic() diff --git a/src/wrenn/commands.py b/src/wrenn/commands.py index 7ca9f44..e24f898 100644 --- a/src/wrenn/commands.py +++ b/src/wrenn/commands.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import builtins import json from collections.abc import AsyncIterator, Iterator from dataclasses import dataclass @@ -199,6 +200,7 @@ class Commands: resp = self._http.post(f"/v1/capsules/{self._capsule_id}/exec", json=payload) data = handle_response(resp) + assert isinstance(data, dict) if background: return CommandHandle( @@ -217,6 +219,7 @@ class Commands: """ resp = self._http.get(f"/v1/capsules/{self._capsule_id}/processes") data = handle_response(resp) + assert isinstance(data, dict) return [ ProcessInfo( pid=p.get("pid", 0), @@ -252,7 +255,7 @@ class Commands: with httpx_ws.connect_ws( f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream", self._http, - ) as ws: + ) as ws: # type: httpx_ws.WebSocketSession while True: try: raw = ws.receive_json() @@ -263,7 +266,9 @@ class Commands: except httpx_ws.WebSocketDisconnect: break - def stream(self, cmd: str, args: list[str] | None = None) -> Iterator[StreamEvent]: + def stream( + self, cmd: str, args: builtins.list[str] | None = None + ) -> Iterator[StreamEvent]: """Execute a command via WebSocket, streaming output as events. Args: @@ -280,7 +285,7 @@ class Commands: with httpx_ws.connect_ws( f"/v1/capsules/{self._capsule_id}/exec/stream", self._http, - ) as ws: + ) as ws: # type: httpx_ws.WebSocketSession if args: start_msg: dict = {"type": "start", "cmd": cmd, "args": args} else: @@ -378,6 +383,7 @@ class AsyncCommands: f"/v1/capsules/{self._capsule_id}/exec", json=payload ) data = handle_response(resp) + assert isinstance(data, dict) if background: return CommandHandle( @@ -396,6 +402,7 @@ class AsyncCommands: """ resp = await self._http.get(f"/v1/capsules/{self._capsule_id}/processes") data = handle_response(resp) + assert isinstance(data, dict) return [ ProcessInfo( pid=p.get("pid", 0), @@ -433,7 +440,7 @@ class AsyncCommands: async with httpx_ws.aconnect_ws( f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream", self._http, - ) as ws: + ) as ws: # type: httpx_ws.AsyncWebSocketSession try: while True: raw = await ws.receive_json() @@ -445,7 +452,7 @@ class AsyncCommands: pass async def stream( - self, cmd: str, args: list[str] | None = None + self, cmd: str, args: builtins.list[str] | None = None ) -> AsyncIterator[StreamEvent]: """Execute a command via WebSocket, streaming output as events. @@ -463,7 +470,7 @@ class AsyncCommands: async with httpx_ws.aconnect_ws( f"/v1/capsules/{self._capsule_id}/exec/stream", self._http, - ) as ws: + ) as ws: # type: httpx_ws.AsyncWebSocketSession if args: start_msg: dict = {"type": "start", "cmd": cmd, "args": args} else: -- 2.49.0 From b5e2b12ef1c90aadd5784fdbad457977ca611084 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sat, 2 May 2026 04:45:05 +0600 Subject: [PATCH 24/44] Version bump and other minor changes --- .gitignore | 5 +++ .woodpecker/check.yml | 3 +- CLAUDE.md | 41 ++++++++++++++++++- pyproject.toml | 3 +- uv.lock | 93 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 140 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 23b2ad4..9670bd4 100644 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,8 @@ cython_debug/ .pypirc CODE_EXECUTION.md + +# AI +.code-review-graph/ +.claude +.mcp.json diff --git a/.woodpecker/check.yml b/.woodpecker/check.yml index 1cc4e69..8c90fcf 100644 --- a/.woodpecker/check.yml +++ b/.woodpecker/check.yml @@ -1,8 +1,7 @@ when: - event: push + event: pull_request branch: - main - - dev path: - "src/**" - "tests/**" diff --git a/CLAUDE.md b/CLAUDE.md index cc00331..4aff987 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,4 +129,43 @@ All values are CSS custom properties in `frontend/src/app.css`. 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. \ No newline at end of file +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. + + +## MCP Tools: code-review-graph + +**IMPORTANT: This project has a knowledge graph. ALWAYS use the +code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore +the codebase.** The graph is faster, cheaper (fewer tokens), and gives +you structural context (callers, dependents, test coverage) that file +scanning cannot. + +### When to use graph tools FIRST + +- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep +- **Understanding impact**: `get_impact_radius` instead of manually tracing imports +- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files +- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for +- **Architecture questions**: `get_architecture_overview` + `list_communities` + +Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need. + +### Key Tools + +| Tool | Use when | +|------|----------| +| `detect_changes` | Reviewing code changes — gives risk-scored analysis | +| `get_review_context` | Need source snippets for review — token-efficient | +| `get_impact_radius` | Understanding blast radius of a change | +| `get_affected_flows` | Finding which execution paths are impacted | +| `query_graph` | Tracing callers, callees, imports, tests, dependencies | +| `semantic_search_nodes` | Finding functions/classes by name or keyword | +| `get_architecture_overview` | Understanding high-level codebase structure | +| `refactor_tool` | Planning renames, finding dead code | + +### Workflow + +1. The graph auto-updates on file changes (via hooks). +2. Use `detect_changes` for code review. +3. Use `get_affected_flows` to understand impact. +4. Use `query_graph` pattern="tests_for" to check coverage. diff --git a/pyproject.toml b/pyproject.toml index a235194..c8f5d1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wrenn" -version = "0.1.0" +version = "0.1.1" description = "Python SDK for Wrenn" readme = "README.md" license = "MIT" @@ -36,6 +36,7 @@ build-backend = "hatchling.build" dev = [ "datamodel-code-generator[ruff]>=0.56.0", "mypy>=1.20.0", + "pre-commit>=4.6.0", "pydoc-markdown>=4.8.2", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", diff --git a/uv.lock b/uv.lock index bc040cd..36aea7d 100644 --- a/uv.lock +++ b/uv.lock @@ -72,6 +72,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -226,6 +235,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -282,6 +300,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, ] +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + [[package]] name = "genson" version = "1.3.0" @@ -343,6 +370,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/f8/a6bc80313a9e93c888fa10534dfce2ad76ff86911b6f485777ce6de6a073/httpx_ws-0.9.0-py3-none-any.whl", hash = "sha256:71640d2fb1bf9a225775015b33cd755cfd4c5f7e21c885192fe3adc4c387b248", size = 15759, upload-time = "2026-03-28T14:11:11.887Z" }, ] +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -548,6 +584,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "nr-date" version = "2.1.0" @@ -615,6 +660,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -745,6 +806,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "python-discovery" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, +] + [[package]] name = "pytokens" version = "0.4.1" @@ -956,6 +1030,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "virtualenv" +version = "21.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/8b/6331f7a7fe70131c301106ec1e7cf23e2501bf7d4ca3636805801ca191bb/virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e", size = 7614069, upload-time = "2026-04-27T17:05:58.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/eb/03bfb1299d4c4510329e470f13f9a4ce793df7fcb5a2fd3510f911066f61/virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", size = 7594690, upload-time = "2026-04-27T17:05:55.468Z" }, +] + [[package]] name = "watchdog" version = "6.0.0" @@ -1032,7 +1121,7 @@ wheels = [ [[package]] name = "wrenn" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "email-validator" }, @@ -1045,6 +1134,7 @@ dependencies = [ dev = [ { name = "datamodel-code-generator", extra = ["ruff"] }, { name = "mypy" }, + { name = "pre-commit" }, { name = "pydoc-markdown" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -1064,6 +1154,7 @@ requires-dist = [ dev = [ { name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" }, { name = "mypy", specifier = ">=1.20.0" }, + { name = "pre-commit", specifier = ">=4.6.0" }, { name = "pydoc-markdown", specifier = ">=4.8.2" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, -- 2.49.0 From dc66ac24d50b37b4e1f568ed3bfdbd91e1e307f7 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sat, 2 May 2026 04:50:11 +0600 Subject: [PATCH 25/44] Updated woodpecker def --- .woodpecker/check.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.woodpecker/check.yml b/.woodpecker/check.yml index 8c90fcf..3b78cc7 100644 --- a/.woodpecker/check.yml +++ b/.woodpecker/check.yml @@ -2,6 +2,7 @@ when: event: pull_request branch: - main + - dev path: - "src/**" - "tests/**" -- 2.49.0 From 4a7db8e2043d58cf1c002514b8a1415a7df7f01e Mon Sep 17 00:00:00 2001 From: Tasnim Kabir Sadik Date: Sat, 2 May 2026 19:02:39 +0600 Subject: [PATCH 26/44] 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) --- .gitignore | 3 +++ src/wrenn/commands.py | 20 +++++++++++++++++--- src/wrenn/exceptions.py | 7 +++++-- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 23b2ad4..155c313 100644 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,6 @@ cython_debug/ .pypirc CODE_EXECUTION.md + +.opencode/ +.claude/ diff --git a/src/wrenn/commands.py b/src/wrenn/commands.py index 7ca9f44..c8a0380 100644 --- a/src/wrenn/commands.py +++ b/src/wrenn/commands.py @@ -4,7 +4,7 @@ import base64 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 @@ -197,7 +197,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) if background: @@ -374,8 +382,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) diff --git a/src/wrenn/exceptions.py b/src/wrenn/exceptions.py index 438cfcb..0f63506 100644 --- a/src/wrenn/exceptions.py +++ b/src/wrenn/exceptions.py @@ -115,8 +115,11 @@ def handle_response(resp: httpx.Response) -> dict | list: try: body = resp.json() except Exception: - resp.raise_for_status() - raise + raise WrennInternalError( + code="internal_error", + message=resp.text or f"HTTP {resp.status_code}", + status_code=resp.status_code, + ) err = body.get("error", {}) code = err.get("code", "internal_error") -- 2.49.0 From 04e5dc652f50680dda249023120cdac87f7c303d Mon Sep 17 00:00:00 2001 From: Tasnim Kabir Sadik Date: Sat, 2 May 2026 21:34:02 +0600 Subject: [PATCH 27/44] 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 --- CLAUDE.md | 132 -------------------- 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/exceptions.py | 50 ++++---- 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 +-- 13 files changed, 142 insertions(+), 244 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index cc00331..0000000 --- a/CLAUDE.md +++ /dev/null @@ -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. \ No newline at end of file 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 3e92de7..24bfbe2 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 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: diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py index 400409f..62865a9 100644 --- a/src/wrenn/capsule.py +++ b/src/wrenn/capsule.py @@ -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: diff --git a/src/wrenn/code_interpreter/async_capsule.py b/src/wrenn/code_interpreter/async_capsule.py index fb99752..a9708c0 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 1b1f7ea..6aebd27 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/exceptions.py b/src/wrenn/exceptions.py index 0f63506..af16f6c 100644 --- a/src/wrenn/exceptions.py +++ b/src/wrenn/exceptions.py @@ -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 {} 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"}) -- 2.49.0 From 06b4a8cbcb07de849b23db8686ead478b3e57861 Mon Sep 17 00:00:00 2001 From: Tasnim Kabir Sadik Date: Sat, 2 May 2026 21:46:16 +0600 Subject: [PATCH 28/44] Merge issues fixed --- .gitignore | 5 +- .pre-commit-config.yaml | 25 ++++++ .woodpecker/check.yml | 2 +- AGENTS.md | 56 +++++++++++++ pyproject.toml | 3 +- src/wrenn/async_capsule.py | 8 +- src/wrenn/capsule.py | 9 +- src/wrenn/client.py | 15 +++- src/wrenn/code_interpreter/async_capsule.py | 2 +- src/wrenn/code_interpreter/capsule.py | 2 +- src/wrenn/commands.py | 19 +++-- uv.lock | 93 ++++++++++++++++++++- 12 files changed, 216 insertions(+), 23 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index 155c313..619209d 100644 --- a/.gitignore +++ b/.gitignore @@ -177,4 +177,7 @@ cython_debug/ CODE_EXECUTION.md .opencode/ -.claude/ +# AI +.code-review-graph/ +.claude +.mcp.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8dc6f53 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.10 + hooks: + - id: ruff + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.20.0 + hooks: + - id: mypy + additional_dependencies: + - pydantic>=2.12.5 + - httpx>=0.28.1 + - httpx-ws>=0.9.0 + - email-validator>=2.3.0 + + - repo: local + hooks: + - id: unit-tests + name: unit tests + entry: uv run pytest -m "not integration" -x -q + language: system + pass_filenames: false + always_run: true diff --git a/.woodpecker/check.yml b/.woodpecker/check.yml index 1cc4e69..3b78cc7 100644 --- a/.woodpecker/check.yml +++ b/.woodpecker/check.yml @@ -1,5 +1,5 @@ when: - event: push + event: pull_request branch: - main - dev 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 a235194..c8f5d1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wrenn" -version = "0.1.0" +version = "0.1.1" description = "Python SDK for Wrenn" readme = "README.md" license = "MIT" @@ -36,6 +36,7 @@ build-backend = "hatchling.build" dev = [ "datamodel-code-generator[ruff]>=0.56.0", "mypy>=1.20.0", + "pre-commit>=4.6.0", "pydoc-markdown>=4.8.2", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", diff --git a/src/wrenn/async_capsule.py b/src/wrenn/async_capsule.py index 24bfbe2..1d72408 100644 --- a/src/wrenn/async_capsule.py +++ b/src/wrenn/async_capsule.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import logging +import builtins import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -103,6 +104,7 @@ class AsyncCapsule: memory_mb=memory_mb, timeout_sec=timeout, ) + assert info.id is not None capsule = cls( _capsule_id=info.id, _client=client, @@ -287,7 +289,7 @@ class AsyncCapsule: async def pty( self, cmd: str = "/bin/bash", - args: list[str] | None = None, + args: builtins.list[str] | None = None, cols: int = 80, rows: int = 24, envs: dict[str, str] | None = None, @@ -319,7 +321,7 @@ class AsyncCapsule: """ async with httpx_ws.aconnect_ws( f"/v1/capsules/{self._id}/pty", client=self._client.http - ) as ws: + ) as ws: # type: httpx_ws.AsyncWebSocketSession session = AsyncPtySession(ws, self._id) await session._send_start( cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd @@ -338,7 +340,7 @@ class AsyncCapsule: """ async with httpx_ws.aconnect_ws( f"/v1/capsules/{self._id}/pty", client=self._client.http - ) as ws: + ) as ws: # type: httpx_ws.AsyncWebSocketSession session = AsyncPtySession(ws, self._id) await session._send_connect(tag) yield session diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py index 62865a9..29fe52f 100644 --- a/src/wrenn/capsule.py +++ b/src/wrenn/capsule.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import builtins import time from collections.abc import Iterator from contextlib import contextmanager @@ -96,7 +97,7 @@ class Capsule: """ if _capsule_id is not None: assert _client is not None - self._id = _capsule_id + self._id: str = _capsule_id self._client = _client self._info = _info if self._id is None: @@ -370,7 +371,7 @@ class Capsule: def pty( self, cmd: str = "/bin/bash", - args: list[str] | None = None, + args: builtins.list[str] | None = None, cols: int = 80, rows: int = 24, envs: dict[str, str] | None = None, @@ -401,7 +402,7 @@ class Capsule: """ with httpx_ws.connect_ws( f"/v1/capsules/{self._id}/pty", client=self._client.http - ) as ws: + ) as ws: # type: httpx_ws.WebSocketSession session = PtySession(ws, self._id) session._send_start( cmd=cmd, args=args, cols=cols, rows=rows, envs=envs, cwd=cwd @@ -420,7 +421,7 @@ class Capsule: """ with httpx_ws.connect_ws( f"/v1/capsules/{self._id}/pty", client=self._client.http - ) as ws: + ) as ws: # type: httpx_ws.WebSocketSession session = PtySession(ws, self._id) session._send_connect(tag) yield session diff --git a/src/wrenn/client.py b/src/wrenn/client.py index c927396..c51b190 100644 --- a/src/wrenn/client.py +++ b/src/wrenn/client.py @@ -6,6 +6,7 @@ import httpx from wrenn._config import DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL from wrenn.exceptions import handle_response + from wrenn.models import ( Template, ) @@ -13,6 +14,8 @@ from wrenn.models import ( Capsule as CapsuleModel, ) +_LONG_TIMEOUT = httpx.Timeout(60.0) + def _resolve_api_key(api_key: str | None) -> str: resolved = api_key or os.environ.get(ENV_API_KEY) @@ -108,7 +111,7 @@ class CapsulesResource: Raises: WrennNotFoundError: If no capsule with the given ID exists. """ - resp = self._http.post(f"/v1/capsules/{id}/pause") + resp = self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT) return CapsuleModel.model_validate(handle_response(resp)) def resume(self, id: str) -> CapsuleModel: @@ -224,7 +227,7 @@ class AsyncCapsulesResource: Raises: WrennNotFoundError: If no capsule with the given ID exists. """ - resp = await self._http.post(f"/v1/capsules/{id}/pause") + resp = await self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT) return CapsuleModel.model_validate(handle_response(resp)) async def resume(self, id: str) -> CapsuleModel: @@ -285,7 +288,9 @@ class SnapshotsResource: params: dict = {} if overwrite: params["overwrite"] = "true" - resp = self._http.post("/v1/snapshots", json=payload, params=params) + resp = self._http.post( + "/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT + ) return Template.model_validate(handle_response(resp)) def list(self, type: str | None = None) -> list[Template]: @@ -347,7 +352,9 @@ class AsyncSnapshotsResource: params: dict = {} if overwrite: params["overwrite"] = "true" - resp = await self._http.post("/v1/snapshots", json=payload, params=params) + resp = await self._http.post( + "/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT + ) return Template.model_validate(handle_response(resp)) async def list(self, type: str | None = None) -> list[Template]: diff --git a/src/wrenn/code_interpreter/async_capsule.py b/src/wrenn/code_interpreter/async_capsule.py index a9708c0..b328f6b 100644 --- a/src/wrenn/code_interpreter/async_capsule.py +++ b/src/wrenn/code_interpreter/async_capsule.py @@ -229,7 +229,7 @@ class AsyncCapsule(BaseAsyncCapsule): deadline = time.monotonic() + timeout headers = {"X-API-Key": self._client._api_key} - async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: + async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.AsyncWebSocketSession await ws.send_text(json.dumps(msg)) while time.monotonic() < deadline: time_left = deadline - time.monotonic() diff --git a/src/wrenn/code_interpreter/capsule.py b/src/wrenn/code_interpreter/capsule.py index 6aebd27..7d70d91 100644 --- a/src/wrenn/code_interpreter/capsule.py +++ b/src/wrenn/code_interpreter/capsule.py @@ -244,7 +244,7 @@ class Capsule(BaseCapsule): deadline = time.monotonic() + timeout headers = {"X-API-Key": self._client._api_key} - with httpx_ws.connect_ws(ws_url, headers=headers) as ws: + with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.WebSocketSession ws.send_text(json.dumps(msg)) while time.monotonic() < deadline: time_left = deadline - time.monotonic() diff --git a/src/wrenn/commands.py b/src/wrenn/commands.py index c8a0380..98b596e 100644 --- a/src/wrenn/commands.py +++ b/src/wrenn/commands.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import builtins import json from collections.abc import AsyncIterator, Iterator from dataclasses import dataclass @@ -207,6 +208,7 @@ class Commands: timeout=http_timeout, ) data = handle_response(resp) + assert isinstance(data, dict) if background: return CommandHandle( @@ -225,6 +227,7 @@ class Commands: """ resp = self._http.get(f"/v1/capsules/{self._capsule_id}/processes") data = handle_response(resp) + assert isinstance(data, dict) return [ ProcessInfo( pid=p.get("pid", 0), @@ -260,7 +263,7 @@ class Commands: with httpx_ws.connect_ws( f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream", self._http, - ) as ws: + ) as ws: # type: httpx_ws.WebSocketSession while True: try: raw = ws.receive_json() @@ -271,7 +274,9 @@ class Commands: except httpx_ws.WebSocketDisconnect: break - def stream(self, cmd: str, args: list[str] | None = None) -> Iterator[StreamEvent]: + def stream( + self, cmd: str, args: builtins.list[str] | None = None + ) -> Iterator[StreamEvent]: """Execute a command via WebSocket, streaming output as events. Args: @@ -288,7 +293,7 @@ class Commands: with httpx_ws.connect_ws( f"/v1/capsules/{self._capsule_id}/exec/stream", self._http, - ) as ws: + ) as ws: # type: httpx_ws.WebSocketSession if args: start_msg: dict = {"type": "start", "cmd": cmd, "args": args} else: @@ -392,6 +397,7 @@ class AsyncCommands: timeout=http_timeout, ) data = handle_response(resp) + assert isinstance(data, dict) if background: return CommandHandle( @@ -410,6 +416,7 @@ class AsyncCommands: """ resp = await self._http.get(f"/v1/capsules/{self._capsule_id}/processes") data = handle_response(resp) + assert isinstance(data, dict) return [ ProcessInfo( pid=p.get("pid", 0), @@ -447,7 +454,7 @@ class AsyncCommands: async with httpx_ws.aconnect_ws( f"/v1/capsules/{self._capsule_id}/processes/{pid}/stream", self._http, - ) as ws: + ) as ws: # type: httpx_ws.AsyncWebSocketSession try: while True: raw = await ws.receive_json() @@ -459,7 +466,7 @@ class AsyncCommands: pass async def stream( - self, cmd: str, args: list[str] | None = None + self, cmd: str, args: builtins.list[str] | None = None ) -> AsyncIterator[StreamEvent]: """Execute a command via WebSocket, streaming output as events. @@ -477,7 +484,7 @@ class AsyncCommands: async with httpx_ws.aconnect_ws( f"/v1/capsules/{self._capsule_id}/exec/stream", self._http, - ) as ws: + ) as ws: # type: httpx_ws.AsyncWebSocketSession if args: start_msg: dict = {"type": "start", "cmd": cmd, "args": args} else: diff --git a/uv.lock b/uv.lock index bc040cd..36aea7d 100644 --- a/uv.lock +++ b/uv.lock @@ -72,6 +72,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -226,6 +235,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -282,6 +300,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, ] +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + [[package]] name = "genson" version = "1.3.0" @@ -343,6 +370,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/f8/a6bc80313a9e93c888fa10534dfce2ad76ff86911b6f485777ce6de6a073/httpx_ws-0.9.0-py3-none-any.whl", hash = "sha256:71640d2fb1bf9a225775015b33cd755cfd4c5f7e21c885192fe3adc4c387b248", size = 15759, upload-time = "2026-03-28T14:11:11.887Z" }, ] +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -548,6 +584,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "nr-date" version = "2.1.0" @@ -615,6 +660,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -745,6 +806,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "python-discovery" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, +] + [[package]] name = "pytokens" version = "0.4.1" @@ -956,6 +1030,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "virtualenv" +version = "21.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/8b/6331f7a7fe70131c301106ec1e7cf23e2501bf7d4ca3636805801ca191bb/virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e", size = 7614069, upload-time = "2026-04-27T17:05:58.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/eb/03bfb1299d4c4510329e470f13f9a4ce793df7fcb5a2fd3510f911066f61/virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", size = 7594690, upload-time = "2026-04-27T17:05:55.468Z" }, +] + [[package]] name = "watchdog" version = "6.0.0" @@ -1032,7 +1121,7 @@ wheels = [ [[package]] name = "wrenn" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "email-validator" }, @@ -1045,6 +1134,7 @@ dependencies = [ dev = [ { name = "datamodel-code-generator", extra = ["ruff"] }, { name = "mypy" }, + { name = "pre-commit" }, { name = "pydoc-markdown" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -1064,6 +1154,7 @@ requires-dist = [ dev = [ { name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" }, { name = "mypy", specifier = ">=1.20.0" }, + { name = "pre-commit", specifier = ">=4.6.0" }, { name = "pydoc-markdown", specifier = ">=4.8.2" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, -- 2.49.0 From 6112c71abc2da6c88911caba10dff3e364189f87 Mon Sep 17 00:00:00 2001 From: Tasnim Kabir Sadik Date: Sat, 16 May 2026 17:02:25 +0600 Subject: [PATCH 29/44] test: make process kill integration test resilient --- api/openapi.yaml | 2 +- src/wrenn/models/_generated.py | 2 +- tests/test_integration.py | 17 ++++++++++++----- uv.lock | 2 +- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 8d3861c..c18c575 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2,7 +2,7 @@ openapi: "3.1.0" info: title: Wrenn API description: MicroVM-based code execution platform API. - version: "0.1.3" + version: "0.1.4" servers: - url: http://localhost:8080 diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py index 5542c2f..656f384 100644 --- a/src/wrenn/models/_generated.py +++ b/src/wrenn/models/_generated.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-04-22T20:21:34+00:00 +# timestamp: 2026-05-04T20:57:00+00:00 from __future__ import annotations from pydantic import AwareDatetime, BaseModel, EmailStr, Field diff --git a/tests/test_integration.py b/tests/test_integration.py index ff66983..87941dd 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -15,6 +15,17 @@ pytestmark = pytest.mark.integration _env_loaded = False +def _wait_for_pid_dead(capsule: Capsule, pid: int, timeout: float = 5.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = capsule.commands.run(f"ps -p {pid} -o stat= 2>/dev/null || true") + state = result.stdout.strip() + if not state or state.startswith("Z"): + return True + time.sleep(0.2) + return False + + def _ensure_env() -> None: global _env_loaded if _env_loaded: @@ -218,11 +229,7 @@ class TestCommands: def test_kill_process(self): handle = self.capsule.commands.run("sleep 30", background=True) self.capsule.commands.kill(handle.pid) - time.sleep(0.5) - - processes = self.capsule.commands.list() - pids = [p.pid for p in processes] - assert handle.pid not in pids + assert _wait_for_pid_dead(self.capsule, handle.pid) def test_run_duration_ms(self): result = self.capsule.commands.run("sleep 1") diff --git a/uv.lock b/uv.lock index 36aea7d..2fd6a46 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.14'", -- 2.49.0 From e5e4e1a85b7cf5aacb826a11bd6964e402bdefca Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sat, 16 May 2026 17:57:20 +0600 Subject: [PATCH 30/44] fix: update SDK for v0.2.0 API compatibility 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. --- .gitignore | 1 + AGENTS.md | 56 ---------------- api/openapi.yaml | 115 +++++++++++++++++++++++++++++---- docs/reference.md | 23 +++---- src/wrenn/async_capsule.py | 29 +++++++-- src/wrenn/capsule.py | 33 +++++++--- src/wrenn/client.py | 4 +- src/wrenn/exceptions.py | 3 + src/wrenn/models/_generated.py | 5 +- tests/test_capsule_features.py | 49 +++++++++----- tests/test_client.py | 20 +++--- 11 files changed, 212 insertions(+), 126 deletions(-) delete mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index 619209d..3632361 100644 --- a/.gitignore +++ b/.gitignore @@ -181,3 +181,4 @@ CODE_EXECUTION.md .code-review-graph/ .claude .mcp.json +AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 53b599d..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,56 +0,0 @@ -# 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/api/openapi.yaml b/api/openapi.yaml index 8d3861c..dfc5c75 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,8 +1,8 @@ openapi: "3.1.0" info: title: Wrenn API - description: MicroVM-based code execution platform API. - version: "0.1.3" + description: AI agent execution platform API. + version: "0.2.0" servers: - url: http://localhost:8080 @@ -866,8 +866,8 @@ paths: schema: $ref: "#/components/schemas/CreateCapsuleRequest" responses: - "201": - description: Capsule created + "202": + description: Capsule creation initiated (status will be "starting") content: application/json: schema: @@ -988,8 +988,8 @@ paths: security: - apiKeyAuth: [] responses: - "204": - description: Capsule destroyed + "202": + description: Capsule destruction initiated /v1/capsules/{id}/exec: parameters: @@ -1260,8 +1260,8 @@ paths: destroys all running resources. The capsule exists only as files on disk and can be resumed later. responses: - "200": - description: Capsule paused (snapshot taken, resources released) + "202": + description: Capsule pause initiated (status will be "pausing") content: application/json: schema: @@ -1292,8 +1292,8 @@ paths: memory loading. Boots a fresh Firecracker process, sets up a new network slot, and waits for envd to become ready. responses: - "200": - description: Capsule resumed (new VM booted from snapshot) + "202": + description: Capsule resume initiated (status will be "resuming") content: application/json: schema: @@ -2035,6 +2035,51 @@ paths: schema: $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: post: summary: Refresh host JWT @@ -2346,6 +2391,54 @@ paths: schema: $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: securitySchemes: apiKeyAuth: @@ -2544,7 +2637,7 @@ components: type: string status: type: string - enum: [pending, starting, running, paused, hibernated, stopped, missing, error] + enum: [pending, starting, running, pausing, paused, resuming, stopping, hibernated, stopped, missing, error] template: type: string vcpus: diff --git a/docs/reference.md b/docs/reference.md index 7e32f6c..9a406df 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1964,15 +1964,17 @@ inactivity TTL is set. #### wait\_ready ```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``. +Polling interval adapts to the current transient status: +0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping. + **Arguments**: - `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``. -- `interval` _float_ - Polling interval in seconds. Defaults to ``0.5``. **Raises**: @@ -2534,15 +2536,17 @@ inactivity TTL is set. #### wait\_ready ```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``. +Polling interval adapts to the current transient status: +0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping. + **Arguments**: - `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``. -- `interval` _float_ - Polling interval in seconds. Defaults to ``0.5``. **Raises**: @@ -2700,17 +2704,6 @@ Create a snapshot template from this capsule's current state. # wrenn.\_config - - -## ConnectionConfig Objects - -```python -@dataclass(frozen=True) -class ConnectionConfig() -``` - -Resolved credentials and base URL for Wrenn API calls. - # wrenn.\_git.\_auth diff --git a/src/wrenn/async_capsule.py b/src/wrenn/async_capsule.py index 1d72408..44a9e84 100644 --- a/src/wrenn/async_capsule.py +++ b/src/wrenn/async_capsule.py @@ -1,8 +1,8 @@ from __future__ import annotations import asyncio -import logging import builtins +import logging import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -140,14 +140,19 @@ class AsyncCapsule: info = await client.capsules.get(capsule_id) 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, _client=client, _info=info, ) + if info.status != Status.running: + await capsule.wait_ready() + + return capsule + # ── Dual instance/static lifecycle ────────────────────────── destroy = _DualMethod("_instance_destroy", "_static_destroy") @@ -224,12 +229,21 @@ class AsyncCapsule: """ 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``. + Polling interval adapts to the current transient status: + 0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping. + Args: timeout (float): Maximum seconds to wait. Defaults to ``30``. - interval (float): Polling interval in seconds. Defaults to ``0.5``. Raises: TimeoutError: If the capsule does not reach ``running`` state @@ -246,7 +260,10 @@ class AsyncCapsule: 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 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) raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s") diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py index 29fe52f..24dd4a5 100644 --- a/src/wrenn/capsule.py +++ b/src/wrenn/capsule.py @@ -1,7 +1,7 @@ from __future__ import annotations -import logging import builtins +import logging import time from collections.abc import Iterator from contextlib import contextmanager @@ -112,9 +112,9 @@ class Capsule: memory_mb=memory_mb, timeout_sec=timeout, ) - self._id = self._info.id - if self._id is None: + if self._info.id is None: raise RuntimeError("API returned a capsule without an ID") + self._id = self._info.id except Exception: self._client.close() raise @@ -214,14 +214,19 @@ class Capsule: info = client.capsules.get(capsule_id) if info.status == Status.paused: - info = client.capsules.resume(capsule_id) + client.capsules.resume(capsule_id) - return cls( + capsule = cls( _capsule_id=capsule_id, _client=client, _info=info, ) + if info.status != Status.running: + capsule.wait_ready() + + return capsule + # ── Dual instance/static lifecycle ────────────────────────── destroy = _DualMethod("_instance_destroy", "_static_destroy") @@ -306,12 +311,21 @@ class Capsule: """ 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``. + Polling interval adapts to the current transient status: + 0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping. + Args: timeout (float): Maximum seconds to wait. Defaults to ``30``. - interval (float): Polling interval in seconds. Defaults to ``0.5``. Raises: TimeoutError: If the capsule does not reach ``running`` state @@ -328,7 +342,10 @@ class Capsule: 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) + self._client.capsules.resume(self._id) + interval = ( + self._POLL_INTERVALS.get(info.status, 0.5) if info.status else 0.5 + ) time.sleep(interval) raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s") diff --git a/src/wrenn/client.py b/src/wrenn/client.py index c51b190..ceece27 100644 --- a/src/wrenn/client.py +++ b/src/wrenn/client.py @@ -111,7 +111,7 @@ class CapsulesResource: Raises: 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)) def resume(self, id: str) -> CapsuleModel: @@ -227,7 +227,7 @@ class AsyncCapsulesResource: Raises: 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)) async def resume(self, id: str) -> CapsuleModel: diff --git a/src/wrenn/exceptions.py b/src/wrenn/exceptions.py index af16f6c..65ac7e8 100644 --- a/src/wrenn/exceptions.py +++ b/src/wrenn/exceptions.py @@ -150,6 +150,9 @@ def handle_response(resp: httpx.Response) -> dict | list: if resp.status_code == 204: return {} + if not resp.content: + return {} + return resp.json() diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py index 5542c2f..8bcec35 100644 --- a/src/wrenn/models/_generated.py +++ b/src/wrenn/models/_generated.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-04-22T20:21:34+00:00 +# timestamp: 2026-05-15T07:57:28+00:00 from __future__ import annotations from pydantic import AwareDatetime, BaseModel, EmailStr, Field @@ -133,7 +133,10 @@ class Status(StrEnum): pending = "pending" starting = "starting" running = "running" + pausing = "pausing" paused = "paused" + resuming = "resuming" + stopping = "stopping" hibernated = "hibernated" stopped = "stopped" missing = "missing" diff --git a/tests/test_capsule_features.py b/tests/test_capsule_features.py index 825eb52..229a907 100644 --- a/tests/test_capsule_features.py +++ b/tests/test_capsule_features.py @@ -1,5 +1,6 @@ from __future__ import annotations +import httpx import respx from wrenn.capsule import Capsule, _build_proxy_url @@ -30,9 +31,13 @@ class TestCapsuleCreate: @respx.mock def test_capsule_constructor_creates(self): 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", base_url=BASE) assert cap.capsule_id == "cl-1" assert hasattr(cap, "commands") assert hasattr(cap, "files") @@ -40,7 +45,7 @@ class TestCapsuleCreate: @respx.mock def test_capsule_create_classmethod(self): 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", base_url=BASE) assert cap.capsule_id == "cl-2" @@ -48,9 +53,9 @@ class TestCapsuleCreate: @respx.mock def test_capsule_context_manager_kills(self): 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", base_url=BASE) as cap: assert cap.capsule_id == "cl-1" assert kill_route.called @@ -59,7 +64,7 @@ class TestCapsuleCreate: def test_capsule_env_var(self, monkeypatch): monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env_key") respx.post(f"{BASE}/v1/capsules").respond( - 201, json={"id": "cl-3", "status": "pending"} + 202, json={"id": "cl-3", "status": "starting"} ) cap = Capsule(base_url=BASE) assert cap.capsule_id == "cl-3" @@ -68,17 +73,21 @@ class TestCapsuleCreate: 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", base_url=BASE) + route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202) + Capsule._static_destroy( + "cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE + ) assert route.called @respx.mock def test_static_pause(self): 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", base_url=BASE) - assert info.status.value == "paused" + info = Capsule._static_pause( + "cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE + ) + assert info.status.value == "pausing" @respx.mock def test_static_list(self): @@ -106,18 +115,24 @@ 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", base_url=BASE) + cap = Capsule.connect( + "cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE + ) assert cap.capsule_id == "cl-1" @respx.mock def test_connect_paused_resumes(self): - respx.get(f"{BASE}/v1/capsules/cl-1").respond( - 200, json={"id": "cl-1", "status": "paused"} - ) + get_route = respx.get(f"{BASE}/v1/capsules/cl-1") + 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( - 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", base_url=BASE) assert cap.capsule_id == "cl-1" diff --git a/tests/test_client.py b/tests/test_client.py index 36adce9..1269233 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -36,10 +36,10 @@ class TestCapsules: @respx.mock def test_create(self, client): respx.post(f"{BASE}/v1/capsules").respond( - 201, + 202, json={ "id": "sb-1", - "status": "pending", + "status": "starting", "template": "base-python", "vcpus": 2, "memory_mb": 1024, @@ -48,12 +48,12 @@ class TestCapsules: resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024) assert isinstance(resp, Capsule) assert resp.id == "sb-1" - assert resp.status == Status.pending + assert resp.status == Status.starting @respx.mock def test_create_defaults(self, client): 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() assert resp.id == "sb-2" @@ -77,25 +77,25 @@ class TestCapsules: @respx.mock 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") assert route.called @respx.mock def test_pause(self, client): 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") - assert resp.status == Status.paused + assert resp.status == Status.pausing @respx.mock def test_resume(self, client): 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") - assert resp.status == Status.running + assert resp.status == Status.resuming @respx.mock def test_ping(self, client): @@ -238,7 +238,7 @@ class TestAsyncClient: async def test_async_capsules_create(self, async_client): async with async_client: 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") assert resp.id == "sb-1" -- 2.49.0 From 51c698751579e75df33fd0278539499efcfd7617 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Tue, 19 May 2026 15:06:49 +0600 Subject: [PATCH 31/44] fix: sync SDK with v0.2 API, add wait kwargs to lifecycle ops - Drop AuthResponse from models __init__ (renamed SessionResponse server-side; SDK auths via API key, doesn't need either) - Regenerate models from updated 0.2 openapi spec - Add wait: bool = False kwarg to Capsule/AsyncCapsule destroy/pause/resume (instance + _static_*); 500ms poll for resume/destroy, 2s for pause - Unify polling into _poll_until / _apoll_until + _wait_for_status helper; remove duplicated _POLL_INTERVALS tables - wait_ready: drop implicit paused->resume side effect; treat missing as fail - Capsule.connect: handle transient pausing (wait for paused first) before resuming, fixes hang when caller pauses then connects immediately - Drop dead "if self._id is None" branch in Capsule.__init__ after assigning from already-truthy _capsule_id - files.make_dir: detect already_exists across 409/wrapped error messages via shared _is_already_exists helper - tests/test_integration.py: assertions on final lifecycle state use wait=True --- api/openapi.yaml | 1273 +++++++++++++++++++++++++++++--- src/wrenn/async_capsule.py | 142 ++-- src/wrenn/capsule.py | 147 ++-- src/wrenn/files.py | 62 +- src/wrenn/models/__init__.py | 2 - src/wrenn/models/_generated.py | 162 +++- tests/test_integration.py | 10 +- 7 files changed, 1551 insertions(+), 247 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index dfc5c75..f3fb110 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -53,7 +53,7 @@ paths: tags: [auth] description: | Consumes the activation token sent via email and activates the user account. - Creates a default team and returns a JWT to log the user in. + Creates a default team and sets a session cookie to log the user in. requestBody: required: true content: @@ -66,11 +66,11 @@ paths: type: string responses: "200": - description: Account activated, JWT issued + description: Account activated, session cookie set content: application/json: schema: - $ref: "#/components/schemas/AuthResponse" + $ref: "#/components/schemas/SessionResponse" "400": description: Invalid or expired token content: @@ -78,17 +78,113 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/auth/logout: + post: + summary: Revoke the current session + operationId: logout + tags: [auth] + security: + - sessionAuth: [] + responses: + "204": + description: Session revoked; cookies cleared + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + /v1/auth/logout-all: + post: + summary: Revoke every session for the current user + operationId: logoutAll + tags: [auth] + description: | + Revokes every active session for the calling user across all devices, + including the caller's own. Returns 204 and clears cookies on the + response. Triggered automatically by password change, password add, + and password reset. + security: + - sessionAuth: [] + responses: + "204": + description: All sessions revoked + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + /v1/me/sessions: + get: + summary: List the caller's active sessions + operationId: listSessions + tags: [me] + security: + - sessionAuth: [] + responses: + "200": + description: Sessions list + content: + application/json: + schema: + type: object + properties: + sessions: + type: array + items: + type: object + properties: + id: + type: string + user_agent: + type: string + ip_address: + type: string + created_at: + type: string + format: date-time + last_seen_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + current: + type: boolean + "401": + $ref: "#/components/responses/Unauthorized" + + /v1/me/sessions/{id}: + delete: + summary: Revoke a single session + operationId: revokeSession + tags: [me] + security: + - sessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "204": + description: Session revoked + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /v1/auth/switch-team: post: summary: Switch active team operationId: switchTeam tags: [auth] security: - - bearerAuth: [] + - sessionAuth: [] description: | - Re-issues a JWT scoped to a different team. The user must be a member of - the target team (verified from DB). Use the returned token for subsequent - requests to that team's resources. + Rotates the session SID and updates its team scope. The user must be a + member of the target team (verified from DB). The new wrenn_sid and + wrenn_csrf cookies are set on the response. requestBody: required: true content: @@ -101,11 +197,11 @@ paths: type: string responses: "200": - description: New JWT issued for the target team + description: New session issued for the target team; cookies refreshed content: application/json: schema: - $ref: "#/components/schemas/AuthResponse" + $ref: "#/components/schemas/SessionResponse" "403": description: Not a member of this team content: @@ -136,7 +232,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AuthResponse" + $ref: "#/components/schemas/SessionResponse" "401": description: Invalid credentials content: @@ -144,7 +240,7 @@ paths: schema: $ref: "#/components/schemas/Error" - /v1/auth/oauth/{provider}: + /auth/oauth/{provider}: parameters: - name: provider in: path @@ -171,7 +267,7 @@ paths: schema: $ref: "#/components/schemas/Error" - /v1/auth/oauth/{provider}/callback: + /auth/oauth/{provider}/callback: parameters: - name: provider in: path @@ -188,9 +284,10 @@ paths: description: | Handles the OAuth provider's callback after user authorization. Exchanges the authorization code for a user profile, creates or - logs in the user, and redirects to the frontend with a JWT token. + logs in the user, sets the wrenn_sid + wrenn_csrf cookies, and + redirects to the SPA callback page. - **On success:** redirects to `{OAUTH_REDIRECT_URL}/auth/{provider}/callback?token=...&user_id=...&team_id=...&email=...` + **On success:** redirects to `{OAUTH_REDIRECT_URL}/auth/{provider}/callback` (no tokens in URL). **On error:** redirects to `{OAUTH_REDIRECT_URL}/auth/{provider}/callback?error=...` @@ -217,7 +314,7 @@ paths: operationId: getMe tags: [account] security: - - bearerAuth: [] + - sessionAuth: [] responses: "200": description: User profile @@ -231,7 +328,7 @@ paths: operationId: updateName tags: [account] security: - - bearerAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -245,12 +342,8 @@ paths: minLength: 1 maxLength: 100 responses: - "200": - description: Name updated, new JWT issued - content: - application/json: - schema: - $ref: "#/components/schemas/AuthResponse" + "204": + description: Name updated; session caches refreshed "400": description: Invalid name content: @@ -263,7 +356,7 @@ paths: operationId: deleteAccount tags: [account] security: - - bearerAuth: [] + - sessionAuth: [] description: | Soft-deletes the account (sets status=deleted, deleted_at=now). The account is permanently removed after 15 days. Blocked if the user @@ -301,7 +394,7 @@ paths: operationId: changePassword tags: [account] security: - - bearerAuth: [] + - sessionAuth: [] description: | For users with an existing password: requires `current_password` and `new_password`. For OAuth-only users adding a password: requires `new_password` and `confirm_password`. @@ -398,7 +491,7 @@ paths: operationId: connectProvider tags: [account] security: - - bearerAuth: [] + - sessionAuth: [] description: | Sets OAuth state and link cookies, then returns the provider's authorization URL. The frontend navigates to this URL to start the @@ -437,7 +530,7 @@ paths: operationId: disconnectProvider tags: [account] security: - - bearerAuth: [] + - sessionAuth: [] description: | Unlinks the OAuth provider from the current account. Blocked if this is the user's only login method (no password and no other providers). @@ -463,7 +556,7 @@ paths: operationId: createAPIKey tags: [api-keys] security: - - bearerAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -489,7 +582,7 @@ paths: operationId: listAPIKeys tags: [api-keys] security: - - bearerAuth: [] + - sessionAuth: [] responses: "200": description: List of API keys (plaintext keys are never returned) @@ -513,7 +606,7 @@ paths: operationId: deleteAPIKey tags: [api-keys] security: - - bearerAuth: [] + - sessionAuth: [] responses: "204": description: API key deleted @@ -524,7 +617,7 @@ paths: operationId: searchUsers tags: [users] security: - - bearerAuth: [] + - sessionAuth: [] description: | Returns up to 10 users whose email starts with the given prefix. The prefix must contain "@". Intended for the add-member UI autocomplete. @@ -557,7 +650,7 @@ paths: operationId: listTeams tags: [teams] security: - - bearerAuth: [] + - sessionAuth: [] responses: "200": description: Teams the user belongs to, each with their role @@ -573,7 +666,7 @@ paths: operationId: createTeam tags: [teams] security: - - bearerAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -613,7 +706,7 @@ paths: operationId: getTeam tags: [teams] security: - - bearerAuth: [] + - sessionAuth: [] responses: "200": description: Team details with members @@ -639,7 +732,7 @@ paths: operationId: renameTeam tags: [teams] security: - - bearerAuth: [] + - sessionAuth: [] description: Admin or owner role required (verified from DB). requestBody: required: true @@ -672,10 +765,10 @@ paths: operationId: deleteTeam tags: [teams] security: - - bearerAuth: [] + - sessionAuth: [] description: | Owner only. Soft-deletes the team and destroys all running/paused/starting - capsulees. All DB records are preserved. The team slug is permanently reserved. + capsules. All DB records are preserved. The team slug is permanently reserved. responses: "204": description: Team deleted @@ -699,7 +792,7 @@ paths: operationId: listTeamMembers tags: [teams] security: - - bearerAuth: [] + - sessionAuth: [] responses: "200": description: Members with roles @@ -715,7 +808,7 @@ paths: operationId: addTeamMember tags: [teams] security: - - bearerAuth: [] + - sessionAuth: [] description: Admin or owner role required. User is added instantly as a member. requestBody: required: true @@ -773,7 +866,7 @@ paths: operationId: updateMemberRole tags: [teams] security: - - bearerAuth: [] + - sessionAuth: [] description: | Admin or owner required. Valid target roles: admin, member. The owner's role cannot be changed. @@ -809,7 +902,7 @@ paths: operationId: removeTeamMember tags: [teams] security: - - bearerAuth: [] + - sessionAuth: [] description: Admin or owner required. Owner cannot be removed. responses: "204": @@ -840,7 +933,7 @@ paths: operationId: leaveTeam tags: [teams] security: - - bearerAuth: [] + - sessionAuth: [] description: The owner cannot leave; they must delete the team instead. responses: "204": @@ -859,6 +952,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -880,14 +974,15 @@ paths: $ref: "#/components/schemas/Error" get: - summary: List capsulees for your team + summary: List capsules for your team operationId: listCapsules tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] responses: "200": - description: List of capsulees + description: List of capsules content: application/json: schema: @@ -902,6 +997,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] parameters: - name: range in: query @@ -928,6 +1024,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] parameters: - name: from in: query @@ -967,6 +1064,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] responses: "200": description: Capsule details @@ -987,6 +1085,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] responses: "202": description: Capsule destruction initiated @@ -1005,6 +1104,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -1051,6 +1151,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] description: | Returns all running processes inside the capsule, including background processes and any processes started by templates or init scripts. @@ -1094,6 +1195,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] parameters: - name: signal in: query @@ -1139,6 +1241,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] description: | Opens a WebSocket connection to stream stdout/stderr from a running background process. The selector can be a numeric PID or a string tag. @@ -1167,9 +1270,10 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] description: | Resets the last_active_at timestamp for a running capsule, preventing - the auto-pause TTL from expiring. Use this as a keepalive for capsulees + the auto-pause TTL from expiring. Use this as a keepalive for capsules that are idle but should remain running. responses: "204": @@ -1201,7 +1305,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] - - bearerAuth: [] + - sessionAuth: [] description: | Returns time-series CPU, memory, and disk metrics for a capsule. Three tiers are available with different granularity and retention: @@ -1209,9 +1313,9 @@ paths: - `2h`: 30-second averages, last 2 hours - `24h`: 5-minute averages, last 24 hours - For running capsulees, data comes from the host agent's in-memory - ring buffer. For paused capsulees, data is read from persisted - snapshots in the database. Stopped/destroyed capsulees return 404. + For running capsules, data comes from the host agent's in-memory + ring buffer. For paused capsules, data is read from persisted + snapshots in the database. Stopped/destroyed capsules return 404. parameters: - name: range in: query @@ -1255,6 +1359,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] description: | Takes a snapshot of the capsule (VM state + memory + rootfs), then destroys all running resources. The capsule exists only as files on @@ -1287,10 +1392,12 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] description: | - Restores a paused capsule from its snapshot using UFFD for lazy - memory loading. Boots a fresh Firecracker process, sets up a new - network slot, and waits for envd to become ready. + Restores a paused capsule from its snapshot. Cloud Hypervisor is + relaunched in --restore mode with memory_restore_mode=ondemand so + guest pages fault in lazily via userfaultfd. The original network + slot (and host-reachable IP) is preserved across pause/resume. responses: "202": description: Capsule resume initiated (status will be "resuming") @@ -1312,18 +1419,15 @@ paths: tags: [snapshots] security: - apiKeyAuth: [] + - sessionAuth: [] description: | - Pauses a running capsule, takes a full snapshot, copies the snapshot - files to the images directory as a reusable template, then destroys - the capsule. The template can be used to create new capsulees. - parameters: - - name: overwrite - in: query - required: false - schema: - type: string - enum: ["true"] - description: Set to "true" to overwrite an existing snapshot with the same name. + Live snapshot: briefly pauses the capsule, writes its VM state + + memory + flattened rootfs to a new template directory, then resumes + the capsule. The source capsule keeps running after the snapshot; + the resulting template can be used to create new capsules. + + Snapshots are immutable: each call must use a fresh name. Re-using + an existing name returns 409 Conflict. requestBody: required: true content: @@ -1350,6 +1454,7 @@ paths: tags: [snapshots] security: - apiKeyAuth: [] + - sessionAuth: [] parameters: - name: type in: query @@ -1382,6 +1487,7 @@ paths: tags: [snapshots] security: - apiKeyAuth: [] + - sessionAuth: [] description: Removes the snapshot files from disk and deletes the database record. responses: "204": @@ -1407,6 +1513,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -1452,6 +1559,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -1487,6 +1595,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -1527,6 +1636,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -1547,7 +1657,9 @@ paths: schema: $ref: "#/components/schemas/Error" "409": - description: Capsule not running + description: > + Capsule not running, or a directory already exists at the + target path (error code `already_exists`). content: application/json: schema: @@ -1567,6 +1679,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -1603,6 +1716,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] description: | Opens a WebSocket connection for streaming command execution. @@ -1656,6 +1770,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] description: | Opens a WebSocket connection for an interactive PTY (terminal) session. Supports creating new sessions, sending input, resizing, killing, and @@ -1733,6 +1848,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] description: | Streams file content to the capsule without buffering in memory. Suitable for large files. Uses the same multipart/form-data format @@ -1782,6 +1898,7 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] description: | Streams file content from the capsule without buffering in memory. Suitable for large files. Returns raw bytes with chunked transfer encoding. @@ -1818,7 +1935,7 @@ paths: operationId: createHost tags: [hosts] security: - - bearerAuth: [] + - sessionAuth: [] description: | Creates a new host record and returns a one-time registration token. Regular hosts can only be created by admins. BYOC hosts can be created @@ -1854,7 +1971,7 @@ paths: operationId: listHosts tags: [hosts] security: - - bearerAuth: [] + - sessionAuth: [] description: | Admins see all hosts. Non-admins see only BYOC hosts belonging to their team. responses: @@ -1880,7 +1997,7 @@ paths: operationId: getHost tags: [hosts] security: - - bearerAuth: [] + - sessionAuth: [] responses: "200": description: Host details @@ -1900,18 +2017,18 @@ paths: operationId: deleteHost tags: [hosts] security: - - bearerAuth: [] + - sessionAuth: [] description: | Admins can delete any host. Team owners and admins can delete BYOC hosts belonging to their team. Without `?force=true`, returns 409 if the host - has active capsulees. With `?force=true`, destroys all capsulees first. + has active capsules. With `?force=true`, destroys all capsules first. parameters: - name: force in: query required: false schema: type: boolean - description: If true, destroy all capsulees on the host before deleting. + description: If true, destroy all capsules on the host before deleting. responses: "204": description: Host deleted @@ -1922,7 +2039,7 @@ paths: schema: $ref: "#/components/schemas/Error" "409": - description: Host has active capsulees (only when force is not set) + description: Host has active capsules (only when force is not set) content: application/json: schema: @@ -1941,7 +2058,7 @@ paths: operationId: regenerateHostToken tags: [hosts] security: - - bearerAuth: [] + - sessionAuth: [] description: | Issues a new registration token for a host still in "pending" status. Use this when a previous registration attempt failed after consuming @@ -2056,7 +2173,13 @@ paths: properties: event: type: string - enum: [sandbox.auto_paused] + description: | + Lifecycle event type. Known values: + * `sandbox.auto_paused` — TTL reaper paused the capsule + * `sandbox.stopped` — autonomous destroy (crash/eviction) + * `sandbox.error` — VMM/crash watcher reported error + Unknown event names are accepted and forwarded to the + stream consumer as-is (future-compatible). sandbox_id: type: string host_id: @@ -2122,7 +2245,7 @@ paths: operationId: getHostDeletePreview tags: [hosts] security: - - bearerAuth: [] + - sessionAuth: [] description: | Returns the list of capsule IDs that would be destroyed if the host were deleted with `?force=true`. No state is modified. @@ -2159,7 +2282,7 @@ paths: operationId: listHostTags tags: [hosts] security: - - bearerAuth: [] + - sessionAuth: [] responses: "200": description: List of tags @@ -2175,7 +2298,7 @@ paths: operationId: addHostTag tags: [hosts] security: - - bearerAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -2210,7 +2333,7 @@ paths: operationId: removeHostTag tags: [hosts] security: - - bearerAuth: [] + - sessionAuth: [] responses: "204": description: Tag removed @@ -2227,7 +2350,7 @@ paths: operationId: createChannel tags: [channels] security: - - bearerAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -2248,7 +2371,7 @@ paths: operationId: listChannels tags: [channels] security: - - bearerAuth: [] + - sessionAuth: [] responses: "200": description: Channels list @@ -2268,7 +2391,7 @@ paths: operationId: testChannel tags: [channels] security: - - bearerAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -2301,7 +2424,7 @@ paths: operationId: getChannel tags: [channels] security: - - bearerAuth: [] + - sessionAuth: [] responses: "200": description: Channel details @@ -2320,7 +2443,7 @@ paths: operationId: updateChannel tags: [channels] security: - - bearerAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -2347,7 +2470,7 @@ paths: operationId: deleteChannel tags: [channels] security: - - bearerAuth: [] + - sessionAuth: [] responses: "204": description: Channel deleted @@ -2368,7 +2491,7 @@ paths: operationId: rotateChannelConfig tags: [channels] security: - - bearerAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -2398,11 +2521,11 @@ paths: 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. + Requires platform admin access. Session caches for the target user + are invalidated immediately so the flag flip takes effect on the + user's next request. security: - - bearerAuth: [] + - sessionAuth: [] parameters: - name: id in: path @@ -2439,7 +2562,811 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/events/stream: + get: + summary: Real-time lifecycle event stream + operationId: streamEvents + tags: [events] + description: | + Server-Sent Events stream of capsule, template, and host lifecycle + events scoped to the caller's active team. Browsers send the + wrenn_sid cookie automatically on EventSource connections; SDKs + authenticate via X-API-Key. + + Frame format follows the standard SSE protocol: + ``` + event: capsule.create + data: {"event":"capsule.create","outcome":"success","resource":{"id":"sb-..."},"sandbox":{...},"timestamp":"2026-05-19T02:00:00Z"} + + : keepalive + ``` + A `: keepalive` comment is emitted every 30s. + security: + - apiKeyAuth: [] + - sessionAuth: [] + responses: + "200": + description: SSE stream opened + content: + text/event-stream: + schema: + $ref: "#/components/schemas/SSEEvent" + "401": + description: Missing or invalid auth + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/audit-logs: + get: + summary: List team audit log entries + operationId: listAuditLogs + tags: [audit] + description: Paginated cursor list of audit events for the caller's team. + security: + - sessionAuth: [] + parameters: + - name: before + in: query + required: false + schema: + type: string + format: date-time + - name: before_id + in: query + required: false + schema: + type: string + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + responses: + "200": + description: Audit log page + content: + application/json: + schema: + type: object + properties: + entries: + type: array + items: + $ref: "#/components/schemas/AuditLogEntry" + next_cursor: + type: object + nullable: true + properties: + before: + type: string + format: date-time + before_id: + type: string + + /v1/admin/events/stream: + get: + summary: Admin SSE event stream (all teams) + operationId: adminStreamEvents + tags: [admin, events] + description: | + Admin variant of /v1/events/stream that emits events across all teams. + Requires an admin session cookie. + security: + - sessionAuth: [] + responses: + "200": + description: SSE stream opened + content: + text/event-stream: + schema: + $ref: "#/components/schemas/SSEEvent" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + /v1/admin/audit-logs: + get: + summary: List audit log entries (all teams) + operationId: adminListAuditLogs + tags: [admin, audit] + security: + - sessionAuth: [] + parameters: + - name: before + in: query + schema: {type: string, format: date-time} + - name: before_id + in: query + schema: {type: string} + - name: limit + in: query + schema: {type: integer, minimum: 1, maximum: 200, default: 50} + responses: + "200": + description: Audit log page (all teams) + content: + application/json: + schema: + type: object + properties: + entries: + type: array + items: + $ref: "#/components/schemas/AuditLogEntry" + + /v1/admin/teams: + get: + summary: List all teams (admin) + operationId: adminListTeams + tags: [admin] + security: + - sessionAuth: [] + responses: + "200": + description: Teams list + content: + application/json: + schema: + type: array + items: {type: object} + + /v1/admin/teams/{id}/byoc: + put: + summary: Toggle BYOC for a team (admin) + operationId: adminSetTeamBYOC + tags: [admin] + security: + - sessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: {type: string} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [byoc] + properties: + byoc: {type: boolean} + responses: + "204": + description: Updated + + /v1/admin/teams/{id}: + delete: + summary: Delete a team (admin) + operationId: adminDeleteTeam + tags: [admin] + security: + - sessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: {type: string} + responses: + "204": + description: Deleted + + /v1/admin/users: + get: + summary: List all users (admin) + operationId: adminListUsers + tags: [admin] + security: + - sessionAuth: [] + responses: + "200": + description: Users list + content: + application/json: + schema: + type: array + items: {type: object} + + /v1/admin/users/{id}/active: + put: + summary: Activate or deactivate a user (admin) + operationId: adminSetUserActive + tags: [admin] + security: + - sessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: {type: string} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [active] + properties: + active: {type: boolean} + responses: + "204": + description: Updated + + /v1/admin/templates: + get: + summary: List all templates (admin) + operationId: adminListTemplates + tags: [admin] + security: + - sessionAuth: [] + responses: + "200": + description: Templates list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Template" + + /v1/admin/templates/{name}: + delete: + summary: Delete a template (admin) + operationId: adminDeleteTemplate + tags: [admin] + security: + - sessionAuth: [] + parameters: + - name: name + in: path + required: true + schema: {type: string} + responses: + "204": + description: Deleted + + /v1/admin/builds: + post: + summary: Submit a template build (admin) + operationId: adminCreateBuild + tags: [admin] + security: + - sessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: {type: object} + responses: + "202": + description: Build queued + content: + application/json: + schema: {type: object} + get: + summary: List builds (admin) + operationId: adminListBuilds + tags: [admin] + security: + - sessionAuth: [] + responses: + "200": + description: Builds list + content: + application/json: + schema: + type: array + items: {type: object} + + /v1/admin/builds/{id}: + get: + summary: Get build detail (admin) + operationId: adminGetBuild + tags: [admin] + security: + - sessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: {type: string} + responses: + "200": + description: Build detail + content: + application/json: + schema: {type: object} + + /v1/admin/builds/{id}/cancel: + post: + summary: Cancel a build (admin) + operationId: adminCancelBuild + tags: [admin] + security: + - sessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: {type: string} + responses: + "204": + description: Cancelled + + /v1/admin/capsules: + post: + summary: Create a capsule on behalf of any team (admin) + operationId: adminCreateCapsule + tags: [admin] + security: + - sessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateCapsuleRequest" + responses: + "201": + description: Capsule created + content: + application/json: + schema: + $ref: "#/components/schemas/Capsule" + get: + summary: List capsules across all teams (admin) + operationId: adminListCapsules + tags: [admin] + security: + - sessionAuth: [] + responses: + "200": + description: Capsules list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Capsule" + + /v1/admin/capsules/{id}: + parameters: + - name: id + in: path + required: true + schema: {type: string} + get: + summary: Get capsule detail (admin) + operationId: adminGetCapsule + tags: [admin] + security: + - sessionAuth: [] + responses: + "200": + description: Capsule detail + content: + application/json: + schema: + $ref: "#/components/schemas/Capsule" + delete: + summary: Destroy capsule (admin) + operationId: adminDestroyCapsule + tags: [admin] + security: + - sessionAuth: [] + responses: + "204": + description: Destroyed + + /v1/admin/capsules/{id}/snapshot: + post: + summary: Create snapshot from any capsule (admin) + operationId: adminCreateSnapshotFromCapsule + tags: [admin] + security: + - sessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: {type: string} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: {type: string} + responses: + "201": + description: Snapshot created + content: + application/json: + schema: + $ref: "#/components/schemas/Template" + + /v1/admin/capsules/{id}/exec: + parameters: + - name: id + in: path + required: true + schema: {type: string} + post: + summary: Execute a command on any capsule (admin) + operationId: adminExecCommand + tags: [admin] + security: + - sessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ExecRequest" + responses: + "200": + description: Command output (foreground exec) + content: + application/json: + schema: + $ref: "#/components/schemas/ExecResponse" + "202": + description: Background process started + content: + application/json: + schema: + $ref: "#/components/schemas/BackgroundExecResponse" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/FailedPrecondition" + + /v1/admin/capsules/{id}/metrics: + parameters: + - name: id + in: path + required: true + schema: {type: string} + get: + summary: Get per-capsule resource metrics (admin) + operationId: adminGetCapsuleMetrics + tags: [admin] + security: + - sessionAuth: [] + parameters: + - name: range + in: query + required: false + schema: + type: string + enum: ["5m", "10m", "1h", "2h", "6h", "12h", "24h"] + default: "10m" + responses: + "200": + description: Metrics retrieved + content: + application/json: + schema: + $ref: "#/components/schemas/CapsuleMetrics" + "404": + $ref: "#/components/responses/NotFound" + + /v1/admin/capsules/{id}/processes: + parameters: + - name: id + in: path + required: true + schema: {type: string} + get: + summary: List running processes on any capsule (admin) + operationId: adminListProcesses + tags: [admin] + security: + - sessionAuth: [] + responses: + "200": + description: Process list + content: + application/json: + schema: + $ref: "#/components/schemas/ProcessListResponse" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/FailedPrecondition" + + /v1/admin/capsules/{id}/processes/{selector}: + parameters: + - name: id + in: path + required: true + schema: {type: string} + - name: selector + in: path + required: true + schema: {type: string} + description: Process PID (numeric) or tag (string) + delete: + summary: Kill a process on any capsule (admin) + operationId: adminKillProcess + tags: [admin] + security: + - sessionAuth: [] + parameters: + - name: signal + in: query + required: false + schema: + type: string + enum: [SIGKILL, SIGTERM] + default: SIGKILL + responses: + "204": + description: Process killed + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/FailedPrecondition" + + /v1/admin/capsules/{id}/files/write: + parameters: + - name: id + in: path + required: true + schema: {type: string} + post: + summary: Upload a file to any capsule (admin) + operationId: adminUploadFile + tags: [admin] + security: + - sessionAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [path, file] + properties: + path: {type: string} + file: {type: string, format: binary} + responses: + "204": + description: File uploaded + "409": + $ref: "#/components/responses/FailedPrecondition" + + /v1/admin/capsules/{id}/files/read: + parameters: + - name: id + in: path + required: true + schema: {type: string} + post: + summary: Download a file from any capsule (admin) + operationId: adminDownloadFile + tags: [admin] + security: + - sessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ReadFileRequest" + responses: + "200": + description: File content + content: + application/octet-stream: + schema: + type: string + format: binary + "404": + $ref: "#/components/responses/NotFound" + + /v1/admin/capsules/{id}/files/list: + parameters: + - name: id + in: path + required: true + schema: {type: string} + post: + summary: List directory contents on any capsule (admin) + operationId: adminListDir + tags: [admin] + security: + - sessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListDirRequest" + responses: + "200": + description: Directory listing + content: + application/json: + schema: + $ref: "#/components/schemas/ListDirResponse" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/FailedPrecondition" + + /v1/admin/capsules/{id}/files/mkdir: + parameters: + - name: id + in: path + required: true + schema: {type: string} + post: + summary: Create a directory on any capsule (admin) + operationId: adminMakeDir + tags: [admin] + security: + - sessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MakeDirRequest" + responses: + "200": + description: Directory created + content: + application/json: + schema: + $ref: "#/components/schemas/MakeDirResponse" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/FailedPrecondition" + + /v1/admin/capsules/{id}/files/remove: + parameters: + - name: id + in: path + required: true + schema: {type: string} + post: + summary: Remove a file or directory on any capsule (admin) + operationId: adminRemovePath + tags: [admin] + security: + - sessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RemoveRequest" + responses: + "204": + description: File or directory removed + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/FailedPrecondition" + + /v1/admin/capsules/{id}/exec/stream: + parameters: + - name: id + in: path + required: true + schema: {type: string} + get: + summary: Stream command execution on any capsule via WebSocket (admin) + operationId: adminExecStream + tags: [admin] + security: + - sessionAuth: [] + description: | + Admin variant of /v1/capsules/{id}/exec/stream. Same protocol — WebSocket + upgrade, client sends `{"type":"start", "cmd":..., "args":...}` to start; + server streams stdout/stderr/exit frames. + responses: + "101": + description: WebSocket upgrade + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/FailedPrecondition" + + /v1/admin/capsules/{id}/pty: + parameters: + - name: id + in: path + required: true + schema: {type: string} + get: + summary: Interactive PTY session on any capsule via WebSocket (admin) + operationId: adminPtySession + tags: [admin] + security: + - sessionAuth: [] + description: | + Admin variant of /v1/capsules/{id}/pty. Same protocol — base64-encoded + PTY bytes, start/connect/input/resize/kill control messages, persistent + sessions reconnectable via tag. + responses: + "101": + description: WebSocket upgrade + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/FailedPrecondition" + + /v1/admin/capsules/{id}/processes/{selector}/stream: + parameters: + - name: id + in: path + required: true + schema: {type: string} + - name: selector + in: path + required: true + schema: {type: string} + description: Process PID (numeric) or tag (string) + get: + summary: Stream process output on any capsule via WebSocket (admin) + operationId: adminConnectProcess + tags: [admin] + security: + - sessionAuth: [] + responses: + "101": + description: WebSocket upgrade + "404": + $ref: "#/components/responses/NotFound" + components: + responses: + BadRequest: + description: Invalid request parameters + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + Unauthorized: + description: Missing or invalid auth + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + Forbidden: + description: Authenticated but not permitted (e.g. non-admin on /v1/admin/*) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + FailedPrecondition: + description: Resource state does not allow this operation (e.g. exec on a paused capsule) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + securitySchemes: apiKeyAuth: type: apiKey @@ -2447,11 +3374,23 @@ components: name: X-API-Key description: API key for capsule lifecycle operations. Create via POST /v1/api-keys. - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: JWT token from /v1/auth/login or /v1/auth/signup. Valid for 6 hours. + sessionAuth: + type: apiKey + in: cookie + name: wrenn_sid + description: | + Opaque session cookie set by POST /v1/auth/login, /v1/auth/activate, or + the OAuth callback. HttpOnly, Secure, SameSite=Strict. Idle window 6h, + absolute lifetime 24h. State-changing requests also require an + X-CSRF-Token header matching the wrenn_csrf cookie (double-submit). + csrfHeader: + type: apiKey + in: header + name: X-CSRF-Token + description: | + Double-submit CSRF token whose value must match the wrenn_csrf cookie. + Required on all non-GET requests authenticated via session cookie. + Not required for API key auth. hostTokenAuth: type: apiKey @@ -2491,12 +3430,13 @@ components: type: string description: Confirmation message instructing user to check email - AuthResponse: + SessionResponse: type: object + description: | + Returned by login, activate, and switch-team. The actual auth credential + is the wrenn_sid cookie set on the response. The body carries identity + data the SPA needs to bootstrap. properties: - token: - type: string - description: JWT token (valid for 6 hours) user_id: type: string team_id: @@ -2505,6 +3445,10 @@ components: type: string name: type: string + role: + type: string + is_admin: + type: boolean CreateAPIKeyRequest: type: object @@ -2549,13 +3493,22 @@ components: memory_mb: type: integer default: 512 + disk_size_mb: + type: integer + default: 5120 + description: > + Maximum size of the per-capsule copy-on-write disk in MB. Capped + at 5 GB by default; the actual size is max(disk_size_mb, origin + rootfs size). timeout_sec: type: integer + minimum: 0 default: 0 description: > Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means - no auto-pause. + no auto-pause. Positive values below 60 are silently clamped + to 60 (the agent's startup envelope). UsageResponse: type: object @@ -2664,6 +3617,17 @@ components: last_updated: type: string format: date-time + metadata: + type: object + additionalProperties: {type: string} + nullable: true + description: | + Free-form key/value labels attached at create-time. Also carries + agent-side version info (kernel_version, vmm_version, + agent_version, envd_version) when running. + disk_size_mb: + type: integer + nullable: true CreateSnapshotRequest: type: object @@ -2696,6 +3660,16 @@ components: created_at: type: string format: date-time + platform: + type: boolean + description: | + True when the template is platform-managed (visible to all teams, + e.g. the built-in `minimal` rootfs). False for team-owned + snapshot templates. + metadata: + type: object + additionalProperties: {type: string} + nullable: true ExecRequest: type: object @@ -2997,7 +3971,7 @@ components: type: array items: type: string - description: IDs of capsulees that would be destroyed on force-delete. + description: IDs of capsules that would be destroyed on force-delete. HostHasCapsulesError: type: object @@ -3014,7 +3988,7 @@ components: type: array items: type: string - description: IDs of active capsulees blocking deletion. + description: IDs of active capsules blocking deletion. AddTagRequest: type: object @@ -3104,7 +4078,7 @@ components: mem_bytes: type: integer format: int64 - description: "Resident memory in bytes (VmRSS of Firecracker process)" + description: "Resident memory in bytes (VmRSS of Cloud Hypervisor process)" disk_bytes: type: integer format: int64 @@ -3135,12 +4109,12 @@ components: items: type: string enum: - - capsule.created - - capsule.running - - capsule.paused - - capsule.destroyed - - template.snapshot.created - - template.snapshot.deleted + - capsule.create + - capsule.pause + - capsule.resume + - capsule.destroy + - template.snapshot.create + - template.snapshot.delete - host.up - host.down @@ -3180,12 +4154,12 @@ components: items: type: string enum: - - capsule.created - - capsule.running - - capsule.paused - - capsule.destroyed - - template.snapshot.created - - template.snapshot.deleted + - capsule.create + - capsule.pause + - capsule.resume + - capsule.destroy + - template.snapshot.create + - template.snapshot.delete - host.up - host.down @@ -3257,3 +4231,78 @@ components: type: string message: type: string + + AuditLogEntry: + type: object + properties: + id: {type: string} + actor_type: {type: string, enum: [user, api_key, host, system]} + actor_id: {type: string} + actor_name: {type: string} + resource_type: {type: string} + resource_id: {type: string} + action: {type: string} + scope: {type: string} + status: {type: string, enum: [success, failure]} + metadata: + type: object + additionalProperties: true + created_at: + type: string + format: date-time + + SSEEvent: + type: object + description: | + Wire format of one SSE message body. The event name (`event:` line) is + the `kind` and the JSON below is the `data:` line. + properties: + event: + type: string + enum: + - connected + - capsule.create + - capsule.pause + - capsule.resume + - capsule.destroy + - capsule.state.changed + - template.snapshot.create + - template.snapshot.delete + - host.up + - host.down + outcome: + type: string + enum: [success, error] + description: | + Present for action events (capsule.* except state.changed, + template.snapshot.*). Absent for host.up/down, capsule.state.changed, + and the connected sentinel. + resource: + type: object + properties: + id: {type: string} + type: {type: string} + actor: + type: object + properties: + type: {type: string, enum: [user, api_key, system]} + id: {type: string} + name: {type: string} + metadata: + type: object + additionalProperties: {type: string} + description: | + Event-specific context. Examples: `reason` (ttl_expired, + host_failure, cleanup_after_create_error, orphaned), + `host_ip`, `from`/`to` (for capsule.state.changed). + error: + type: string + description: Failure reason; only set when outcome=error. + sandbox: + allOf: + - $ref: "#/components/schemas/Capsule" + nullable: true + description: Populated for capsule.* events; null if DB lookup failed. + timestamp: + type: string + format: date-time diff --git a/src/wrenn/async_capsule.py b/src/wrenn/async_capsule.py index 44a9e84..4cf4c96 100644 --- a/src/wrenn/async_capsule.py +++ b/src/wrenn/async_capsule.py @@ -10,15 +10,54 @@ from contextlib import asynccontextmanager import httpx_ws from wrenn._git import AsyncGit -from wrenn.capsule import _DualMethod, _build_proxy_url +from wrenn.capsule import ( + _DEFAULT_WAIT_TIMEOUT, + _DESTROY_INTERVAL, + _FAIL_STATUSES, + _PAUSE_INTERVAL, + _RESUME_INTERVAL, + _START_INTERVAL, + _DualMethod, + _build_proxy_url, +) from wrenn.client import AsyncWrennClient from wrenn.commands import AsyncCommands +from wrenn.exceptions import WrennNotFoundError from wrenn.files import AsyncFiles from wrenn.models import Capsule as CapsuleModel from wrenn.models import Status, Template from wrenn.pty import AsyncPtySession +async def _apoll_until( + fetch, + targets: set[Status], + interval: float, + timeout: float = _DEFAULT_WAIT_TIMEOUT, + fail_on: set[Status] | None = None, +) -> CapsuleModel: + fail = fail_on if fail_on is not None else _FAIL_STATUSES + treat_missing_as_target = Status.missing in targets + deadline = time.monotonic() + timeout + last: CapsuleModel | None = None + while time.monotonic() < deadline: + try: + last = await fetch() + except WrennNotFoundError: + if treat_missing_as_target: + return CapsuleModel(status=Status.missing) + raise + if last.status in targets: + return last + if last.status is not None and last.status in fail: + raise RuntimeError(f"Capsule entered {last.status} state while waiting") + await asyncio.sleep(interval) + raise TimeoutError( + f"Capsule did not reach {targets} within {timeout}s " + f"(last status: {last.status if last else 'unknown'})" + ) + + class AsyncCapsule: """Async Wrenn capsule with e2b-compatible interface. @@ -139,15 +178,16 @@ class AsyncCapsule: client = AsyncWrennClient(api_key=api_key, base_url=base_url) info = await client.capsules.get(capsule_id) - if info.status == Status.paused: - await client.capsules.resume(capsule_id) - capsule = cls( _capsule_id=capsule_id, _client=client, _info=info, ) + if info.status == Status.pausing: + info = await capsule._wait_for_status({Status.paused}, _PAUSE_INTERVAL) + if info.status == Status.paused: + await client.capsules.resume(capsule_id) if info.status != Status.running: await capsule.wait_ready() @@ -160,22 +200,35 @@ class AsyncCapsule: resume = _DualMethod("_instance_resume", "_static_resume") get_info = _DualMethod("_instance_get_info", "_static_get_info") - async def _instance_destroy(self) -> None: + async def _instance_destroy(self, wait: bool = False) -> None: await self._client.capsules.destroy(self._id) + if wait: + await self._wait_for_status( + {Status.stopped, Status.missing}, _DESTROY_INTERVAL + ) @classmethod async def _static_destroy( cls, capsule_id: str, *, + wait: bool = False, api_key: str | None = None, base_url: str | None = None, ) -> None: async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client: await client.capsules.destroy(capsule_id) + if wait: + await _apoll_until( + lambda: client.capsules.get(capsule_id), + {Status.stopped, Status.missing}, + _DESTROY_INTERVAL, + ) - async def _instance_pause(self) -> CapsuleModel: + async def _instance_pause(self, wait: bool = False) -> CapsuleModel: self._info = await self._client.capsules.pause(self._id) + if wait: + self._info = await self._wait_for_status({Status.paused}, _PAUSE_INTERVAL) return self._info @classmethod @@ -183,14 +236,24 @@ class AsyncCapsule: cls, capsule_id: str, *, + wait: bool = False, api_key: str | None = None, base_url: str | None = None, ) -> CapsuleModel: async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client: - return await client.capsules.pause(capsule_id) + info = await client.capsules.pause(capsule_id) + if wait: + info = await _apoll_until( + lambda: client.capsules.get(capsule_id), + {Status.paused}, + _PAUSE_INTERVAL, + ) + return info - async def _instance_resume(self) -> CapsuleModel: + async def _instance_resume(self, wait: bool = False) -> CapsuleModel: self._info = await self._client.capsules.resume(self._id) + if wait: + self._info = await self._wait_for_status({Status.running}, _RESUME_INTERVAL) return self._info @classmethod @@ -198,11 +261,19 @@ class AsyncCapsule: cls, capsule_id: str, *, + wait: bool = False, api_key: str | None = None, base_url: str | None = None, ) -> CapsuleModel: async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client: - return await client.capsules.resume(capsule_id) + info = await client.capsules.resume(capsule_id) + if wait: + info = await _apoll_until( + lambda: client.capsules.get(capsule_id), + {Status.running}, + _RESUME_INTERVAL, + ) + return info async def _instance_get_info(self) -> CapsuleModel: self._info = await self._client.capsules.get(self._id) @@ -229,43 +300,30 @@ class AsyncCapsule: """ await self._client.capsules.ping(self._id) - _POLL_INTERVALS: dict[Status, float] = { - Status.starting: 0.5, - Status.resuming: 0.5, - Status.pausing: 2.0, - Status.stopping: 1.0, - } + async def _wait_for_status( + self, + targets: set[Status], + interval: float, + timeout: float = _DEFAULT_WAIT_TIMEOUT, + ) -> CapsuleModel: + info = await _apoll_until( + lambda: self._client.capsules.get(self._id), + targets, + interval, + timeout, + fail_on={Status.error, Status.stopped, Status.missing} - targets, + ) + self._info = info + return info - async def wait_ready(self, timeout: float = 30) -> None: - """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: - timeout (float): Maximum seconds to wait. Defaults to ``30``. + async def wait_ready(self, timeout: float = _DEFAULT_WAIT_TIMEOUT) -> None: + """Await until capsule status is ``running``. Raises: - TimeoutError: If the capsule does not reach ``running`` state - within ``timeout`` seconds. - RuntimeError: If the capsule enters an error, stopped, or paused - state while waiting. + TimeoutError: If capsule does not reach ``running`` within ``timeout``. + RuntimeError: If capsule enters error/stopped/missing while waiting. """ - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - info = await self._client.capsules.get(self._id) - if info.status == Status.running: - self._info = info - return - if info.status in (Status.error, Status.stopped): - 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) - raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s") + await self._wait_for_status({Status.running}, _START_INTERVAL, timeout) async def is_running(self) -> bool: """Check whether the capsule is currently running. diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py index 24dd4a5..f533205 100644 --- a/src/wrenn/capsule.py +++ b/src/wrenn/capsule.py @@ -13,6 +13,7 @@ import httpx_ws from wrenn._git import Git from wrenn.client import WrennClient from wrenn.commands import Commands +from wrenn.exceptions import WrennNotFoundError from wrenn.files import Files from wrenn.models import Capsule as CapsuleModel from wrenn.models import Status, Template @@ -28,6 +29,44 @@ def _build_proxy_url(base_url: str, capsule_id: str | None, port: int) -> str: return f"{scheme}://{port}-{capsule_id}.{host}" +_RESUME_INTERVAL = 0.5 +_DESTROY_INTERVAL = 0.5 +_PAUSE_INTERVAL = 2.0 +_START_INTERVAL = 0.5 +_DEFAULT_WAIT_TIMEOUT = 30.0 +_FAIL_STATUSES = {Status.error} + + +def _poll_until( + fetch, + targets: set[Status], + interval: float, + timeout: float = _DEFAULT_WAIT_TIMEOUT, + fail_on: set[Status] | None = None, +) -> CapsuleModel: + """Poll ``fetch()`` until status ∈ ``targets``. Raise on ``fail_on``/timeout.""" + fail = fail_on if fail_on is not None else _FAIL_STATUSES + treat_missing_as_target = Status.missing in targets + deadline = time.monotonic() + timeout + last: CapsuleModel | None = None + while time.monotonic() < deadline: + try: + last = fetch() + except WrennNotFoundError: + if treat_missing_as_target: + return CapsuleModel(status=Status.missing) + raise + if last.status in targets: + return last + if last.status is not None and last.status in fail: + raise RuntimeError(f"Capsule entered {last.status} state while waiting") + time.sleep(interval) + raise TimeoutError( + f"Capsule did not reach {targets} within {timeout}s " + f"(last status: {last.status if last else 'unknown'})" + ) + + class _DualMethod: """Descriptor that dispatches to instance method or classmethod depending on call site.""" @@ -100,9 +139,6 @@ 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: self._client = WrennClient(api_key=api_key, base_url=base_url) try: @@ -213,15 +249,16 @@ class Capsule: client = WrennClient(api_key=api_key, base_url=base_url) info = client.capsules.get(capsule_id) - if info.status == Status.paused: - client.capsules.resume(capsule_id) - capsule = cls( _capsule_id=capsule_id, _client=client, _info=info, ) + if info.status == Status.pausing: + info = capsule._wait_for_status({Status.paused}, _PAUSE_INTERVAL) + if info.status == Status.paused: + client.capsules.resume(capsule_id) if info.status != Status.running: capsule.wait_ready() @@ -234,25 +271,36 @@ class Capsule: resume = _DualMethod("_instance_resume", "_static_resume") get_info = _DualMethod("_instance_get_info", "_static_get_info") - def _instance_destroy(self) -> None: - """Destroy this capsule.""" + def _instance_destroy(self, wait: bool = False) -> None: + """Destroy this capsule. If ``wait``, poll until stopped/missing.""" self._client.capsules.destroy(self._id) + if wait: + self._wait_for_status({Status.stopped, Status.missing}, _DESTROY_INTERVAL) @classmethod def _static_destroy( cls, capsule_id: str, *, + wait: bool = False, api_key: str | None = None, base_url: str | None = None, ) -> None: """Destroy a capsule by ID.""" with WrennClient(api_key=api_key, base_url=base_url) as client: client.capsules.destroy(capsule_id) + if wait: + _poll_until( + lambda: client.capsules.get(capsule_id), + {Status.stopped, Status.missing}, + _DESTROY_INTERVAL, + ) - def _instance_pause(self) -> CapsuleModel: - """Pause this capsule.""" + def _instance_pause(self, wait: bool = False) -> CapsuleModel: + """Pause this capsule. If ``wait``, poll until ``paused``.""" self._info = self._client.capsules.pause(self._id) + if wait: + self._info = self._wait_for_status({Status.paused}, _PAUSE_INTERVAL) return self._info @classmethod @@ -260,16 +308,26 @@ class Capsule: cls, capsule_id: str, *, + wait: bool = False, api_key: str | None = None, base_url: str | None = None, ) -> CapsuleModel: """Pause a capsule by ID.""" with WrennClient(api_key=api_key, base_url=base_url) as client: - return client.capsules.pause(capsule_id) + info = client.capsules.pause(capsule_id) + if wait: + info = _poll_until( + lambda: client.capsules.get(capsule_id), + {Status.paused}, + _PAUSE_INTERVAL, + ) + return info - def _instance_resume(self) -> CapsuleModel: - """Resume this capsule.""" + def _instance_resume(self, wait: bool = False) -> CapsuleModel: + """Resume this capsule. If ``wait``, poll until ``running``.""" self._info = self._client.capsules.resume(self._id) + if wait: + self._info = self._wait_for_status({Status.running}, _RESUME_INTERVAL) return self._info @classmethod @@ -277,12 +335,20 @@ class Capsule: cls, capsule_id: str, *, + wait: bool = False, api_key: str | None = None, base_url: str | None = None, ) -> CapsuleModel: """Resume a capsule by ID.""" with WrennClient(api_key=api_key, base_url=base_url) as client: - return client.capsules.resume(capsule_id) + info = client.capsules.resume(capsule_id) + if wait: + info = _poll_until( + lambda: client.capsules.get(capsule_id), + {Status.running}, + _RESUME_INTERVAL, + ) + return info def _instance_get_info(self) -> CapsuleModel: """Get current info for this capsule.""" @@ -311,43 +377,30 @@ class Capsule: """ self._client.capsules.ping(self._id) - _POLL_INTERVALS: dict[Status, float] = { - Status.starting: 0.5, - Status.resuming: 0.5, - Status.pausing: 2.0, - Status.stopping: 1.0, - } + def _wait_for_status( + self, + targets: set[Status], + interval: float, + timeout: float = _DEFAULT_WAIT_TIMEOUT, + ) -> CapsuleModel: + info = _poll_until( + lambda: self._client.capsules.get(self._id), + targets, + interval, + timeout, + fail_on={Status.error, Status.stopped, Status.missing} - targets, + ) + self._info = info + return info - def wait_ready(self, timeout: float = 30) -> None: - """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: - timeout (float): Maximum seconds to wait. Defaults to ``30``. + def wait_ready(self, timeout: float = _DEFAULT_WAIT_TIMEOUT) -> None: + """Block until capsule status is ``running``. Raises: - TimeoutError: If the capsule does not reach ``running`` state - within ``timeout`` seconds. - RuntimeError: If the capsule enters an error, stopped, or paused - state while waiting. + TimeoutError: If capsule does not reach ``running`` within ``timeout``. + RuntimeError: If capsule enters error/stopped/missing while waiting. """ - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - info = self._client.capsules.get(self._id) - if info.status == Status.running: - self._info = info - return - if info.status in (Status.error, Status.stopped): - 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) - raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s") + self._wait_for_status({Status.running}, _START_INTERVAL, timeout) def is_running(self) -> bool: """Check whether the capsule is currently running. diff --git a/src/wrenn/files.py b/src/wrenn/files.py index 477aeca..5a99289 100644 --- a/src/wrenn/files.py +++ b/src/wrenn/files.py @@ -9,6 +9,36 @@ from wrenn.exceptions import WrennNotFoundError, _raise_for_status, handle_respo from wrenn.models import FileEntry, ListDirResponse, MakeDirResponse +def _is_already_exists(resp: httpx.Response) -> bool: + """Detect server's already-exists reply across status codes / code strings. + + Server may return 409 with code "conflict"/"already_exists" or wrap + "already_exists" inside an "internal" 500 message. + """ + if resp.status_code < 400: + return False + try: + body = resp.json() + except Exception: + return False + err = body.get("error", {}) if isinstance(body, dict) else {} + code = err.get("code", "") + msg = err.get("message", "") or "" + return code in {"conflict", "already_exists"} or "already_exists" in msg + + +def _find_entry(list_fn, path: str) -> FileEntry | None: + parent = os.path.dirname(path) + name = os.path.basename(path) + try: + for entry in list_fn(parent, depth=1): + if entry.name == name: + return entry + except WrennNotFoundError: + return None + return None + + class Files: """Sync filesystem interface. Accessed via ``capsule.files``.""" @@ -118,17 +148,10 @@ class Files: f"/v1/capsules/{self._capsule_id}/files/mkdir", json={"path": path}, ) - if resp.status_code == 409: - try: - body = resp.json() - if body.get("error", {}).get("code") == "conflict": - parent = os.path.dirname(path) - name = os.path.basename(path) - for entry in self.list(parent, depth=1): - if entry.name == name: - return entry - except Exception: - pass + if _is_already_exists(resp): + existing = _find_entry(self.list, path) + if existing is not None: + return existing parsed = MakeDirResponse.model_validate(handle_response(resp)) if parsed.entry is None: raise RuntimeError("mkdir response missing entry") @@ -315,17 +338,12 @@ class AsyncFiles: f"/v1/capsules/{self._capsule_id}/files/mkdir", json={"path": path}, ) - if resp.status_code == 409: - try: - body = resp.json() - if body.get("error", {}).get("code") == "conflict": - parent = os.path.dirname(path) - name = os.path.basename(path) - for entry in await self.list(parent, depth=1): - if entry.name == name: - return entry - except Exception: - pass + if _is_already_exists(resp): + parent = os.path.dirname(path) + name = os.path.basename(path) + for entry in await self.list(parent, depth=1): + if entry.name == name: + return entry parsed = MakeDirResponse.model_validate(handle_response(resp)) if parsed.entry is None: raise RuntimeError("mkdir response missing entry") diff --git a/src/wrenn/models/__init__.py b/src/wrenn/models/__init__.py index 5628e11..6fe5eb8 100644 --- a/src/wrenn/models/__init__.py +++ b/src/wrenn/models/__init__.py @@ -1,6 +1,5 @@ from wrenn.models._generated import ( APIKeyResponse, - AuthResponse, Capsule, CreateAPIKeyRequest, CreateCapsuleRequest, @@ -34,7 +33,6 @@ from wrenn.models._generated import ( __all__ = [ "APIKeyResponse", - "AuthResponse", "CreateAPIKeyRequest", "CreateHostRequest", "CreateHostResponse", diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py index 8bcec35..8eb7425 100644 --- a/src/wrenn/models/_generated.py +++ b/src/wrenn/models/_generated.py @@ -1,10 +1,10 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-05-15T07:57:28+00:00 +# timestamp: 2026-05-19T08:54:50+00:00 from __future__ import annotations from pydantic import AwareDatetime, BaseModel, EmailStr, Field -from typing import Annotated +from typing import Annotated, Any from datetime import date as date_aliased from enum import StrEnum @@ -27,14 +27,20 @@ class SignupResponse(BaseModel): ] = None -class AuthResponse(BaseModel): - token: Annotated[str | None, Field(description="JWT token (valid for 6 hours)")] = ( - None - ) +class SessionResponse(BaseModel): + """ + Returned by login, activate, and switch-team. The actual auth credential + is the wrenn_sid cookie set on the response. The body carries identity + data the SPA needs to bootstrap. + + """ + user_id: str | None = None team_id: str | None = None email: str | None = None name: str | None = None + role: str | None = None + is_admin: bool | None = None class CreateAPIKeyRequest(BaseModel): @@ -62,10 +68,17 @@ class CreateCapsuleRequest(BaseModel): template: str | None = "minimal" vcpus: int | None = 1 memory_mb: int | None = 512 + disk_size_mb: Annotated[ + int | None, + Field( + description="Maximum size of the per-capsule copy-on-write disk in MB. Capped at 5 GB by default; the actual size is max(disk_size_mb, origin rootfs size).\n" + ), + ] = 5120 timeout_sec: Annotated[ int | None, Field( - description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause.\n" + description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause. Positive values below 60 are silently clamped to 60 (the agent's startup envelope).\n", + ge=0, ), ] = 0 @@ -156,6 +169,13 @@ class Capsule(BaseModel): started_at: AwareDatetime | None = None last_active_at: AwareDatetime | None = None last_updated: AwareDatetime | None = None + metadata: Annotated[ + dict[str, str] | None, + Field( + description="Free-form key/value labels attached at create-time. Also carries\nagent-side version info (kernel_version, vmm_version,\nagent_version, envd_version) when running.\n" + ), + ] = None + disk_size_mb: int | None = None class CreateSnapshotRequest(BaseModel): @@ -180,6 +200,13 @@ class Template(BaseModel): memory_mb: int | None = None size_bytes: int | None = None created_at: AwareDatetime | None = None + platform: Annotated[ + bool | None, + Field( + description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal` rootfs). False for team-owned\nsnapshot templates.\n" + ), + ] = None + metadata: dict[str, str] | None = None class ExecRequest(BaseModel): @@ -402,7 +429,7 @@ class HostDeletePreview(BaseModel): host: Host | None = None sandbox_ids: Annotated[ list[str] | None, - Field(description="IDs of capsulees that would be destroyed on force-delete."), + Field(description="IDs of capsules that would be destroyed on force-delete."), ] = None @@ -410,8 +437,7 @@ class Error(BaseModel): code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None message: str | None = None sandbox_ids: Annotated[ - list[str] | None, - Field(description="IDs of active capsulees blocking deletion."), + list[str] | None, Field(description="IDs of active capsules blocking deletion.") ] = None @@ -479,7 +505,9 @@ class MetricPoint(BaseModel): ] = None mem_bytes: Annotated[ int | None, - Field(description="Resident memory in bytes (VmRSS of Firecracker process)"), + Field( + description="Resident memory in bytes (VmRSS of Cloud Hypervisor process)" + ), ] = None disk_bytes: Annotated[ int | None, Field(description="Allocated disk bytes for the CoW sparse file") @@ -497,12 +525,12 @@ class Provider(StrEnum): class Event(StrEnum): - capsule_created = "capsule.created" - capsule_running = "capsule.running" - capsule_paused = "capsule.paused" - capsule_destroyed = "capsule.destroyed" - template_snapshot_created = "template.snapshot.created" - template_snapshot_deleted = "template.snapshot.deleted" + capsule_create = "capsule.create" + capsule_pause = "capsule.pause" + capsule_resume = "capsule.resume" + capsule_destroy = "capsule.destroy" + template_snapshot_create = "template.snapshot.create" + template_snapshot_delete = "template.snapshot.delete" host_up = "host.up" host_down = "host.down" @@ -594,6 +622,106 @@ class Error1(BaseModel): error: Error2 | None = None +class ActorType(StrEnum): + user = "user" + api_key = "api_key" + host = "host" + system = "system" + + +class Status2(StrEnum): + success = "success" + failure = "failure" + + +class AuditLogEntry(BaseModel): + id: str | None = None + actor_type: ActorType | None = None + actor_id: str | None = None + actor_name: str | None = None + resource_type: str | None = None + resource_id: str | None = None + action: str | None = None + scope: str | None = None + status: Status2 | None = None + metadata: dict[str, Any] | None = None + created_at: AwareDatetime | None = None + + +class Event2(StrEnum): + connected = "connected" + capsule_create = "capsule.create" + capsule_pause = "capsule.pause" + capsule_resume = "capsule.resume" + capsule_destroy = "capsule.destroy" + capsule_state_changed = "capsule.state.changed" + template_snapshot_create = "template.snapshot.create" + template_snapshot_delete = "template.snapshot.delete" + host_up = "host.up" + host_down = "host.down" + + +class Outcome(StrEnum): + """ + Present for action events (capsule.* except state.changed, + template.snapshot.*). Absent for host.up/down, capsule.state.changed, + and the connected sentinel. + + """ + + success = "success" + error = "error" + + +class Resource(BaseModel): + id: str | None = None + type: str | None = None + + +class Type4(StrEnum): + user = "user" + api_key = "api_key" + system = "system" + + +class Actor(BaseModel): + type: Type4 | None = None + id: str | None = None + name: str | None = None + + +class SSEEvent(BaseModel): + """ + Wire format of one SSE message body. The event name (`event:` line) is + the `kind` and the JSON below is the `data:` line. + + """ + + event: Event2 | None = None + outcome: Annotated[ + Outcome | None, + Field( + description="Present for action events (capsule.* except state.changed,\ntemplate.snapshot.*). Absent for host.up/down, capsule.state.changed,\nand the connected sentinel.\n" + ), + ] = None + resource: Resource | None = None + actor: Actor | None = None + metadata: Annotated[ + dict[str, str] | None, + Field( + description="Event-specific context. Examples: `reason` (ttl_expired,\nhost_failure, cleanup_after_create_error, orphaned),\n`host_ip`, `from`/`to` (for capsule.state.changed).\n" + ), + ] = None + error: Annotated[ + str | None, Field(description="Failure reason; only set when outcome=error.") + ] = None + sandbox: Annotated[ + Capsule | None, + Field(description="Populated for capsule.* events; null if DB lookup failed."), + ] = None + timestamp: AwareDatetime | None = None + + class ListDirResponse(BaseModel): entries: list[FileEntry] | None = None diff --git a/tests/test_integration.py b/tests/test_integration.py index ff66983..d280d2c 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -46,7 +46,7 @@ class TestCapsuleLifecycle: assert capsule_id assert capsule.info is not None finally: - capsule.destroy() + capsule.destroy(wait=True) info = Capsule.get_info(capsule_id) assert info.status in (Status.stopped, Status.missing) @@ -65,7 +65,7 @@ class TestCapsuleLifecycle: assert capsule.is_running() info = Capsule.get_info(capsule_id) - assert info.status in (Status.stopped, Status.missing) + assert info.status in (Status.stopping, Status.stopped, Status.missing) def test_get_info(self): capsule = Capsule(wait=True) @@ -80,11 +80,11 @@ class TestCapsuleLifecycle: def test_pause_and_resume(self): capsule = Capsule(wait=True) try: - paused = capsule.pause() + paused = capsule.pause(wait=True) assert paused.status == Status.paused assert not capsule.is_running() - resumed = capsule.resume() + resumed = capsule.resume(wait=True) assert resumed.status == Status.running finally: capsule.destroy() @@ -93,7 +93,7 @@ class TestCapsuleLifecycle: capsule = Capsule(wait=True) capsule_id = capsule.capsule_id try: - Capsule.destroy(capsule_id) + Capsule.destroy(capsule_id, wait=True) except Exception: capsule.destroy() raise -- 2.49.0 From fce514c49c3633eac30121ee29d106cd18cb5cb3 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Tue, 19 May 2026 17:12:52 +0600 Subject: [PATCH 32/44] test: expand command/PTY/git coverage, fix WebSocket close handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests: - tests/test_commands.py: unit coverage for Commands/AsyncCommands — payload construction (cwd, envs, tag, timeout), background dispatch, base64 response decoding, stream-event parsing, stream/connect iterators. - tests/test_integration_advanced.py: live tests for cwd/env handling, long-running commands (apt-get), PTY sessions, streaming exec, process connect, and git workflows including cloning wrennhq/wrenn. - test_filesystem_pty.py: PTY ping/pong reply tests. - test_integration.py: poll for async process-registry prune in test_kill_process instead of asserting on a zero-delay list(). Fixes: - commands.py / pty.py: stream(), connect() and the PTY iterators only caught WebSocketDisconnect. The server closes exec/process streams abruptly, raising WebSocketNetworkError — a sibling under HTTPXWSException — which crashed connect() entirely. Both are now caught via _WS_CLOSED so abrupt closes end iteration cleanly. - pty.py: reply to the server keepalive ping with a pong so idle PTY sessions stay open. --- src/wrenn/commands.py | 13 +- src/wrenn/pty.py | 26 +- tests/test_commands.py | 490 ++++++++++++++++++++++++++++ tests/test_filesystem_pty.py | 55 ++++ tests/test_integration.py | 20 +- tests/test_integration_advanced.py | 499 +++++++++++++++++++++++++++++ 6 files changed, 1085 insertions(+), 18 deletions(-) create mode 100644 tests/test_commands.py create mode 100644 tests/test_integration_advanced.py diff --git a/src/wrenn/commands.py b/src/wrenn/commands.py index 98b596e..2ad4957 100644 --- a/src/wrenn/commands.py +++ b/src/wrenn/commands.py @@ -12,6 +12,11 @@ import httpx_ws from wrenn.exceptions import handle_response +# Both signal a terminated WebSocket: ``WebSocketDisconnect`` is a clean close, +# ``WebSocketNetworkError`` an abrupt one. The Wrenn server closes exec/process +# streams abruptly, so iterators must treat either as end-of-stream. +_WS_CLOSED = (httpx_ws.WebSocketDisconnect, httpx_ws.WebSocketNetworkError) + @dataclass class CommandResult: @@ -271,7 +276,7 @@ class Commands: yield event if event.type in ("exit", "error"): break - except httpx_ws.WebSocketDisconnect: + except _WS_CLOSED: break def stream( @@ -306,7 +311,7 @@ class Commands: yield event if event.type in ("exit", "error"): break - except httpx_ws.WebSocketDisconnect: + except _WS_CLOSED: break @@ -462,7 +467,7 @@ class AsyncCommands: yield event if event.type in ("exit", "error"): break - except httpx_ws.WebSocketDisconnect: + except _WS_CLOSED: pass async def stream( @@ -497,5 +502,5 @@ class AsyncCommands: yield event if event.type in ("exit", "error"): break - except httpx_ws.WebSocketDisconnect: + except _WS_CLOSED: pass diff --git a/src/wrenn/pty.py b/src/wrenn/pty.py index c116f2a..63dd26f 100644 --- a/src/wrenn/pty.py +++ b/src/wrenn/pty.py @@ -9,6 +9,10 @@ from typing import Any import httpx_ws from pydantic import BaseModel +# A clean (``WebSocketDisconnect``) or abrupt (``WebSocketNetworkError``) close +# both mean the PTY stream has ended; iteration must stop on either. +_WS_CLOSED = (httpx_ws.WebSocketDisconnect, httpx_ws.WebSocketNetworkError) + class PtyEventType(StrEnum): started = "started" @@ -109,6 +113,13 @@ class PtySession: def _send_connect(self, tag: str) -> None: self._ws.send_text(json.dumps({"type": "connect", "tag": tag})) + def _send_pong(self) -> None: + """Reply to a server keepalive ``ping`` so the session stays open.""" + try: + self._ws.send_text(json.dumps({"type": "pong"})) + except _WS_CLOSED: + pass + def write(self, data: bytes) -> None: """Send raw bytes to the PTY stdin. @@ -144,7 +155,7 @@ class PtySession: raise StopIteration try: raw = self._ws.receive_text() - except httpx_ws.WebSocketDisconnect: + except _WS_CLOSED: raise StopIteration event = _parse_pty_event(json.loads(raw)) if event.type == PtyEventType.started: @@ -152,6 +163,8 @@ class PtySession: self._tag = event.tag if event.pid is not None: self._pid = event.pid + if event.type == PtyEventType.ping: + self._send_pong() if event.type == PtyEventType.exit: self._done = True return event @@ -236,6 +249,13 @@ class AsyncPtySession: async def _send_connect(self, tag: str) -> None: await self._ws.send_text(json.dumps({"type": "connect", "tag": tag})) + async def _send_pong(self) -> None: + """Reply to a server keepalive ``ping`` so the session stays open.""" + try: + await self._ws.send_text(json.dumps({"type": "pong"})) + except _WS_CLOSED: + pass + async def write(self, data: bytes) -> None: """Send raw bytes to the PTY stdin. @@ -273,7 +293,7 @@ class AsyncPtySession: raise StopAsyncIteration try: raw = await self._ws.receive_text() - except httpx_ws.WebSocketDisconnect: + except _WS_CLOSED: raise StopAsyncIteration event = _parse_pty_event(json.loads(raw)) if event.type == PtyEventType.started: @@ -281,6 +301,8 @@ class AsyncPtySession: self._tag = event.tag if event.pid is not None: self._pid = event.pid + if event.type == PtyEventType.ping: + await self._send_pong() if event.type == PtyEventType.exit: self._done = True return event diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..d2d304d --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,490 @@ +"""Unit tests for wrenn.commands — Commands / AsyncCommands. + +Covers payload construction (cwd, envs, tag, timeout), foreground/background +dispatch, base64 response decoding, stream-event parsing, and the +WebSocket-backed ``stream`` / ``connect`` iterators (with a fake WS). +""" + +from __future__ import annotations + +import base64 +import json +from contextlib import asynccontextmanager, contextmanager + +import httpx_ws +import pytest +import respx + +from wrenn.client import AsyncWrennClient, WrennClient +from wrenn.commands import ( + AsyncCommands, + CommandHandle, + CommandResult, + Commands, + ProcessInfo, + StreamErrorEvent, + StreamEvent, + StreamExitEvent, + StreamStartEvent, + StreamStderrEvent, + StreamStdoutEvent, + _decode_exec_response, + _parse_stream_event, +) + +BASE = "https://app.wrenn.dev/api" +CAPSULE_ID = "cl-cmd123" +EXEC_URL = f"{BASE}/v1/capsules/{CAPSULE_ID}/exec" +PROC_URL = f"{BASE}/v1/capsules/{CAPSULE_ID}/processes" + + +def _make_commands() -> Commands: + client = WrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) + return Commands(CAPSULE_ID, client.http) + + +def _make_async_commands() -> AsyncCommands: + client = AsyncWrennClient(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) + return AsyncCommands(CAPSULE_ID, client.http) + + +# ── _decode_exec_response ───────────────────────────────────────── + + +class TestDecodeExecResponse: + def test_plain_text(self): + result = _decode_exec_response( + {"stdout": "hello\n", "stderr": "", "exit_code": 0, "duration_ms": 12} + ) + assert isinstance(result, CommandResult) + assert result.stdout == "hello\n" + assert result.exit_code == 0 + assert result.duration_ms == 12 + + def test_base64_stdout(self): + encoded = base64.b64encode(b"binary\xff\x00out").decode() + result = _decode_exec_response( + {"stdout": encoded, "encoding": "base64", "exit_code": 0} + ) + assert "binary" in result.stdout + + def test_base64_stderr(self): + out = base64.b64encode(b"ok").decode() + err = base64.b64encode(b"warning").decode() + result = _decode_exec_response( + {"stdout": out, "stderr": err, "encoding": "base64", "exit_code": 1} + ) + assert result.stdout == "ok" + assert result.stderr == "warning" + assert result.exit_code == 1 + + def test_missing_fields_default(self): + result = _decode_exec_response({}) + assert result.stdout == "" + assert result.stderr == "" + assert result.exit_code == -1 + assert result.duration_ms is None + + def test_null_stdout_coerced_to_empty(self): + result = _decode_exec_response({"stdout": None, "stderr": None}) + assert result.stdout == "" + assert result.stderr == "" + + +# ── _parse_stream_event ─────────────────────────────────────────── + + +class TestParseStreamEvent: + def test_start(self): + event = _parse_stream_event({"type": "start", "pid": 99}) + assert isinstance(event, StreamStartEvent) + assert event.type == "start" + assert event.pid == 99 + + def test_stdout(self): + event = _parse_stream_event({"type": "stdout", "data": "out"}) + assert isinstance(event, StreamStdoutEvent) + assert event.data == "out" + + def test_stderr(self): + event = _parse_stream_event({"type": "stderr", "data": "err"}) + assert isinstance(event, StreamStderrEvent) + assert event.data == "err" + + def test_exit(self): + event = _parse_stream_event({"type": "exit", "exit_code": 7}) + assert isinstance(event, StreamExitEvent) + assert event.exit_code == 7 + + def test_error(self): + event = _parse_stream_event({"type": "error", "data": "boom"}) + assert isinstance(event, StreamErrorEvent) + assert event.data == "boom" + + def test_unknown_type(self): + event = _parse_stream_event({"type": "weird"}) + assert isinstance(event, StreamEvent) + assert event.type == "weird" + + def test_missing_type(self): + event = _parse_stream_event({}) + assert event.type == "unknown" + + def test_exit_missing_code_defaults(self): + event = _parse_stream_event({"type": "exit"}) + assert isinstance(event, StreamExitEvent) + assert event.exit_code == -1 + + +# ── Commands.run — payload construction ─────────────────────────── + + +class TestRunPayload: + @respx.mock + def test_foreground_basic_payload(self): + route = respx.post(EXEC_URL).respond(200, json={"stdout": "hi", "exit_code": 0}) + result = _make_commands().run("echo hi") + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"] == ["-c", "echo hi"] + assert body["background"] is False + assert body["timeout_sec"] == 30 + assert result.stdout == "hi" + + @respx.mock + def test_cwd_in_payload(self): + route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0}) + _make_commands().run("pwd", cwd="/tmp/work") + body = json.loads(route.calls[0].request.content) + assert body["cwd"] == "/tmp/work" + + @respx.mock + def test_cwd_omitted_when_none(self): + route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0}) + _make_commands().run("pwd") + body = json.loads(route.calls[0].request.content) + assert "cwd" not in body + + @respx.mock + def test_envs_in_payload(self): + route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0}) + _make_commands().run("env", envs={"FOO": "bar", "BAZ": "qux"}) + body = json.loads(route.calls[0].request.content) + assert body["envs"] == {"FOO": "bar", "BAZ": "qux"} + + @respx.mock + def test_empty_envs_still_sent(self): + route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0}) + _make_commands().run("env", envs={}) + body = json.loads(route.calls[0].request.content) + assert body["envs"] == {} + + @respx.mock + def test_tag_in_payload(self): + route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0}) + _make_commands().run("echo x", tag="my-tag") + body = json.loads(route.calls[0].request.content) + assert body["tag"] == "my-tag" + + @respx.mock + def test_custom_timeout_in_payload(self): + route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0}) + _make_commands().run("sleep 1", timeout=120) + body = json.loads(route.calls[0].request.content) + assert body["timeout_sec"] == 120 + + @respx.mock + def test_timeout_none_omits_field(self): + route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0}) + _make_commands().run("echo x", timeout=None) + body = json.loads(route.calls[0].request.content) + assert "timeout_sec" not in body + + @respx.mock + def test_all_kwargs_combined(self): + route = respx.post(EXEC_URL).respond(200, json={"exit_code": 0}) + _make_commands().run("echo x", timeout=60, envs={"A": "1"}, cwd="/srv", tag="t") + body = json.loads(route.calls[0].request.content) + assert body["cwd"] == "/srv" + assert body["envs"] == {"A": "1"} + assert body["tag"] == "t" + assert body["timeout_sec"] == 60 + + +class TestRunBackground: + @respx.mock + def test_background_returns_handle(self): + respx.post(EXEC_URL).respond(200, json={"pid": 1234, "tag": "bg"}) + handle = _make_commands().run("sleep 100", background=True) + assert isinstance(handle, CommandHandle) + assert handle.pid == 1234 + assert handle.tag == "bg" + assert handle.capsule_id == CAPSULE_ID + + @respx.mock + def test_background_omits_timeout_sec(self): + route = respx.post(EXEC_URL).respond(200, json={"pid": 1, "tag": "x"}) + _make_commands().run("sleep 100", background=True, timeout=30) + body = json.loads(route.calls[0].request.content) + assert "timeout_sec" not in body + assert body["background"] is True + + @respx.mock + def test_background_carries_cwd_and_envs(self): + route = respx.post(EXEC_URL).respond(200, json={"pid": 5, "tag": "t"}) + _make_commands().run( + "server", background=True, cwd="/app", envs={"PORT": "80"}, tag="srv" + ) + body = json.loads(route.calls[0].request.content) + assert body["cwd"] == "/app" + assert body["envs"] == {"PORT": "80"} + assert body["tag"] == "srv" + + @respx.mock + def test_background_missing_pid_defaults_zero(self): + respx.post(EXEC_URL).respond(200, json={"tag": "x"}) + handle = _make_commands().run("x", background=True) + assert handle.pid == 0 + + +class TestListAndKill: + @respx.mock + def test_list_parses_processes(self): + respx.get(PROC_URL).respond( + 200, + json={ + "processes": [ + { + "pid": 10, + "tag": "web", + "cmd": "/bin/sh", + "args": ["-c", "serve"], + }, + {"pid": 11}, + ] + }, + ) + procs = _make_commands().list() + assert len(procs) == 2 + assert isinstance(procs[0], ProcessInfo) + assert procs[0].pid == 10 + assert procs[0].tag == "web" + assert procs[0].args == ["-c", "serve"] + assert procs[1].pid == 11 + assert procs[1].tag is None + + @respx.mock + def test_list_empty(self): + respx.get(PROC_URL).respond(200, json={"processes": []}) + assert _make_commands().list() == [] + + @respx.mock + def test_list_missing_key(self): + respx.get(PROC_URL).respond(200, json={}) + assert _make_commands().list() == [] + + @respx.mock + def test_kill_sends_delete(self): + route = respx.delete(f"{PROC_URL}/42").respond(204) + _make_commands().kill(42) + assert route.called + + @respx.mock + def test_kill_unknown_pid_raises(self): + from wrenn.exceptions import WrennNotFoundError + + respx.delete(f"{PROC_URL}/999").respond( + 404, json={"error": {"code": "not_found", "message": "no such process"}} + ) + with pytest.raises(WrennNotFoundError): + _make_commands().kill(999) + + +# ── Fake WebSocket plumbing for stream / connect ────────────────── + + +class _FakeWS: + """Synchronous fake WebSocket session.""" + + def __init__(self, messages: list) -> None: + self._messages = list(messages) + self.sent: list[str] = [] + + def send_text(self, text: str) -> None: + self.sent.append(text) + + def receive_json(self) -> dict: + if not self._messages: + raise httpx_ws.WebSocketDisconnect() + msg = self._messages.pop(0) + if isinstance(msg, Exception): + raise msg + return msg + + +class _AsyncFakeWS: + """Asynchronous fake WebSocket session.""" + + def __init__(self, messages: list) -> None: + self._messages = list(messages) + self.sent: list[str] = [] + + async def send_text(self, text: str) -> None: + self.sent.append(text) + + async def receive_json(self) -> dict: + if not self._messages: + raise httpx_ws.WebSocketDisconnect() + msg = self._messages.pop(0) + if isinstance(msg, Exception): + raise msg + return msg + + +def _patch_sync_ws(monkeypatch, ws: _FakeWS) -> None: + @contextmanager + def _fake_connect(url, client): + yield ws + + monkeypatch.setattr("wrenn.commands.httpx_ws.connect_ws", _fake_connect) + + +def _patch_async_ws(monkeypatch, ws: _AsyncFakeWS) -> None: + @asynccontextmanager + async def _fake_aconnect(url, client): + yield ws + + monkeypatch.setattr("wrenn.commands.httpx_ws.aconnect_ws", _fake_aconnect) + + +# ── Commands.stream ─────────────────────────────────────────────── + + +class TestStream: + def test_stream_sends_shell_wrapped_start(self, monkeypatch): + ws = _FakeWS([{"type": "exit", "exit_code": 0}]) + _patch_sync_ws(monkeypatch, ws) + list(_make_commands().stream("echo hi")) + start = json.loads(ws.sent[0]) + assert start == {"type": "start", "cmd": "/bin/sh", "args": ["-c", "echo hi"]} + + def test_stream_with_explicit_args(self, monkeypatch): + ws = _FakeWS([{"type": "exit", "exit_code": 0}]) + _patch_sync_ws(monkeypatch, ws) + list(_make_commands().stream("/usr/bin/env", args=["python", "-V"])) + start = json.loads(ws.sent[0]) + assert start == { + "type": "start", + "cmd": "/usr/bin/env", + "args": ["python", "-V"], + } + + def test_stream_yields_events_until_exit(self, monkeypatch): + ws = _FakeWS( + [ + {"type": "start", "pid": 3}, + {"type": "stdout", "data": "line1"}, + {"type": "stderr", "data": "warn"}, + {"type": "exit", "exit_code": 0}, + {"type": "stdout", "data": "after-exit-ignored"}, + ] + ) + _patch_sync_ws(monkeypatch, ws) + events = list(_make_commands().stream("echo line1")) + assert [e.type for e in events] == ["start", "stdout", "stderr", "exit"] + + def test_stream_stops_on_error(self, monkeypatch): + ws = _FakeWS([{"type": "error", "data": "fatal"}]) + _patch_sync_ws(monkeypatch, ws) + events = list(_make_commands().stream("bad")) + assert len(events) == 1 + assert events[0].type == "error" + + def test_stream_handles_disconnect(self, monkeypatch): + ws = _FakeWS([{"type": "stdout", "data": "x"}]) # then disconnect + _patch_sync_ws(monkeypatch, ws) + events = list(_make_commands().stream("echo x")) + assert [e.type for e in events] == ["stdout"] + + +# ── Commands.connect ────────────────────────────────────────────── + + +class TestConnect: + def test_connect_yields_until_exit(self, monkeypatch): + ws = _FakeWS( + [ + {"type": "stdout", "data": "tick"}, + {"type": "exit", "exit_code": 0}, + ] + ) + _patch_sync_ws(monkeypatch, ws) + events = list(_make_commands().connect(55)) + assert [e.type for e in events] == ["stdout", "exit"] + + def test_connect_handles_disconnect(self, monkeypatch): + ws = _FakeWS([]) # immediate disconnect + _patch_sync_ws(monkeypatch, ws) + assert list(_make_commands().connect(1)) == [] + + +# ── AsyncCommands ───────────────────────────────────────────────── + + +class TestAsyncCommands: + @pytest.mark.asyncio + @respx.mock + async def test_async_run_payload(self): + route = respx.post(EXEC_URL).respond(200, json={"stdout": "hi", "exit_code": 0}) + cmds = _make_async_commands() + result = await cmds.run("echo hi", cwd="/tmp", envs={"K": "v"}, tag="z") + body = json.loads(route.calls[0].request.content) + assert body["cwd"] == "/tmp" + assert body["envs"] == {"K": "v"} + assert body["tag"] == "z" + assert result.stdout == "hi" + + @pytest.mark.asyncio + @respx.mock + async def test_async_run_background(self): + respx.post(EXEC_URL).respond(200, json={"pid": 7, "tag": "bg"}) + handle = await _make_async_commands().run("sleep 1", background=True) + assert isinstance(handle, CommandHandle) + assert handle.pid == 7 + + @pytest.mark.asyncio + @respx.mock + async def test_async_list(self): + respx.get(PROC_URL).respond(200, json={"processes": [{"pid": 1, "tag": "a"}]}) + procs = await _make_async_commands().list() + assert len(procs) == 1 + assert procs[0].pid == 1 + + @pytest.mark.asyncio + @respx.mock + async def test_async_kill(self): + route = respx.delete(f"{PROC_URL}/3").respond(204) + await _make_async_commands().kill(3) + assert route.called + + @pytest.mark.asyncio + async def test_async_stream(self, monkeypatch): + ws = _AsyncFakeWS( + [ + {"type": "start", "pid": 1}, + {"type": "stdout", "data": "out"}, + {"type": "exit", "exit_code": 0}, + ] + ) + _patch_async_ws(monkeypatch, ws) + events = [e async for e in _make_async_commands().stream("echo out")] + assert [e.type for e in events] == ["start", "stdout", "exit"] + start = json.loads(ws.sent[0]) + assert start["cmd"] == "/bin/sh" + + @pytest.mark.asyncio + async def test_async_connect(self, monkeypatch): + ws = _AsyncFakeWS([{"type": "exit", "exit_code": 0}]) + _patch_async_ws(monkeypatch, ws) + events = [e async for e in _make_async_commands().connect(9)] + assert [e.type for e in events] == ["exit"] diff --git a/tests/test_filesystem_pty.py b/tests/test_filesystem_pty.py index 7de58e6..2ce3f40 100644 --- a/tests/test_filesystem_pty.py +++ b/tests/test_filesystem_pty.py @@ -341,6 +341,39 @@ class TestPtySessionIteration: assert events == [] +class TestPtySessionPong: + def test_ping_triggers_pong(self): + ws = MagicMock() + ws.receive_text.side_effect = [ + json.dumps({"type": "ping"}), + json.dumps({"type": "exit", "exit_code": 0}), + ] + session = PtySession(ws, "cl-abc") + events = list(session) + assert events[0].type == PtyEventType.ping + sent = [json.loads(c[0][0]) for c in ws.send_text.call_args_list] + assert {"type": "pong"} in sent + + def test_no_pong_without_ping(self): + ws = MagicMock() + ws.receive_text.side_effect = [ + json.dumps({"type": "output", "data": ""}), + json.dumps({"type": "exit", "exit_code": 0}), + ] + session = PtySession(ws, "cl-abc") + list(session) + sent = [json.loads(c[0][0]) for c in ws.send_text.call_args_list] + assert {"type": "pong"} not in sent + + def test_send_pong_swallows_closed_ws(self): + import httpx_ws + + ws = MagicMock() + ws.send_text.side_effect = httpx_ws.WebSocketNetworkError() + session = PtySession(ws, "cl-abc") + session._send_pong() # must not raise + + class TestPtySessionContextManager: def test_exit_kills_and_closes(self): ws = MagicMock() @@ -450,6 +483,28 @@ class TestAsyncPtySession: assert sent["cmd"] == "/bin/zsh" assert sent["cols"] == 100 + @pytest.mark.asyncio + async def test_async_ping_triggers_pong(self): + ws = AsyncMock() + ws.receive_text.side_effect = [ + json.dumps({"type": "ping"}), + json.dumps({"type": "exit", "exit_code": 0}), + ] + session = AsyncPtySession(ws, "cl-abc") + events = [e async for e in session] + assert events[0].type == PtyEventType.ping + sent = [json.loads(c[0][0]) for c in ws.send_text.call_args_list] + assert {"type": "pong"} in sent + + @pytest.mark.asyncio + async def test_async_send_pong_swallows_closed_ws(self): + import httpx_ws + + ws = AsyncMock() + ws.send_text.side_effect = httpx_ws.WebSocketNetworkError() + session = AsyncPtySession(ws, "cl-abc") + await session._send_pong() # must not raise + @pytest.mark.asyncio async def test_async_iteration(self): ws = AsyncMock() diff --git a/tests/test_integration.py b/tests/test_integration.py index 23c10cd..49eaab7 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -15,17 +15,6 @@ pytestmark = pytest.mark.integration _env_loaded = False -def _wait_for_pid_dead(capsule: Capsule, pid: int, timeout: float = 5.0) -> bool: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - result = capsule.commands.run(f"ps -p {pid} -o stat= 2>/dev/null || true") - state = result.stdout.strip() - if not state or state.startswith("Z"): - return True - time.sleep(0.2) - return False - - def _ensure_env() -> None: global _env_loaded if _env_loaded: @@ -229,7 +218,14 @@ class TestCommands: def test_kill_process(self): handle = self.capsule.commands.run("sleep 30", background=True) self.capsule.commands.kill(handle.pid) - assert _wait_for_pid_dead(self.capsule, handle.pid) + # Registry prune runs asynchronously after the process end event, + # so poll rather than asserting on a zero-delay list(). + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if handle.pid not in [p.pid for p in self.capsule.commands.list()]: + break + time.sleep(0.2) + assert handle.pid not in [p.pid for p in self.capsule.commands.list()] def test_run_duration_ms(self): result = self.capsule.commands.run("sleep 1") diff --git a/tests/test_integration_advanced.py b/tests/test_integration_advanced.py new file mode 100644 index 0000000..3f5e343 --- /dev/null +++ b/tests/test_integration_advanced.py @@ -0,0 +1,499 @@ +"""Advanced integration tests against a live Wrenn server. + +Skipped automatically when ``WRENN_API_KEY`` is not set (see conftest.py). + +Covers working-directory / environment handling, long-running commands +(``apt-get``), interactive PTY sessions, streaming exec, and real ``git`` +workflows including cloning ``github.com/wrennhq/wrenn``. +""" + +from __future__ import annotations + +import os +import time +import uuid +from pathlib import Path + +import pytest + +from wrenn import Capsule +from wrenn.commands import StreamExitEvent, StreamStartEvent +from wrenn.exceptions import WrennError +from wrenn.pty import PtyEventType + +pytestmark = pytest.mark.integration + +WRENN_REPO = "https://github.com/wrennhq/wrenn" + +_env_loaded = False + + +def _ensure_env() -> None: + global _env_loaded + if _env_loaded: + return + _env_loaded = True + env_file = Path(__file__).resolve().parent.parent / ".env" + if not env_file.exists(): + return + for line in env_file.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key, value = key.strip(), value.strip().strip("\"'") + if key and key not in os.environ: + os.environ[key] = value + + +# ══════════════════════════════════════════════════════════════════ +# Working directory & environment +# ══════════════════════════════════════════════════════════════════ + + +class TestCommandEnvironment: + """cwd / envs handling for foreground commands.""" + + capsule: Capsule + + @classmethod + def setup_class(cls): + _ensure_env() + cls.capsule = Capsule(wait=True) + + @classmethod + def teardown_class(cls): + try: + cls.capsule.destroy() + except Exception: + pass + + def test_cwd_changes_working_directory(self): + result = self.capsule.commands.run("pwd", cwd="/tmp") + assert result.exit_code == 0 + assert result.stdout.strip() == "/tmp" + + def test_default_cwd_is_home(self): + result = self.capsule.commands.run("pwd") + assert result.stdout.strip() == "/root" + + def test_cwd_resolves_relative_paths(self): + self.capsule.files.make_dir("/tmp/cwd_probe/sub") + result = self.capsule.commands.run("ls", cwd="/tmp/cwd_probe") + assert "sub" in result.stdout + + def test_cwd_nonexistent_raises(self): + with pytest.raises(WrennError): + self.capsule.commands.run("pwd", cwd="/no/such/dir/xyz") + + def test_cwd_does_not_persist_between_calls(self): + # Each run is a fresh process — `cd` in one does not affect the next. + self.capsule.commands.run("cd /tmp") + result = self.capsule.commands.run("pwd") + assert result.stdout.strip() == "/root" + + def test_single_env_var(self): + result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"}) + assert result.stdout.strip() == "hi" + + def test_multiple_env_vars(self): + result = self.capsule.commands.run( + "echo $A-$B-$C", envs={"A": "1", "B": "2", "C": "3"} + ) + assert result.stdout.strip() == "1-2-3" + + def test_env_vars_do_not_leak_between_calls(self): + self.capsule.commands.run("echo $SECRET", envs={"SECRET": "leaky"}) + result = self.capsule.commands.run("echo [$SECRET]") + assert result.stdout.strip() == "[]" + + def test_env_var_with_special_chars(self): + value = "a b&c|d;e" + result = self.capsule.commands.run('printf "%s" "$X"', envs={"X": value}) + assert result.stdout == value + + def test_base_environment_present(self): + result = self.capsule.commands.run("echo $HOME; echo $PATH") + lines = result.stdout.strip().splitlines() + assert lines[0] == "/root" + assert "/usr/bin" in lines[1] + + +# ══════════════════════════════════════════════════════════════════ +# Long-running commands +# ══════════════════════════════════════════════════════════════════ + + +class TestLongRunningCommands: + """apt-get installs and other slow commands.""" + + capsule: Capsule + + @classmethod + def setup_class(cls): + _ensure_env() + cls.capsule = Capsule(wait=True) + + @classmethod + def teardown_class(cls): + try: + cls.capsule.destroy() + except Exception: + pass + + def test_apt_get_install(self): + result = self.capsule.commands.run( + "apt-get update -qq && apt-get install -y -qq cowsay", timeout=300 + ) + assert result.exit_code == 0 + + def test_apt_installed_binary_runs(self): + # Depends on test_apt_get_install having installed the package. + self.capsule.commands.run("apt-get install -y -qq cowsay", timeout=300) + result = self.capsule.commands.run("/usr/games/cowsay moo") + assert result.exit_code == 0 + assert "moo" in result.stdout + + def test_foreground_timeout_raises(self): + # A command exceeding its timeout surfaces as a server-side error. + with pytest.raises(WrennError): + self.capsule.commands.run("sleep 20", timeout=2) + + def test_long_sleep_in_background_returns_immediately(self): + start = time.monotonic() + handle = self.capsule.commands.run( + "sleep 60", background=True, tag="long-sleep" + ) + elapsed = time.monotonic() - start + assert elapsed < 10 + assert handle.pid > 0 + self.capsule.commands.kill(handle.pid) + + def test_slow_command_within_timeout(self): + result = self.capsule.commands.run("sleep 3 && echo done", timeout=30) + assert result.exit_code == 0 + assert result.stdout.strip() == "done" + + +# ══════════════════════════════════════════════════════════════════ +# PTY sessions +# ══════════════════════════════════════════════════════════════════ + + +def _drain_pty(term, *, max_events: int = 200) -> tuple[bytes, int | None]: + """Collect PTY output until exit; return (output, exit_code).""" + output = b"" + exit_code: int | None = None + for i, event in enumerate(term): + if event.type == PtyEventType.output and event.data: + output += event.data + elif event.type == PtyEventType.exit: + exit_code = event.exit_code + break + elif event.type == PtyEventType.error and event.fatal: + break + if i >= max_events: + break + return output, exit_code + + +class TestPty: + """Interactive PTY behaviour.""" + + capsule: Capsule + + @classmethod + def setup_class(cls): + _ensure_env() + cls.capsule = Capsule(wait=True) + + @classmethod + def teardown_class(cls): + try: + cls.capsule.destroy() + except Exception: + pass + + def test_pty_runs_command_and_exits(self): + with self.capsule.pty(cmd="/bin/bash") as term: + term.write(b"echo pty-result-$((6*7))\n") + term.write(b"exit\n") + output, exit_code = _drain_pty(term) + assert b"pty-result-42" in output + assert exit_code is not None + + def test_pty_started_event_sets_tag_and_pid(self): + with self.capsule.pty(cmd="/bin/bash") as term: + term.write(b"exit\n") + _drain_pty(term) + assert term.tag is not None + assert term.tag.startswith("pty-") + assert term.pid is not None and term.pid > 0 + + def test_pty_respects_cwd(self): + with self.capsule.pty(cmd="/bin/bash", cwd="/tmp") as term: + term.write(b"pwd\n") + term.write(b"exit\n") + output, _ = _drain_pty(term) + assert b"/tmp" in output + + def test_pty_respects_envs(self): + with self.capsule.pty(cmd="/bin/bash", envs={"PTY_VAR": "xyzzy"}) as term: + term.write(b"echo marker-$PTY_VAR\n") + term.write(b"exit\n") + output, _ = _drain_pty(term) + assert b"marker-xyzzy" in output + + def test_pty_resize(self): + with self.capsule.pty(cmd="/bin/bash", cols=80, rows=24) as term: + term.resize(120, 40) + term.write(b"echo resized\n") + term.write(b"exit\n") + output, _ = _drain_pty(term) + assert b"resized" in output + + def test_pty_explicit_command(self): + with self.capsule.pty(cmd="/bin/echo", args=["hello-from-argv"]) as term: + output, exit_code = _drain_pty(term) + assert b"hello-from-argv" in output + + def test_pty_exit_code_nonzero(self): + with self.capsule.pty(cmd="/bin/bash") as term: + term.write(b"exit 3\n") + _, exit_code = _drain_pty(term) + assert exit_code == 3 + + def test_pty_survives_idle_ping_cycle(self): + # The server emits a keepalive `ping` (~every 30s); the SDK must + # auto-reply `pong` and the session must stay usable afterwards. + with self.capsule.pty(cmd="/bin/bash") as term: + saw_ping = False + for event in term: + if event.type == PtyEventType.ping: + saw_ping = True + break + if event.type == PtyEventType.exit: + break + if event.type == PtyEventType.error and event.fatal: + break + assert saw_ping, "no keepalive ping received" + term.write(b"echo still-alive\n") + term.write(b"exit\n") + output, _ = _drain_pty(term) + assert b"still-alive" in output + + +# ══════════════════════════════════════════════════════════════════ +# Streaming exec +# ══════════════════════════════════════════════════════════════════ + + +class TestStreamingExec: + capsule: Capsule + + @classmethod + def setup_class(cls): + _ensure_env() + cls.capsule = Capsule(wait=True) + + @classmethod + def teardown_class(cls): + try: + cls.capsule.destroy() + except Exception: + pass + + def test_stream_emits_start_and_exit(self): + events = list(self.capsule.commands.stream("echo streamed")) + types = [e.type for e in events] + assert "exit" in types + starts = [e for e in events if isinstance(e, StreamStartEvent)] + exits = [e for e in events if isinstance(e, StreamExitEvent)] + assert exits and exits[0].exit_code == 0 + if starts: + assert starts[0].pid > 0 + + def test_stream_captures_stdout(self): + events = list(self.capsule.commands.stream("for i in 1 2 3; do echo n$i; done")) + out = "".join( + e.data for e in events if e.type == "stdout" and getattr(e, "data", None) + ) + assert "n1" in out and "n3" in out + + def test_stream_nonzero_exit(self): + events = list(self.capsule.commands.stream("exit 5")) + exits = [e for e in events if isinstance(e, StreamExitEvent)] + assert exits and exits[0].exit_code == 5 + + +# ══════════════════════════════════════════════════════════════════ +# Process connect — attach to a background process over WebSocket +# ══════════════════════════════════════════════════════════════════ + + +class TestProcessConnect: + """commands.connect — must survive the server's abrupt WebSocket close.""" + + capsule: Capsule + + @classmethod + def setup_class(cls): + _ensure_env() + cls.capsule = Capsule(wait=True) + + @classmethod + def teardown_class(cls): + try: + cls.capsule.destroy() + except Exception: + pass + + def test_connect_streams_running_process(self): + handle = self.capsule.commands.run( + "for i in $(seq 1 5); do echo tick$i; sleep 1; done", + background=True, + tag="connect-run", + ) + time.sleep(0.3) + events = list(self.capsule.commands.connect(handle.pid)) + types = [e.type for e in events] + assert "exit" in types + # connect streams output from the attach point onward, so early + # ticks may be missed — assert it captured the live tail. + out = "".join( + e.data for e in events if e.type == "stdout" and getattr(e, "data", None) + ) + assert "tick" in out + + def test_connect_to_finished_process_does_not_raise(self): + handle = self.capsule.commands.run("echo quick", background=True) + time.sleep(2) + # Process already exited — server closes the WebSocket abruptly; + # the iterator must terminate cleanly rather than raise. + events = list(self.capsule.commands.connect(handle.pid)) + assert isinstance(events, list) + + +# ══════════════════════════════════════════════════════════════════ +# Git — real workflows including cloning wrennhq/wrenn +# ══════════════════════════════════════════════════════════════════ + + +class TestGitClone: + """Clone github.com/wrennhq/wrenn and operate on it.""" + + capsule: Capsule + + @classmethod + def setup_class(cls): + _ensure_env() + cls.capsule = Capsule(wait=True) + cls.capsule.git.clone(WRENN_REPO, "/root/wrenn", depth=1, timeout=300) + + @classmethod + def teardown_class(cls): + try: + cls.capsule.destroy() + except Exception: + pass + + def test_clone_created_repo(self): + assert self.capsule.files.exists("/root/wrenn/.git") + + def test_clone_checked_out_files(self): + entries = self.capsule.files.list("/root/wrenn") + names = [e.name for e in entries] + assert "README.md" in names + + def test_status_of_clone_is_clean(self): + status = self.capsule.git.status(cwd="/root/wrenn") + assert status.branch == "main" + assert status.is_clean + + def test_branches_lists_main(self): + branches = self.capsule.git.branches(cwd="/root/wrenn") + names = [b.name for b in branches] + assert "main" in names + assert any(b.is_current for b in branches) + + def test_remote_get_origin(self): + url = self.capsule.git.remote_get("origin", cwd="/root/wrenn") + assert url is not None + assert "wrennhq/wrenn" in url + + def test_git_log_has_commit(self): + result = self.capsule.commands.run("git log --oneline -1", cwd="/root/wrenn") + assert result.exit_code == 0 + assert result.stdout.strip() + + def test_modify_add_commit(self): + marker = uuid.uuid4().hex + self.capsule.git.configure_user( + "CI Bot", "ci@example.com", cwd="/root/wrenn", scope="local" + ) + self.capsule.files.write(f"/root/wrenn/sdk_probe_{marker}.txt", marker) + self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/root/wrenn") + + staged = self.capsule.git.status(cwd="/root/wrenn") + assert staged.has_staged + + result = self.capsule.git.commit("probe commit", cwd="/root/wrenn") + assert result.exit_code == 0 + + after = self.capsule.git.status(cwd="/root/wrenn") + assert after.is_clean + assert after.ahead >= 1 + + def test_create_and_checkout_branch_in_clone(self): + self.capsule.git.create_branch("sdk-feature", cwd="/root/wrenn") + branches = self.capsule.git.branches(cwd="/root/wrenn") + current = [b for b in branches if b.is_current] + assert current and current[0].name == "sdk-feature" + self.capsule.git.checkout_branch("main", cwd="/root/wrenn") + + def test_diff_via_commands(self): + self.capsule.files.write("/root/wrenn/README.md", "overwritten\n") + try: + result = self.capsule.commands.run("git diff --stat", cwd="/root/wrenn") + assert "README.md" in result.stdout + finally: + self.capsule.git.restore(["README.md"], worktree=True, cwd="/root/wrenn") + + +class TestGitErrors: + capsule: Capsule + + @classmethod + def setup_class(cls): + _ensure_env() + cls.capsule = Capsule(wait=True) + + @classmethod + def teardown_class(cls): + try: + cls.capsule.destroy() + except Exception: + pass + + def test_clone_nonexistent_repo_raises(self): + from wrenn._git import GitError + + with pytest.raises(GitError): + self.capsule.git.clone( + "https://github.com/wrennhq/this-repo-does-not-exist-xyz", + "/root/missing", + timeout=120, + ) + + def test_status_outside_repo_raises(self): + from wrenn._git import GitError + + with pytest.raises(GitError): + self.capsule.git.status(cwd="/tmp") + + def test_clone_with_branch(self): + self.capsule.git.clone( + WRENN_REPO, "/root/wrenn-main", branch="main", depth=1, timeout=300 + ) + status = self.capsule.git.status(cwd="/root/wrenn-main") + assert status.branch == "main" -- 2.49.0 From 369c75af24b73af313160802cd98912fcb27b560 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Tue, 19 May 2026 21:19:20 +0600 Subject: [PATCH 33/44] ci: run unit tests on every push Move per-step `when` filters: unit tests now run on every branch push, integration tests keep pull_request + main/dev branch restriction. Co-Authored-By: Claude Opus 4.7 (1M context) --- .woodpecker/check.yml | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/.woodpecker/check.yml b/.woodpecker/check.yml index 3b78cc7..6f7273b 100644 --- a/.woodpecker/check.yml +++ b/.woodpecker/check.yml @@ -1,21 +1,25 @@ -when: - event: pull_request - branch: - - main - - dev - path: - - "src/**" - - "tests/**" - steps: unit-tests: image: ghcr.io/astral-sh/uv:python3.13-bookworm + when: + event: push + path: + - "src/**" + - "tests/**" commands: - uv sync --dev - uv run pytest -m "not integration" -v integration-tests: image: ghcr.io/astral-sh/uv:python3.13-bookworm + when: + event: pull_request + branch: + - main + - dev + path: + - "src/**" + - "tests/**" environment: WRENN_API_KEY: from_secret: WRENN_API_KEY -- 2.49.0 From 9edde7bff52fa035a74c5ff66bbb859ccab89f1d Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 20 May 2026 04:29:31 +0600 Subject: [PATCH 34/44] feat(code_runner): rename module, fix __del__ + kernel name, expand tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `wrenn.code_interpreter` → `wrenn.code_runner` (canonical). Keep old path as deprecation alias that emits a FutureWarning on import, mirroring the existing `Sandbox` → `Capsule` pattern. Submodule shims `code_interpreter/{capsule,async_capsule,models}.py` keep direct-submodule imports working. - Fix sync/async ctor-failure-safe `__del__`: initialise `_kernel_id`, `_kernel_name`, `_proxy_client` before calling `super().__init__` so a failed creation no longer crashes the destructor with AttributeError. - Send the kernel name to Jupyter. Previously `POST /api/kernels` had no body, so the server picked an arbitrary default kernelspec. Now sends `{"name": "wrenn"}` (override via `Capsule(kernel=...)`) and reuses an existing kernel only when its `name` matches. - Preserve Jupyter `text/plain` verbatim in `Result.from_bundle`. The previous outer-quote strip was lossy (the string `'2'` became indistinguishable from the int `2`, and strings containing escaped quotes were mangled). `text` is now the `repr()` Jupyter sends. Updated the stale `test_capsule_features` quote-strip test. - Validate `run_code(language=...)`. Anything other than `"python"` now raises `ValueError` instead of being silently ignored. - Async `__del__` no longer touches the event loop; users must call `await close()` or use `async with`. - New unit suite `tests/test_code_runner_unit.py` (46 tests): MIME unpacking, deprecation alias + warning, default template + kernel, custom kernel override, ctor-failure-safe __del__, kernel create/reuse/cache, retry on 5xx, 4xx propagation, request shape, run_code stream/result/error/foreign-parent/idle/unsupported-language, async variants. - New e2e suite `tests/test_code_runner_e2e.py` (44 tests, integration marker): template == `code-runner-beta`, kernel == `wrenn`, stdout /stderr capture, state/import/function/class persistence, exceptions (Value/Name/Syntax), callbacks, multi-line, `text` repr preservation, filesystem round-trip, isolation between capsules, deprecated import path. MIME-type class covers html, markdown, json, latex, svg, javascript, png (matplotlib + seaborn), jpeg, multi-format bundles, and text-round-trip via numpy + requests. - `make test-code-runner` runs unit + e2e together. `make test` extended to include the unit file. - README: "Code Interpreter" section renamed to "Code Runner", all imports updated, `kernel=` documented, removed the incorrect "quotes stripped automatically" claim, replaced with the actual `text/plain` semantics. - CLAUDE.md: appended a "Code Runner Module" section covering module path, defaults, kernel-reuse semantics, lifecycle invariant, and the new test files + make target. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 36 ++ Makefile | 7 +- README.md | 38 +- src/wrenn/code_interpreter/__init__.py | 36 +- src/wrenn/code_interpreter/async_capsule.py | 293 +-------- src/wrenn/code_interpreter/capsule.py | 310 +--------- src/wrenn/code_interpreter/models.py | 162 +---- src/wrenn/code_runner/__init__.py | 51 ++ src/wrenn/code_runner/async_capsule.py | 298 +++++++++ src/wrenn/code_runner/capsule.py | 333 +++++++++++ src/wrenn/code_runner/models.py | 151 +++++ tests/test_capsule_features.py | 7 +- tests/test_code_runner_e2e.py | 538 +++++++++++++++++ tests/test_code_runner_unit.py | 632 ++++++++++++++++++++ 14 files changed, 2116 insertions(+), 776 deletions(-) create mode 100644 src/wrenn/code_runner/__init__.py create mode 100644 src/wrenn/code_runner/async_capsule.py create mode 100644 src/wrenn/code_runner/capsule.py create mode 100644 src/wrenn/code_runner/models.py create mode 100644 tests/test_code_runner_e2e.py create mode 100644 tests/test_code_runner_unit.py diff --git a/CLAUDE.md b/CLAUDE.md index 4aff987..417a565 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,3 +169,39 @@ Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need. 2. Use `detect_changes` for code review. 3. Use `get_affected_flows` to understand impact. 4. Use `query_graph` pattern="tests_for" to check coverage. + +## Code Runner Module + +`wrenn.code_runner` — stateful code execution capsule via persistent +Jupyter kernel. + +- **Module path:** `wrenn.code_runner` (canonical). The old path + `wrenn.code_interpreter` is a deprecation alias that emits a + `FutureWarning` on import; do not introduce new uses. +- **Defaults:** template `code-runner-beta`, kernelspec `wrenn`. + Both overridable via `Capsule(template=..., kernel=...)`. +- **Kernel reuse:** `_ensure_kernel` lists `/api/kernels`, reuses the + first kernel whose `name` matches the configured kernelspec, else + POSTs `{"name": }` to create one. Matching by name (not just + "any kernel") is intentional — multiple kernelspecs may coexist on + the same Jupyter. +- **Lifecycle invariant:** the constructor sets `_kernel_id`, + `_kernel_name`, `_proxy_client` to safe defaults *before* calling + `super().__init__`. `__del__` must never assume construction + completed. Async `__del__` only drops the reference — the proxy + `httpx.AsyncClient` must be closed via `await close()` or + `async with`. + +### Tests + +- `tests/test_code_runner_unit.py` — pure unit tests (respx + mocked + WebSocket). Covers `Result.from_bundle`, MIME unpacking, + quote-stripping, `Execution.text`, kernel reuse vs create, retry on + 5xx, 4xx propagation, ctor-failure-safe `__del__`, deprecation + alias. +- `tests/test_code_runner_e2e.py` — live integration tests (marked + `integration`, skipped without `WRENN_API_KEY`). Covers stateful + execution, exceptions, callbacks, rich outputs (HTML, matplotlib, + pandas), async variant, isolation between capsules, and the + deprecated `code_interpreter` import path. +- Run both: `make test-code-runner`. diff --git a/Makefile b/Makefile index 65b3a04..130c439 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Makefile -.PHONY: generate lint test check test-integration +.PHONY: generate lint test check test-integration test-code-runner # Variables SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml" @@ -30,11 +30,14 @@ lint: uv run ruff format --check src/ test: - uv run pytest tests/test_client.py -v + uv run pytest tests/test_client.py tests/test_code_runner_unit.py -v test-integration: uv run pytest tests/ -v -m "integration or not integration" +test-code-runner: + uv run pytest tests/test_code_runner_unit.py tests/test_code_runner_e2e.py -v -m "integration or not integration" + check: lint test gen-docs: diff --git a/README.md b/README.md index 787a4b9..e5f1f6f 100644 --- a/README.md +++ b/README.md @@ -84,10 +84,10 @@ capsule = Capsule.connect("cl-abc123") result = capsule.commands.run("echo still running") ``` -For code interpreter capsules: +For code runner capsules: ```python -from wrenn.code_interpreter import Capsule as CodeCapsule +from wrenn.code_runner import Capsule as CodeCapsule capsule = CodeCapsule.connect("cl-abc123") result = capsule.run_code("print('reconnected')") @@ -329,14 +329,16 @@ template = capsule.create_snapshot(name="my-template", overwrite=True) --- -## Code Interpreter +## Code Runner -The `wrenn.code_interpreter` module provides a specialized capsule for stateful code execution via a persistent Jupyter kernel. +The `wrenn.code_runner` module provides a specialized capsule for stateful code execution via a persistent Jupyter kernel. Defaults to the `code-runner-beta` template and the `wrenn` Jupyter kernelspec. + +> The legacy module path `wrenn.code_interpreter` still works but emits a `FutureWarning` on import. Use `wrenn.code_runner`. ### Quick Start ```python -from wrenn.code_interpreter import Capsule +from wrenn.code_runner import Capsule with Capsule(wait=True) as capsule: result = capsule.run_code("print('hello')") @@ -348,7 +350,7 @@ with Capsule(wait=True) as capsule: Variables, imports, and function definitions persist across `run_code` calls: ```python -from wrenn.code_interpreter import Capsule +from wrenn.code_runner import Capsule with Capsule(wait=True) as capsule: capsule.run_code("x = 42") @@ -403,15 +405,21 @@ capsule.run_code( ) ``` -### Custom Templates +### Custom Templates and Kernels -By default, `code-runner-beta` template is used. You can specify a custom template: +By default, the `code-runner-beta` template and the `wrenn` Jupyter kernelspec are used. Override either: ```python -capsule = Capsule(template="my-custom-jupyter-template", wait=True) +capsule = Capsule( + template="my-custom-jupyter-template", + kernel="python3", + wait=True, +) result = capsule.run_code("print('running on custom template')") ``` +`Capsule` reuses the first kernel matching the requested `kernel` name on the Jupyter server and creates one if none exists. + ### Execution Model `run_code()` returns an `Execution` object: @@ -424,14 +432,14 @@ result = capsule.run_code("print('running on custom template')") | `execution_count` | `int \| None` | Jupyter cell execution counter | | `text` | `str \| None` | (property) `text/plain` of the main `execute_result` | -Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `pdf`, `latex`, `json`, `javascript`, plus `extra` for unknown types. String expression results have quotes stripped automatically. +Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `pdf`, `latex`, `json`, `javascript`, plus `extra` for unknown types. The `text` field is Jupyter's `text/plain` bundle verbatim — the Python `repr()` of the cell's last expression. So `run_code("'hi'").text` is `"'hi'"` (with quotes), and `run_code("42").text` is `"42"`. This preserves the distinction between the string `'2'` and the int `2`. -### Code Interpreter + Commands/Files +### Code Runner + Commands/Files -The code interpreter capsule inherits all standard capsule features: +The code runner capsule inherits all standard capsule features: ```python -from wrenn.code_interpreter import Capsule +from wrenn.code_runner import Capsule with Capsule(wait=True) as capsule: # Use run_code for Jupyter execution @@ -469,10 +477,10 @@ async with await AsyncCapsule.create(template="minimal", wait=True) as capsule: await capsule.resume() ``` -### Async Code Interpreter +### Async Code Runner ```python -from wrenn.code_interpreter import AsyncCapsule +from wrenn.code_runner import AsyncCapsule async with await AsyncCapsule.create(wait=True) as capsule: result = await capsule.run_code("2 + 2") diff --git a/src/wrenn/code_interpreter/__init__.py b/src/wrenn/code_interpreter/__init__.py index 9818204..7c4f532 100644 --- a/src/wrenn/code_interpreter/__init__.py +++ b/src/wrenn/code_interpreter/__init__.py @@ -1,6 +1,33 @@ -from wrenn.code_interpreter.async_capsule import AsyncCapsule -from wrenn.code_interpreter.capsule import Capsule -from wrenn.code_interpreter.models import ( +"""Deprecated alias for :mod:`wrenn.code_runner`. + +Importing from ``wrenn.code_interpreter`` emits a ``FutureWarning``. +Use ``wrenn.code_runner`` instead. +""" + +from __future__ import annotations + +import warnings as _warnings + +warnings_emitted: bool = False + + +def _warn_once() -> None: + global warnings_emitted + if warnings_emitted: + return + warnings_emitted = True + _warnings.warn( + "'wrenn.code_interpreter' is deprecated, use 'wrenn.code_runner' instead", + FutureWarning, + stacklevel=3, + ) + + +_warn_once() + +from wrenn.code_runner.async_capsule import AsyncCapsule # noqa: E402 +from wrenn.code_runner.capsule import Capsule # noqa: E402 +from wrenn.code_runner.models import ( # noqa: E402 Execution, ExecutionError, Logs, @@ -20,12 +47,11 @@ __all__ = [ def __getattr__(name: str) -> type: import sys - import warnings _module = sys.modules[__name__] if name == "Sandbox": - warnings.warn( + _warnings.warn( "'Sandbox' is deprecated, use 'Capsule' instead", FutureWarning, stacklevel=2, diff --git a/src/wrenn/code_interpreter/async_capsule.py b/src/wrenn/code_interpreter/async_capsule.py index b328f6b..cb92324 100644 --- a/src/wrenn/code_interpreter/async_capsule.py +++ b/src/wrenn/code_interpreter/async_capsule.py @@ -1,292 +1,3 @@ -from __future__ import annotations +"""Deprecated — use :mod:`wrenn.code_runner.async_capsule`.""" -import asyncio -import json -import time -import uuid -from collections.abc import Callable -from typing import Any - -import httpx -import httpx_ws - -from wrenn.async_capsule import AsyncCapsule as BaseAsyncCapsule -from wrenn.capsule import _build_proxy_url -from wrenn.client import AsyncWrennClient -from wrenn.code_interpreter.capsule import DEFAULT_TEMPLATE -from wrenn.code_interpreter.models import ( - Execution, - ExecutionError, - Result, -) - - -class AsyncCapsule(BaseAsyncCapsule): - """Async code interpreter capsule with ``run_code`` support. - - Uses ``code-runner-beta`` template by default:: - - from wrenn.code_interpreter import AsyncCapsule - - capsule = await AsyncCapsule.create() - result = await capsule.run_code("print('hello')") - """ - - _kernel_id: str | None - _proxy_client: httpx.AsyncClient | None - - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) - 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, - template: str | None = None, - vcpus: int | None = None, - memory_mb: int | None = None, - timeout: int | None = None, - *, - wait: bool = False, - api_key: str | None = None, - base_url: str | None = None, - ) -> AsyncCapsule: - """Create a new async code interpreter capsule. - - Args: - template (str | None): Template to boot from. Defaults to - ``"code-runner-beta"``. - vcpus (int | None): Number of virtual CPUs. - memory_mb (int | None): Memory in MiB. - timeout (int | None): Inactivity TTL in seconds before auto-pause. - wait (bool): Await until the capsule reaches ``running`` status. - api_key (str | None): Wrenn API key. Falls back to - ``WRENN_API_KEY`` env var. - base_url (str | None): API base URL override. - - Returns: - AsyncCapsule: A new async code interpreter capsule instance. - """ - client = AsyncWrennClient(api_key=api_key, base_url=base_url) - info = await client.capsules.create( - template=template or DEFAULT_TEMPLATE, - vcpus=vcpus, - memory_mb=memory_mb, - timeout_sec=timeout, - ) - capsule = cls( - _capsule_id=info.id, - _client=client, - _info=info, - ) - if wait: - await capsule.wait_ready() - return capsule - - def _get_proxy_client(self) -> httpx.AsyncClient: - if self._proxy_client is None: - url = ( - _build_proxy_url(self._client._base_url, self._id, 8888) - .replace("ws://", "http://") - .replace("wss://", "https://") - ) - self._proxy_client = httpx.AsyncClient( - base_url=url, - headers={"X-API-Key": self._client._api_key}, - ) - return self._proxy_client - - async def _ensure_kernel(self, jupyter_timeout: float = 30) -> str: - if self._kernel_id is not None: - return self._kernel_id - - client = self._get_proxy_client() - deadline = time.monotonic() + jupyter_timeout - last_exc: Exception | None = None - - while time.monotonic() < deadline: - try: - # Try to reuse an existing kernel - resp = await client.get("/api/kernels") - if resp.status_code < 500: - resp.raise_for_status() - kernels = resp.json() - if kernels: - self._kernel_id = kernels[0]["id"] - return self._kernel_id - # No existing kernels, create a new one - resp = await client.post("/api/kernels") - if resp.status_code < 500: - resp.raise_for_status() - self._kernel_id = resp.json()["id"] - return self._kernel_id - last_exc = httpx.HTTPStatusError( - f"Jupyter returned {resp.status_code}", - request=resp.request, - response=resp, - ) - 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) - - raise TimeoutError( - f"Jupyter not available within {jupyter_timeout}s: {last_exc}" - ) - - def _jupyter_ws_url(self, kernel_id: str) -> str: - proxy = _build_proxy_url(self._client._base_url, self._id, 8888) - return f"{proxy}/api/kernels/{kernel_id}/channels" - - @staticmethod - def _jupyter_execute_request(code: str) -> dict: - msg_id = str(uuid.uuid4()) - return { - "header": { - "msg_id": msg_id, - "msg_type": "execute_request", - "username": "wrenn-sdk", - "session": str(uuid.uuid4()), - "date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), - "version": "5.3", - }, - "parent_header": {}, - "metadata": {}, - "content": { - "code": code, - "silent": False, - "store_history": True, - "user_expressions": {}, - "allow_stdin": False, - "stop_on_error": True, - }, - "buffers": [], - "channel": "shell", - } - - async def run_code( - self, - code: str, - language: str = "python", - timeout: float = 30, - jupyter_timeout: float = 30, - on_result: Callable[[Result], Any] | None = None, - on_stdout: Callable[[str], Any] | None = None, - on_stderr: Callable[[str], Any] | None = None, - on_error: Callable[[ExecutionError], Any] | None = None, - ) -> Execution: - """Execute code in a persistent Jupyter kernel (async). - - Args: - code: Code string to execute. - language: Execution backend language. Currently only ``"python"``. - timeout: Maximum seconds to wait for execution to complete. - jupyter_timeout: Maximum seconds to wait for Jupyter to become - available. - on_result: Called for each rich output (charts, images, expression - values). - on_stdout: Called for each stdout chunk. - on_stderr: Called for each stderr chunk. - on_error: Called when the cell raises an exception. - - Returns: - An :class:`Execution` with ``.results``, ``.logs``, ``.error``, - and a convenience ``.text`` property. - """ - kernel_id = await self._ensure_kernel(jupyter_timeout=jupyter_timeout) - ws_url = self._jupyter_ws_url(kernel_id) - - msg = self._jupyter_execute_request(code) - msg_id = msg["header"]["msg_id"] - - execution = Execution() - deadline = time.monotonic() + timeout - headers = {"X-API-Key": self._client._api_key} - - async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.AsyncWebSocketSession - await ws.send_text(json.dumps(msg)) - while time.monotonic() < deadline: - time_left = deadline - time.monotonic() - if time_left <= 0: - break - try: - data = await asyncio.wait_for(ws.receive_json(), timeout=time_left) - except Exception: - break - if not data: - break - parent = data.get("parent_header", {}).get("msg_id") - if parent != msg_id: - continue - msg_type = data.get("msg_type") or data.get("header", {}).get( - "msg_type" - ) - content = data.get("content", {}) - - if msg_type == "stream": - text = content.get("text", "") - name = content.get("name", "stdout") - if name == "stderr": - execution.logs.stderr.append(text) - if on_stderr is not None: - on_stderr(text) - else: - execution.logs.stdout.append(text) - if on_stdout is not None: - on_stdout(text) - elif msg_type in ("execute_result", "display_data"): - bundle = content.get("data", {}) - is_main = msg_type == "execute_result" - result = Result.from_bundle(bundle, is_main_result=is_main) - execution.results.append(result) - if is_main: - execution.execution_count = content.get("execution_count") - if on_result is not None: - on_result(result) - elif msg_type == "error": - err = ExecutionError( - name=content.get("ename", ""), - value=content.get("evalue", ""), - traceback="\n".join(content.get("traceback", [])), - ) - execution.error = err - if on_error is not None: - on_error(err) - elif msg_type == "status" and content.get("execution_state") == "idle": - break - - return execution - - async def __aexit__(self, *args) -> None: - if self._proxy_client is not None: - try: - await self._proxy_client.aclose() - except Exception: - pass - await super().__aexit__(*args) +from wrenn.code_runner.async_capsule import AsyncCapsule # noqa: F401 diff --git a/src/wrenn/code_interpreter/capsule.py b/src/wrenn/code_interpreter/capsule.py index 7d70d91..0ba439f 100644 --- a/src/wrenn/code_interpreter/capsule.py +++ b/src/wrenn/code_interpreter/capsule.py @@ -1,307 +1,7 @@ -from __future__ import annotations +"""Deprecated — use :mod:`wrenn.code_runner.capsule`.""" -import json -import time -import uuid -from collections.abc import Callable -from typing import Any - -import httpx -import httpx_ws - -from wrenn.capsule import Capsule as BaseCapsule -from wrenn.capsule import _build_proxy_url -from wrenn.code_interpreter.models import ( - Execution, - ExecutionError, - Result, +from wrenn.code_runner.capsule import ( # noqa: F401 + DEFAULT_KERNEL, + DEFAULT_TEMPLATE, + Capsule, ) - -DEFAULT_TEMPLATE = "code-runner-beta" - - -class Capsule(BaseCapsule): - """Code interpreter capsule with ``run_code`` support. - - Uses ``code-runner-beta`` template by default:: - - from wrenn.code_interpreter import Capsule - - capsule = Capsule() - result = capsule.run_code("print('hello')") - print(result.logs.stdout) # ["hello\\n"] - """ - - _kernel_id: str | None - _proxy_client: httpx.Client | None - - def __init__( - self, - template: str | None = None, - vcpus: int | None = None, - memory_mb: int | None = None, - timeout: int | None = None, - *, - api_key: str | None = None, - base_url: str | None = None, - **kwargs, - ) -> None: - """Create a code interpreter capsule. - - Args: - template (str | None): Template to boot from. Defaults to - ``"code-runner-beta"``. - vcpus (int | None): Number of virtual CPUs. - memory_mb (int | None): Memory in MiB. - timeout (int | None): Inactivity TTL in seconds before auto-pause. - api_key (str | None): Wrenn API key. Falls back to - ``WRENN_API_KEY`` env var. - base_url (str | None): API base URL override. - """ - super().__init__( - template=template or DEFAULT_TEMPLATE, - vcpus=vcpus, - memory_mb=memory_mb, - timeout=timeout, - api_key=api_key, - base_url=base_url, - **kwargs, - ) - 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, - template: str | None = None, - vcpus: int | None = None, - memory_mb: int | None = None, - timeout: int | None = None, - *, - wait: bool = False, - api_key: str | None = None, - base_url: str | None = None, - ) -> Capsule: - """Create a new code interpreter capsule. - - Args: - template (str | None): Template to boot from. Defaults to - ``"code-runner-beta"``. - vcpus (int | None): Number of virtual CPUs. - memory_mb (int | None): Memory in MiB. - timeout (int | None): Inactivity TTL in seconds before auto-pause. - wait (bool): Block until the capsule reaches ``running`` status. - api_key (str | None): Wrenn API key. Falls back to - ``WRENN_API_KEY`` env var. - base_url (str | None): API base URL override. - - Returns: - Capsule: A new code interpreter capsule instance. - """ - return cls( - template=template or DEFAULT_TEMPLATE, - vcpus=vcpus, - memory_mb=memory_mb, - timeout=timeout, - wait=wait, - api_key=api_key, - base_url=base_url, - ) - - def _get_proxy_client(self) -> httpx.Client: - if self._proxy_client is None: - url = ( - _build_proxy_url(self._client._base_url, self._id, 8888) - .replace("ws://", "http://") - .replace("wss://", "https://") - ) - self._proxy_client = httpx.Client( - base_url=url, - headers={"X-API-Key": self._client._api_key}, - ) - return self._proxy_client - - def _ensure_kernel(self, jupyter_timeout: float = 30) -> str: - if self._kernel_id is not None: - return self._kernel_id - - client = self._get_proxy_client() - deadline = time.monotonic() + jupyter_timeout - last_exc: Exception | None = None - - while time.monotonic() < deadline: - try: - # Try to reuse an existing kernel - resp = client.get("/api/kernels") - if resp.status_code < 500: - resp.raise_for_status() - kernels = resp.json() - if kernels: - self._kernel_id = kernels[0]["id"] - return self._kernel_id - # No existing kernels, create a new one - resp = client.post("/api/kernels") - if resp.status_code < 500: - resp.raise_for_status() - self._kernel_id = resp.json()["id"] - return self._kernel_id - last_exc = httpx.HTTPStatusError( - f"Jupyter returned {resp.status_code}", - request=resp.request, - response=resp, - ) - 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) - - raise TimeoutError( - f"Jupyter not available within {jupyter_timeout}s: {last_exc}" - ) - - def _jupyter_ws_url(self, kernel_id: str) -> str: - proxy = _build_proxy_url(self._client._base_url, self._id, 8888) - return f"{proxy}/api/kernels/{kernel_id}/channels" - - @staticmethod - def _jupyter_execute_request(code: str) -> dict: - msg_id = str(uuid.uuid4()) - return { - "header": { - "msg_id": msg_id, - "msg_type": "execute_request", - "username": "wrenn-sdk", - "session": str(uuid.uuid4()), - "date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), - "version": "5.3", - }, - "parent_header": {}, - "metadata": {}, - "content": { - "code": code, - "silent": False, - "store_history": True, - "user_expressions": {}, - "allow_stdin": False, - "stop_on_error": True, - }, - "buffers": [], - "channel": "shell", - } - - def run_code( - self, - code: str, - language: str = "python", - timeout: float = 30, - jupyter_timeout: float = 30, - on_result: Callable[[Result], Any] | None = None, - on_stdout: Callable[[str], Any] | None = None, - on_stderr: Callable[[str], Any] | None = None, - on_error: Callable[[ExecutionError], Any] | None = None, - ) -> Execution: - """Execute code in a persistent Jupyter kernel. - - Variables, imports, and function definitions survive across calls. - - Args: - code: Code string to execute. - language: Execution backend language. Currently only ``"python"``. - timeout: Maximum seconds to wait for execution to complete. - jupyter_timeout: Maximum seconds to wait for Jupyter to become - available. - on_result: Called for each rich output (charts, images, expression - values). - on_stdout: Called for each stdout chunk. - on_stderr: Called for each stderr chunk. - on_error: Called when the cell raises an exception. - - Returns: - An :class:`Execution` with ``.results``, ``.logs``, ``.error``, - and a convenience ``.text`` property. - """ - kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout) - ws_url = self._jupyter_ws_url(kernel_id) - - msg = self._jupyter_execute_request(code) - msg_id = msg["header"]["msg_id"] - - execution = Execution() - deadline = time.monotonic() + timeout - headers = {"X-API-Key": self._client._api_key} - - with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.WebSocketSession - ws.send_text(json.dumps(msg)) - while time.monotonic() < deadline: - time_left = deadline - time.monotonic() - if time_left <= 0: - break - try: - data = ws.receive_json(timeout=time_left) - except Exception: - break - if not data: - break - parent = data.get("parent_header", {}).get("msg_id") - if parent != msg_id: - continue - msg_type = data.get("msg_type") or data.get("header", {}).get( - "msg_type" - ) - content = data.get("content", {}) - - if msg_type == "stream": - text = content.get("text", "") - name = content.get("name", "stdout") - if name == "stderr": - execution.logs.stderr.append(text) - if on_stderr is not None: - on_stderr(text) - else: - execution.logs.stdout.append(text) - if on_stdout is not None: - on_stdout(text) - elif msg_type in ("execute_result", "display_data"): - bundle = content.get("data", {}) - is_main = msg_type == "execute_result" - result = Result.from_bundle(bundle, is_main_result=is_main) - execution.results.append(result) - if is_main: - execution.execution_count = content.get("execution_count") - if on_result is not None: - on_result(result) - elif msg_type == "error": - err = ExecutionError( - name=content.get("ename", ""), - value=content.get("evalue", ""), - traceback="\n".join(content.get("traceback", [])), - ) - execution.error = err - if on_error is not None: - on_error(err) - elif msg_type == "status" and content.get("execution_state") == "idle": - break - - return execution - - def __exit__(self, *args) -> None: - if self._proxy_client is not None: - try: - self._proxy_client.close() - except Exception: - pass - super().__exit__(*args) diff --git a/src/wrenn/code_interpreter/models.py b/src/wrenn/code_interpreter/models.py index 1449bc4..1c202f2 100644 --- a/src/wrenn/code_interpreter/models.py +++ b/src/wrenn/code_interpreter/models.py @@ -1,156 +1,8 @@ -from __future__ import annotations +"""Deprecated — use :mod:`wrenn.code_runner.models`.""" -from dataclasses import dataclass, field - -_MIME_MAP: dict[str, str] = { - "text/plain": "text", - "text/html": "html", - "text/markdown": "markdown", - "image/svg+xml": "svg", - "image/png": "png", - "image/jpeg": "jpeg", - "application/pdf": "pdf", - "text/latex": "latex", - "application/json": "json", - "application/javascript": "javascript", -} - - -@dataclass -class ExecutionError: - """Error raised during code execution. - - Attributes: - name: Exception class name (e.g. ``"NameError"``). - value: Exception message. - traceback: Full traceback string. - """ - - name: str = "" - value: str = "" - traceback: str = "" - - -@dataclass -class Logs: - """Captured stdout/stderr streams. - - Each element in the list is one chunk of text as it arrived from - the kernel. - """ - - stdout: list[str] = field(default_factory=list) - stderr: list[str] = field(default_factory=list) - - -@dataclass -class Result: - """A single rich output from code execution. - - Jupyter cells can produce multiple outputs — one ``execute_result`` - (the expression value) and zero or more ``display_data`` messages - (from ``plt.show()``, ``display()``, etc.). Each becomes a - ``Result``. - - Known MIME types are unpacked into named attributes; anything else - lands in :pyattr:`extra`. - """ - - # --- MIME type fields --- - text: str | None = None - """``text/plain`` representation.""" - html: str | None = None - """``text/html`` representation.""" - markdown: str | None = None - """``text/markdown`` representation.""" - svg: str | None = None - """``image/svg+xml`` representation.""" - png: str | None = None - """``image/png`` — base64-encoded.""" - jpeg: str | None = None - """``image/jpeg`` — base64-encoded.""" - pdf: str | None = None - """``application/pdf`` — base64-encoded.""" - latex: str | None = None - """``text/latex`` representation.""" - json: dict | None = None - """``application/json`` representation.""" - javascript: str | None = None - """``application/javascript`` representation.""" - extra: dict[str, str] | None = None - """MIME types not covered by the named fields above.""" - - is_main_result: bool = False - """``True`` when this came from an ``execute_result`` message - (i.e. the value of the last expression in the cell). ``False`` - for ``display_data`` outputs.""" - - @classmethod - def from_bundle( - cls, bundle: dict[str, str], *, is_main_result: bool = False - ) -> Result: - """Build a ``Result`` from a Jupyter MIME bundle dict.""" - kwargs: dict = {"is_main_result": is_main_result} - extra: dict[str, str] = {} - for mime, value in bundle.items(): - attr = _MIME_MAP.get(mime) - if attr is not None: - kwargs[attr] = value - else: - extra[mime] = value - if extra: - kwargs["extra"] = extra - # Strip surrounding quotes from text/plain (Jupyter repr artefact) - text = kwargs.get("text") - if isinstance(text, str) and len(text) >= 2: - if (text[0] == text[-1]) and text[0] in ("'", '"'): - kwargs["text"] = text[1:-1] - return cls(**kwargs) - - def formats(self) -> list[str]: - """Return names of non-``None`` MIME-type fields.""" - out: list[str] = [] - for attr in ( - "text", - "html", - "markdown", - "svg", - "png", - "jpeg", - "pdf", - "latex", - "json", - "javascript", - ): - if getattr(self, attr) is not None: - out.append(attr) - if self.extra: - out.extend(self.extra) - return out - - -@dataclass -class Execution: - """Complete result of a ``run_code`` call. - - Attributes: - results: All rich outputs produced by the cell — charts, tables, - images, expression values, etc. - logs: Captured stdout/stderr text. - error: Populated when the cell raised an exception. - execution_count: Jupyter execution counter (the ``[N]`` number). - """ - - results: list[Result] = field(default_factory=list) - logs: Logs = field(default_factory=Logs) - error: ExecutionError | None = None - execution_count: int | None = None - - @property - def text(self) -> str | None: - """Convenience — ``text/plain`` of the main ``execute_result``, - or ``None`` if the cell had no expression value.""" - for r in self.results: - if r.is_main_result: - return r.text - return None +from wrenn.code_runner.models import ( # noqa: F401 + Execution, + ExecutionError, + Logs, + Result, +) diff --git a/src/wrenn/code_runner/__init__.py b/src/wrenn/code_runner/__init__.py new file mode 100644 index 0000000..973a6f6 --- /dev/null +++ b/src/wrenn/code_runner/__init__.py @@ -0,0 +1,51 @@ +"""Code runner — execute code in persistent Jupyter kernels. + +Uses the ``code-runner-beta`` template and the ``wrenn`` Jupyter +kernelspec by default. + +Example:: + + from wrenn.code_runner import Capsule + + with Capsule(wait=True) as capsule: + result = capsule.run_code("print('hello')") + print(result.logs.stdout) +""" + +from wrenn.code_runner.async_capsule import AsyncCapsule +from wrenn.code_runner.capsule import DEFAULT_KERNEL, DEFAULT_TEMPLATE, Capsule +from wrenn.code_runner.models import ( + Execution, + ExecutionError, + Logs, + Result, +) + +__all__ = [ + "AsyncCapsule", + "Capsule", + "DEFAULT_KERNEL", + "DEFAULT_TEMPLATE", + "Execution", + "ExecutionError", + "Logs", + "Result", + "Sandbox", +] + + +def __getattr__(name: str) -> type: + import sys + import warnings + + _module = sys.modules[__name__] + + if name == "Sandbox": + warnings.warn( + "'Sandbox' is deprecated, use 'Capsule' instead", + FutureWarning, + stacklevel=2, + ) + setattr(_module, name, Capsule) + return Capsule + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/wrenn/code_runner/async_capsule.py b/src/wrenn/code_runner/async_capsule.py new file mode 100644 index 0000000..b8607b3 --- /dev/null +++ b/src/wrenn/code_runner/async_capsule.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import asyncio +import json +import time +import uuid +from collections.abc import Callable +from typing import Any + +import httpx +import httpx_ws + +from wrenn.async_capsule import AsyncCapsule as BaseAsyncCapsule +from wrenn.capsule import _build_proxy_url +from wrenn.client import AsyncWrennClient +from wrenn.code_runner.capsule import DEFAULT_KERNEL, DEFAULT_TEMPLATE +from wrenn.code_runner.models import ( + Execution, + ExecutionError, + Result, +) + + +class AsyncCapsule(BaseAsyncCapsule): + """Async code runner capsule with ``run_code`` support. + + Uses ``code-runner-beta`` template and the ``wrenn`` Jupyter + kernelspec by default:: + + from wrenn.code_runner import AsyncCapsule + + capsule = await AsyncCapsule.create() + result = await capsule.run_code("print('hello')") + """ + + _kernel_id: str | None + _kernel_name: str + _proxy_client: httpx.AsyncClient | None + + def __init__(self, *, kernel: str | None = None, **kwargs) -> None: + # Set attrs before super().__init__ so __del__ never sees a + # half-constructed instance. + self._kernel_id = None + self._kernel_name = kernel or DEFAULT_KERNEL + self._proxy_client = None + super().__init__(**kwargs) + + async def close(self) -> None: + proxy = getattr(self, "_proxy_client", None) + if proxy is not None: + try: + await proxy.aclose() + except Exception: + pass + self._proxy_client = None + + def __del__(self) -> None: + # Async client cannot be safely closed from __del__; just drop the + # reference and let httpx warn if the connection was never closed. + # Users should call ``await close()`` or use ``async with``. + self._proxy_client = None + + @classmethod + async def create( + cls, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + kernel: str | None = None, + wait: bool = False, + api_key: str | None = None, + base_url: str | None = None, + ) -> AsyncCapsule: + """Create a new async code runner capsule. + + Args: + template (str | None): Template to boot from. Defaults to + ``"code-runner-beta"``. + vcpus (int | None): Number of virtual CPUs. + memory_mb (int | None): Memory in MiB. + timeout (int | None): Inactivity TTL in seconds before auto-pause. + kernel (str | None): Jupyter kernelspec name. Defaults to + ``"wrenn"``. + wait (bool): Await until the capsule reaches ``running`` status. + api_key (str | None): Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. + base_url (str | None): API base URL override. + + Returns: + AsyncCapsule: A new async code runner capsule instance. + """ + client = AsyncWrennClient(api_key=api_key, base_url=base_url) + info = await client.capsules.create( + template=template or DEFAULT_TEMPLATE, + vcpus=vcpus, + memory_mb=memory_mb, + timeout_sec=timeout, + ) + capsule = cls( + kernel=kernel, + _capsule_id=info.id, + _client=client, + _info=info, + ) + if wait: + await capsule.wait_ready() + return capsule + + def _get_proxy_client(self) -> httpx.AsyncClient: + if self._proxy_client is None: + url = ( + _build_proxy_url(self._client._base_url, self._id, 8888) + .replace("ws://", "http://") + .replace("wss://", "https://") + ) + self._proxy_client = httpx.AsyncClient( + base_url=url, + headers={"X-API-Key": self._client._api_key}, + ) + return self._proxy_client + + async def _ensure_kernel(self, jupyter_timeout: float = 30) -> str: + if self._kernel_id is not None: + return self._kernel_id + + client = self._get_proxy_client() + deadline = time.monotonic() + jupyter_timeout + last_exc: Exception | None = None + + while time.monotonic() < deadline: + try: + resp = await client.get("/api/kernels") + if resp.status_code < 500: + resp.raise_for_status() + kernels = resp.json() + for k in kernels: + if k.get("name") == self._kernel_name: + self._kernel_id = k["id"] + return self._kernel_id + resp = await client.post( + "/api/kernels", + json={"name": self._kernel_name}, + ) + if resp.status_code < 500: + resp.raise_for_status() + self._kernel_id = resp.json()["id"] + return self._kernel_id + last_exc = httpx.HTTPStatusError( + f"Jupyter returned {resp.status_code}", + request=resp.request, + response=resp, + ) + 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) + + raise TimeoutError( + f"Jupyter not available within {jupyter_timeout}s: {last_exc}" + ) + + def _jupyter_ws_url(self, kernel_id: str) -> str: + proxy = _build_proxy_url(self._client._base_url, self._id, 8888) + return f"{proxy}/api/kernels/{kernel_id}/channels" + + @staticmethod + def _jupyter_execute_request(code: str) -> dict: + msg_id = str(uuid.uuid4()) + return { + "header": { + "msg_id": msg_id, + "msg_type": "execute_request", + "username": "wrenn-sdk", + "session": str(uuid.uuid4()), + "date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), + "version": "5.3", + }, + "parent_header": {}, + "metadata": {}, + "content": { + "code": code, + "silent": False, + "store_history": True, + "user_expressions": {}, + "allow_stdin": False, + "stop_on_error": True, + }, + "buffers": [], + "channel": "shell", + } + + async def run_code( + self, + code: str, + language: str = "python", + timeout: float = 30, + jupyter_timeout: float = 30, + on_result: Callable[[Result], Any] | None = None, + on_stdout: Callable[[str], Any] | None = None, + on_stderr: Callable[[str], Any] | None = None, + on_error: Callable[[ExecutionError], Any] | None = None, + ) -> Execution: + """Execute code in a persistent Jupyter kernel (async). + + Args: + code: Code string to execute. + language: Execution backend language. Currently only ``"python"``. + timeout: Maximum seconds to wait for execution to complete. + jupyter_timeout: Maximum seconds to wait for Jupyter to become + available. + on_result: Called for each rich output (charts, images, expression + values). + on_stdout: Called for each stdout chunk. + on_stderr: Called for each stderr chunk. + on_error: Called when the cell raises an exception. + + Returns: + An :class:`Execution` with ``.results``, ``.logs``, ``.error``, + and a convenience ``.text`` property. + """ + if language != "python": + raise ValueError( + f"language={language!r} is not supported; only 'python'. " + "Use the ``kernel=`` constructor argument to target a " + "non-Python kernelspec." + ) + kernel_id = await self._ensure_kernel(jupyter_timeout=jupyter_timeout) + ws_url = self._jupyter_ws_url(kernel_id) + + msg = self._jupyter_execute_request(code) + msg_id = msg["header"]["msg_id"] + + execution = Execution() + deadline = time.monotonic() + timeout + headers = {"X-API-Key": self._client._api_key} + + async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.AsyncWebSocketSession + await ws.send_text(json.dumps(msg)) + while time.monotonic() < deadline: + time_left = deadline - time.monotonic() + if time_left <= 0: + break + try: + data = await asyncio.wait_for(ws.receive_json(), timeout=time_left) + except Exception: + break + if not data: + break + parent = data.get("parent_header", {}).get("msg_id") + if parent != msg_id: + continue + msg_type = data.get("msg_type") or data.get("header", {}).get( + "msg_type" + ) + content = data.get("content", {}) + + if msg_type == "stream": + text = content.get("text", "") + name = content.get("name", "stdout") + if name == "stderr": + execution.logs.stderr.append(text) + if on_stderr is not None: + on_stderr(text) + else: + execution.logs.stdout.append(text) + if on_stdout is not None: + on_stdout(text) + elif msg_type in ("execute_result", "display_data"): + bundle = content.get("data", {}) + is_main = msg_type == "execute_result" + result = Result.from_bundle(bundle, is_main_result=is_main) + execution.results.append(result) + if is_main: + execution.execution_count = content.get("execution_count") + if on_result is not None: + on_result(result) + elif msg_type == "error": + err = ExecutionError( + name=content.get("ename", ""), + value=content.get("evalue", ""), + traceback="\n".join(content.get("traceback", [])), + ) + execution.error = err + if on_error is not None: + on_error(err) + elif msg_type == "status" and content.get("execution_state") == "idle": + break + + return execution + + async def __aexit__(self, *args) -> None: + await self.close() + await super().__aexit__(*args) diff --git a/src/wrenn/code_runner/capsule.py b/src/wrenn/code_runner/capsule.py new file mode 100644 index 0000000..cac94b0 --- /dev/null +++ b/src/wrenn/code_runner/capsule.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import json +import time +import uuid +from collections.abc import Callable +from typing import Any + +import httpx +import httpx_ws + +from wrenn.capsule import Capsule as BaseCapsule +from wrenn.capsule import _build_proxy_url +from wrenn.code_runner.models import ( + Execution, + ExecutionError, + Result, +) + +DEFAULT_TEMPLATE = "code-runner-beta" +DEFAULT_KERNEL = "wrenn" + + +class Capsule(BaseCapsule): + """Code runner capsule with ``run_code`` support. + + Uses ``code-runner-beta`` template and the ``wrenn`` Jupyter + kernelspec by default:: + + from wrenn.code_runner import Capsule + + capsule = Capsule() + result = capsule.run_code("print('hello')") + print(result.logs.stdout) # ["hello\\n"] + """ + + _kernel_id: str | None + _kernel_name: str + _proxy_client: httpx.Client | None + + def __init__( + self, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + kernel: str | None = None, + api_key: str | None = None, + base_url: str | None = None, + **kwargs, + ) -> None: + """Create a code runner capsule. + + Args: + template (str | None): Template to boot from. Defaults to + ``"code-runner-beta"``. + vcpus (int | None): Number of virtual CPUs. + memory_mb (int | None): Memory in MiB. + timeout (int | None): Inactivity TTL in seconds before auto-pause. + kernel (str | None): Jupyter kernelspec name. Defaults to + ``"wrenn"``. + api_key (str | None): Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. + base_url (str | None): API base URL override. + """ + # Set attrs before super().__init__ so __del__ never sees a + # half-constructed instance if creation fails. + self._kernel_id = None + self._kernel_name = kernel or DEFAULT_KERNEL + self._proxy_client = None + super().__init__( + template=template or DEFAULT_TEMPLATE, + vcpus=vcpus, + memory_mb=memory_mb, + timeout=timeout, + api_key=api_key, + base_url=base_url, + **kwargs, + ) + + def close(self) -> None: + proxy = getattr(self, "_proxy_client", None) + if proxy is not None: + try: + proxy.close() + except Exception: + pass + self._proxy_client = None + + def __del__(self) -> None: + try: + self.close() + except Exception: + pass + + @classmethod + def create( + cls, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + kernel: str | None = None, + wait: bool = False, + api_key: str | None = None, + base_url: str | None = None, + ) -> Capsule: + """Create a new code runner capsule. + + Args: + template (str | None): Template to boot from. Defaults to + ``"code-runner-beta"``. + vcpus (int | None): Number of virtual CPUs. + memory_mb (int | None): Memory in MiB. + timeout (int | None): Inactivity TTL in seconds before auto-pause. + kernel (str | None): Jupyter kernelspec name. Defaults to + ``"wrenn"``. + wait (bool): Block until the capsule reaches ``running`` status. + api_key (str | None): Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. + base_url (str | None): API base URL override. + + Returns: + Capsule: A new code runner capsule instance. + """ + return cls( + template=template or DEFAULT_TEMPLATE, + vcpus=vcpus, + memory_mb=memory_mb, + timeout=timeout, + kernel=kernel, + wait=wait, + api_key=api_key, + base_url=base_url, + ) + + def _get_proxy_client(self) -> httpx.Client: + if self._proxy_client is None: + url = ( + _build_proxy_url(self._client._base_url, self._id, 8888) + .replace("ws://", "http://") + .replace("wss://", "https://") + ) + self._proxy_client = httpx.Client( + base_url=url, + headers={"X-API-Key": self._client._api_key}, + ) + return self._proxy_client + + def _ensure_kernel(self, jupyter_timeout: float = 30) -> str: + if self._kernel_id is not None: + return self._kernel_id + + client = self._get_proxy_client() + deadline = time.monotonic() + jupyter_timeout + last_exc: Exception | None = None + + while time.monotonic() < deadline: + try: + # Try to reuse an existing kernel of the requested kernelspec. + resp = client.get("/api/kernels") + if resp.status_code < 500: + resp.raise_for_status() + kernels = resp.json() + for k in kernels: + if k.get("name") == self._kernel_name: + self._kernel_id = k["id"] + return self._kernel_id + # No matching kernel; create one with the requested spec. + resp = client.post( + "/api/kernels", + json={"name": self._kernel_name}, + ) + if resp.status_code < 500: + resp.raise_for_status() + self._kernel_id = resp.json()["id"] + return self._kernel_id + last_exc = httpx.HTTPStatusError( + f"Jupyter returned {resp.status_code}", + request=resp.request, + response=resp, + ) + 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) + + raise TimeoutError( + f"Jupyter not available within {jupyter_timeout}s: {last_exc}" + ) + + def _jupyter_ws_url(self, kernel_id: str) -> str: + proxy = _build_proxy_url(self._client._base_url, self._id, 8888) + return f"{proxy}/api/kernels/{kernel_id}/channels" + + @staticmethod + def _jupyter_execute_request(code: str) -> dict: + msg_id = str(uuid.uuid4()) + return { + "header": { + "msg_id": msg_id, + "msg_type": "execute_request", + "username": "wrenn-sdk", + "session": str(uuid.uuid4()), + "date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), + "version": "5.3", + }, + "parent_header": {}, + "metadata": {}, + "content": { + "code": code, + "silent": False, + "store_history": True, + "user_expressions": {}, + "allow_stdin": False, + "stop_on_error": True, + }, + "buffers": [], + "channel": "shell", + } + + def run_code( + self, + code: str, + language: str = "python", + timeout: float = 30, + jupyter_timeout: float = 30, + on_result: Callable[[Result], Any] | None = None, + on_stdout: Callable[[str], Any] | None = None, + on_stderr: Callable[[str], Any] | None = None, + on_error: Callable[[ExecutionError], Any] | None = None, + ) -> Execution: + """Execute code in a persistent Jupyter kernel. + + Variables, imports, and function definitions survive across calls. + + Args: + code: Code string to execute. + language: Execution backend language. Currently only ``"python"`` + is supported; passing anything else raises ``ValueError``. + To target a non-Python kernel, set ``kernel=`` on the + capsule constructor. + timeout: Maximum seconds to wait for execution to complete. + jupyter_timeout: Maximum seconds to wait for Jupyter to become + available. + on_result: Called for each rich output (charts, images, expression + values). + on_stdout: Called for each stdout chunk. + on_stderr: Called for each stderr chunk. + on_error: Called when the cell raises an exception. + + Returns: + An :class:`Execution` with ``.results``, ``.logs``, ``.error``, + and a convenience ``.text`` property. + """ + if language != "python": + raise ValueError( + f"language={language!r} is not supported; only 'python'. " + "Use the ``kernel=`` constructor argument to target a " + "non-Python kernelspec." + ) + kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout) + ws_url = self._jupyter_ws_url(kernel_id) + + msg = self._jupyter_execute_request(code) + msg_id = msg["header"]["msg_id"] + + execution = Execution() + deadline = time.monotonic() + timeout + headers = {"X-API-Key": self._client._api_key} + + with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.WebSocketSession + ws.send_text(json.dumps(msg)) + while time.monotonic() < deadline: + time_left = deadline - time.monotonic() + if time_left <= 0: + break + try: + data = ws.receive_json(timeout=time_left) + except Exception: + break + if not data: + break + parent = data.get("parent_header", {}).get("msg_id") + if parent != msg_id: + continue + msg_type = data.get("msg_type") or data.get("header", {}).get( + "msg_type" + ) + content = data.get("content", {}) + + if msg_type == "stream": + text = content.get("text", "") + name = content.get("name", "stdout") + if name == "stderr": + execution.logs.stderr.append(text) + if on_stderr is not None: + on_stderr(text) + else: + execution.logs.stdout.append(text) + if on_stdout is not None: + on_stdout(text) + elif msg_type in ("execute_result", "display_data"): + bundle = content.get("data", {}) + is_main = msg_type == "execute_result" + result = Result.from_bundle(bundle, is_main_result=is_main) + execution.results.append(result) + if is_main: + execution.execution_count = content.get("execution_count") + if on_result is not None: + on_result(result) + elif msg_type == "error": + err = ExecutionError( + name=content.get("ename", ""), + value=content.get("evalue", ""), + traceback="\n".join(content.get("traceback", [])), + ) + execution.error = err + if on_error is not None: + on_error(err) + elif msg_type == "status" and content.get("execution_state") == "idle": + break + + return execution + + def __exit__(self, *args) -> None: + self.close() + super().__exit__(*args) diff --git a/src/wrenn/code_runner/models.py b/src/wrenn/code_runner/models.py new file mode 100644 index 0000000..39a1e64 --- /dev/null +++ b/src/wrenn/code_runner/models.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +_MIME_MAP: dict[str, str] = { + "text/plain": "text", + "text/html": "html", + "text/markdown": "markdown", + "image/svg+xml": "svg", + "image/png": "png", + "image/jpeg": "jpeg", + "application/pdf": "pdf", + "text/latex": "latex", + "application/json": "json", + "application/javascript": "javascript", +} + + +@dataclass +class ExecutionError: + """Error raised during code execution. + + Attributes: + name: Exception class name (e.g. ``"NameError"``). + value: Exception message. + traceback: Full traceback string. + """ + + name: str = "" + value: str = "" + traceback: str = "" + + +@dataclass +class Logs: + """Captured stdout/stderr streams. + + Each element in the list is one chunk of text as it arrived from + the kernel. + """ + + stdout: list[str] = field(default_factory=list) + stderr: list[str] = field(default_factory=list) + + +@dataclass +class Result: + """A single rich output from code execution. + + Jupyter cells can produce multiple outputs — one ``execute_result`` + (the expression value) and zero or more ``display_data`` messages + (from ``plt.show()``, ``display()``, etc.). Each becomes a + ``Result``. + + Known MIME types are unpacked into named attributes; anything else + lands in :pyattr:`extra`. + """ + + # --- MIME type fields --- + text: str | None = None + """``text/plain`` representation.""" + html: str | None = None + """``text/html`` representation.""" + markdown: str | None = None + """``text/markdown`` representation.""" + svg: str | None = None + """``image/svg+xml`` representation.""" + png: str | None = None + """``image/png`` — base64-encoded.""" + jpeg: str | None = None + """``image/jpeg`` — base64-encoded.""" + pdf: str | None = None + """``application/pdf`` — base64-encoded.""" + latex: str | None = None + """``text/latex`` representation.""" + json: dict | None = None + """``application/json`` representation.""" + javascript: str | None = None + """``application/javascript`` representation.""" + extra: dict[str, str] | None = None + """MIME types not covered by the named fields above.""" + + is_main_result: bool = False + """``True`` when this came from an ``execute_result`` message + (i.e. the value of the last expression in the cell). ``False`` + for ``display_data`` outputs.""" + + @classmethod + def from_bundle( + cls, bundle: dict[str, str], *, is_main_result: bool = False + ) -> Result: + """Build a ``Result`` from a Jupyter MIME bundle dict.""" + kwargs: dict = {"is_main_result": is_main_result} + extra: dict[str, str] = {} + for mime, value in bundle.items(): + attr = _MIME_MAP.get(mime) + if attr is not None: + kwargs[attr] = value + else: + extra[mime] = value + if extra: + kwargs["extra"] = extra + return cls(**kwargs) + + def formats(self) -> list[str]: + """Return names of non-``None`` MIME-type fields.""" + out: list[str] = [] + for attr in ( + "text", + "html", + "markdown", + "svg", + "png", + "jpeg", + "pdf", + "latex", + "json", + "javascript", + ): + if getattr(self, attr) is not None: + out.append(attr) + if self.extra: + out.extend(self.extra) + return out + + +@dataclass +class Execution: + """Complete result of a ``run_code`` call. + + Attributes: + results: All rich outputs produced by the cell — charts, tables, + images, expression values, etc. + logs: Captured stdout/stderr text. + error: Populated when the cell raised an exception. + execution_count: Jupyter execution counter (the ``[N]`` number). + """ + + results: list[Result] = field(default_factory=list) + logs: Logs = field(default_factory=Logs) + error: ExecutionError | None = None + execution_count: int | None = None + + @property + def text(self) -> str | None: + """Convenience — ``text/plain`` of the main ``execute_result``, + or ``None`` if the cell had no expression value.""" + for r in self.results: + if r.is_main_result: + return r.text + return None diff --git a/tests/test_capsule_features.py b/tests/test_capsule_features.py index 229a907..7cfe624 100644 --- a/tests/test_capsule_features.py +++ b/tests/test_capsule_features.py @@ -4,7 +4,7 @@ import httpx import respx from wrenn.capsule import Capsule, _build_proxy_url -from wrenn.code_interpreter.models import Execution, ExecutionError, Logs, Result +from wrenn.code_runner.models import Execution, ExecutionError, Logs, Result BASE = "https://app.wrenn.dev/api" @@ -152,10 +152,11 @@ class TestExecutionModels: assert r.png == "base64data" assert r.is_main_result is True - def test_result_from_bundle_strips_quotes(self): + def test_result_from_bundle_preserves_text_plain(self): + # ``text/plain`` is the Jupyter repr — preserved verbatim now. bundle = {"text/plain": "'hello'"} r = Result.from_bundle(bundle) - assert r.text == "hello" + assert r.text == "'hello'" def test_result_from_bundle_extra_mimes(self): bundle = {"text/plain": "x", "application/vnd.custom": "data"} diff --git a/tests/test_code_runner_e2e.py b/tests/test_code_runner_e2e.py new file mode 100644 index 0000000..dd233ff --- /dev/null +++ b/tests/test_code_runner_e2e.py @@ -0,0 +1,538 @@ +from __future__ import annotations + +import asyncio +import os +import warnings +from pathlib import Path + +import pytest + +from wrenn.code_runner import ( + AsyncCapsule, + Capsule, + Execution, + Result, +) + +pytestmark = pytest.mark.integration + +_env_loaded = False + + +def _ensure_env() -> None: + global _env_loaded + if _env_loaded: + return + _env_loaded = True + env_file = Path(__file__).resolve().parent.parent / ".env" + if not env_file.exists(): + return + for line in env_file.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key, value = key.strip(), value.strip().strip("\"'") + if key and key not in os.environ: + os.environ[key] = value + + +# ───────────────────────── Sync e2e ───────────────────────── + + +class TestCodeRunnerSync: + """Shared capsule — kernel state persists across tests.""" + + capsule: Capsule + + @classmethod + def setup_class(cls): + _ensure_env() + cls.capsule = Capsule(wait=True) + + @classmethod + def teardown_class(cls): + try: + cls.capsule.destroy() + except Exception: + pass + + def test_uses_code_runner_beta_template(self): + assert self.capsule.info is not None + assert self.capsule.info.template == "code-runner-beta" + + def test_default_kernel_name_is_wrenn(self): + assert self.capsule._kernel_name == "wrenn" + + def test_simple_expression(self): + ex = self.capsule.run_code("1 + 1") + assert isinstance(ex, Execution) + assert ex.error is None + assert ex.text == "2" + assert ex.execution_count is not None + assert ex.execution_count >= 1 + + def test_print_captures_stdout(self): + ex = self.capsule.run_code("print('hello world')") + assert ex.error is None + joined = "".join(ex.logs.stdout) + assert "hello world" in joined + + def test_stderr_captured(self): + ex = self.capsule.run_code("import sys; sys.stderr.write('an error\\n')") + assert ex.error is None + joined = "".join(ex.logs.stderr) + assert "an error" in joined + + def test_kernel_state_persists_across_calls(self): + self.capsule.run_code("persistent_value = 12345") + ex = self.capsule.run_code("persistent_value") + assert ex.text == "12345" + + def test_import_persists(self): + self.capsule.run_code("import math") + ex = self.capsule.run_code("round(math.pi, 4)") + assert ex.text == "3.1416" + + def test_function_definition_persists(self): + self.capsule.run_code( + "def fib(n):\n" + " a, b = 0, 1\n" + " for _ in range(n):\n" + " a, b = b, a + b\n" + " return a\n" + ) + ex = self.capsule.run_code("fib(10)") + assert ex.text == "55" + + def test_class_definition_persists(self): + self.capsule.run_code( + "class Counter:\n" + " def __init__(self): self.n = 0\n" + " def inc(self): self.n += 1; return self.n\n" + "c = Counter()\n" + ) + ex = self.capsule.run_code("c.inc(); c.inc(); c.inc(); c.n") + assert ex.text == "3" + + def test_exception_captured(self): + ex = self.capsule.run_code("raise ValueError('boom')") + assert ex.error is not None + assert ex.error.name == "ValueError" + assert "boom" in ex.error.value + assert "ValueError" in ex.error.traceback + + def test_name_error(self): + ex = self.capsule.run_code("undefined_symbol_xyz") + assert ex.error is not None + assert ex.error.name == "NameError" + + def test_syntax_error(self): + ex = self.capsule.run_code("def )(\n") + assert ex.error is not None + assert "SyntaxError" in ex.error.name + + def test_callbacks_fire(self): + stdout_chunks: list[str] = [] + stderr_chunks: list[str] = [] + results: list[Result] = [] + errors = [] + self.capsule.run_code( + "import sys\nprint('on stdout')\nsys.stderr.write('on stderr\\n')\n42\n", + on_stdout=stdout_chunks.append, + on_stderr=stderr_chunks.append, + on_result=results.append, + on_error=errors.append, + ) + assert any("on stdout" in c for c in stdout_chunks) + assert any("on stderr" in c for c in stderr_chunks) + assert any(r.text == "42" for r in results) + assert errors == [] + + def test_multi_line_output(self): + ex = self.capsule.run_code("for i in range(3):\n print(i)\n") + joined = "".join(ex.logs.stdout) + assert "0" in joined and "1" in joined and "2" in joined + + def test_no_main_result_when_statement_only(self): + ex = self.capsule.run_code("x = 5") + assert ex.text is None + assert ex.error is None + + def test_html_repr_result(self): + ex = self.capsule.run_code( + "from IPython.display import HTML\nHTML('bold')" + ) + assert ex.error is None + main = [r for r in ex.results if r.is_main_result] + assert main, "expected execute_result" + assert main[0].html is not None + assert "bold" in main[0].html + + def test_display_data_separate_from_execute_result(self): + ex = self.capsule.run_code( + "from IPython.display import display, HTML\n" + "display(HTML('shown'))\n" + "'final'\n" + ) + assert ex.error is None + mains = [r for r in ex.results if r.is_main_result] + displays = [r for r in ex.results if not r.is_main_result] + assert len(mains) == 1 + assert mains[0].text == "'final'" + assert len(displays) >= 1 + assert any(r.html and "shown" in r.html for r in displays) + + def test_matplotlib_png(self): + ex = self.capsule.run_code( + "%matplotlib inline\n" + "import matplotlib.pyplot as plt\n" + "plt.figure()\n" + "plt.plot([1,2,3],[4,1,5])\n" + "plt.show()\n" + ) + if ex.error is not None and ex.error.name == "ModuleNotFoundError": + pytest.skip("matplotlib not in template") + assert ex.error is None + pngs = [r for r in ex.results if r.png is not None] + assert pngs, "expected at least one PNG result from plt.show()" + + def test_pandas_repr(self): + ex = self.capsule.run_code( + "import pandas as pd\npd.DataFrame({'a':[1,2],'b':[3,4]})\n" + ) + if ex.error is not None and ex.error.name == "ModuleNotFoundError": + pytest.skip("pandas not in template") + assert ex.error is None + main = [r for r in ex.results if r.is_main_result] + assert main + assert main[0].html is not None or main[0].text is not None + + def test_filesystem_round_trip(self): + self.capsule.run_code( + "with open('/tmp/from_kernel.txt','w') as f: f.write('written-by-kernel')" + ) + content = self.capsule.files.read("/tmp/from_kernel.txt") + assert content == "written-by-kernel" + + def test_text_preserves_string_repr(self): + """Strings keep their surrounding quotes — the ``text/plain`` MIME + is the Jupyter repr, which is what disambiguates ``'2'`` from + ``2``.""" + ex = self.capsule.run_code("'hello'") + assert ex.text == "'hello'" + ex = self.capsule.run_code('"with\\"inside"') + assert ex.text is not None + assert ex.text.startswith("'") or ex.text.startswith('"') + ex = self.capsule.run_code("42") + assert ex.text == "42" + ex = self.capsule.run_code("[1, 2, 3]") + assert ex.text == "[1, 2, 3]" + ex = self.capsule.run_code("{'k': 'v'}") + assert ex.text == "{'k': 'v'}" + + def test_kernel_id_cached(self): + first = self.capsule._kernel_id + self.capsule.run_code("1") + assert self.capsule._kernel_id == first + + def test_complex_workflow(self): + ex = self.capsule.run_code( + "import json\n" + "data = [{'n': i, 'sq': i*i} for i in range(5)]\n" + "print(json.dumps(data))\n" + "sum(d['sq'] for d in data)\n" + ) + assert ex.error is None + assert ex.text == "30" + assert any('"sq": 16' in c for c in ex.logs.stdout) + + +class TestCodeRunnerMimeTypes: + """Cover every non-text MIME field on ``Result`` using the libs + baked into the ``code-runner-beta`` template + (numpy, pandas, matplotlib, seaborn, requests).""" + + capsule: Capsule + + @classmethod + def setup_class(cls): + _ensure_env() + cls.capsule = Capsule(wait=True) + + @classmethod + def teardown_class(cls): + try: + cls.capsule.destroy() + except Exception: + pass + + def _run(self, code: str) -> Execution: + ex = self.capsule.run_code(code, timeout=60) + assert ex.error is None, f"unexpected error: {ex.error}" + return ex + + # ── html ────────────────────────────────────────────────────── + def test_html_via_ipython_display(self): + ex = self._run( + "from IPython.display import HTML\nHTML('
x
')" + ) + main = next(r for r in ex.results if r.is_main_result) + assert main.html is not None + assert "" in main.html + assert "html" in main.formats() + + def test_html_via_pandas_dataframe(self): + ex = self._run( + "import pandas as pd\n" + "pd.DataFrame({'a': [1, 2, 3], 'b': ['x', 'y', 'z']})\n" + ) + main = next(r for r in ex.results if r.is_main_result) + assert main.html is not None + # pandas emits a styled
+ assert "' + '' + ) + ex = self._run(f"from IPython.display import SVG\nSVG(data='{svg_payload}')") + main = next(r for r in ex.results if r.is_main_result) + assert main.svg is not None + assert "42", + "image/png": "iVBORw0KGgo=", + "application/json": {"x": 1}, + }, + is_main_result=True, + ) + assert r.text == "42" + assert r.html == "42" + assert r.png == "iVBORw0KGgo=" + assert r.json == {"x": 1} + assert r.is_main_result is True + assert r.extra is None + + def test_unknown_mime_lands_in_extra(self): + r = Result.from_bundle({"application/vnd.custom+json": "{}"}) + assert r.extra == {"application/vnd.custom+json": "{}"} + assert r.is_main_result is False + + @pytest.mark.parametrize( + "raw", + [ + "'hello'", + '"hello"', + "hello", + "'x", + "''", + "'", + "'it\\'s'", + "{'a': 1}", + "[1, 2, 3]", + ], + ) + def test_text_plain_preserved_verbatim(self, raw): + """``text/plain`` is the Jupyter repr — pass through unchanged. + Stripping outer quotes would lose string identity (a string + ``'2'`` would become indistinguishable from the int ``2``).""" + r = Result.from_bundle({"text/plain": raw}) + assert r.text == raw + + def test_formats_lists_present_fields(self): + r = Result.from_bundle({"text/plain": "x", "image/svg+xml": ""}) + fmts = r.formats() + assert "text" in fmts + assert "svg" in fmts + assert "html" not in fmts + + def test_formats_includes_extra(self): + r = Result.from_bundle({"application/x-foo": "bar"}) + assert "application/x-foo" in r.formats() + + def test_all_mime_types_map(self): + r = Result.from_bundle( + { + "text/plain": "a", + "text/html": "b", + "text/markdown": "c", + "image/svg+xml": "d", + "image/png": "e", + "image/jpeg": "f", + "application/pdf": "g", + "text/latex": "h", + "application/json": {"k": 1}, + "application/javascript": "j", + } + ) + for attr in ( + "text", + "html", + "markdown", + "svg", + "png", + "jpeg", + "pdf", + "latex", + "json", + "javascript", + ): + assert getattr(r, attr) is not None + + +class TestExecution: + def test_text_returns_main_result(self): + ex = Execution( + results=[ + Result(text="display", is_main_result=False), + Result(text="main", is_main_result=True), + ] + ) + assert ex.text == "main" + + def test_text_none_when_no_main(self): + ex = Execution(results=[Result(text="x", is_main_result=False)]) + assert ex.text is None + + def test_defaults(self): + ex = Execution() + assert ex.results == [] + assert isinstance(ex.logs, Logs) + assert ex.error is None + assert ex.execution_count is None + + +# ───────────────────────── deprecation alias ───────────────────────── + + +class TestDeprecationAlias: + def test_code_interpreter_emits_warning_on_import(self): + # Force a fresh import to observe the warning. + sys.modules.pop("wrenn.code_interpreter", None) + # Reset the one-shot flag in case the module was previously imported. + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + ci = importlib.import_module("wrenn.code_interpreter") + ci.warnings_emitted = False # type: ignore[attr-defined] + # Re-import to trigger again + sys.modules.pop("wrenn.code_interpreter", None) + importlib.import_module("wrenn.code_interpreter") + msgs = [ + str(w.message) + for w in captured + if issubclass(w.category, FutureWarning) + ] + assert any("code_interpreter" in m and "code_runner" in m for m in msgs) + + def test_alias_re_exports_same_classes(self): + from wrenn import code_interpreter as ci + + assert ci.Capsule is Capsule + assert ci.AsyncCapsule is AsyncCapsule + assert ci.Execution is Execution + assert ci.Result is Result + + def test_sandbox_attr_deprecated(self): + from wrenn import code_runner as cr + + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + S = cr.Sandbox + assert S is cr.Capsule + assert any( + issubclass(w.category, FutureWarning) and "Sandbox" in str(w.message) + for w in captured + ) + + +# ───────────────────────── Capsule (mock HTTP) ───────────────────────── + + +@respx.mock +def _make_capsule(capsule_id: str = "sb-1") -> Capsule: + respx.post(f"{BASE}/v1/capsules").respond( + 202, + json={"id": capsule_id, "status": "starting", "template": DEFAULT_TEMPLATE}, + ) + return Capsule(api_key=API_KEY, base_url=BASE) + + +class TestCapsuleDefaults: + @respx.mock + def test_default_template_sent(self): + route = respx.post(f"{BASE}/v1/capsules").respond( + 202, json={"id": "sb-1", "status": "starting"} + ) + Capsule(api_key=API_KEY, base_url=BASE) + body = json.loads(route.calls[0].request.content) + assert body["template"] == DEFAULT_TEMPLATE + assert DEFAULT_TEMPLATE == "code-runner-beta" + + @respx.mock + def test_explicit_template_override(self): + route = respx.post(f"{BASE}/v1/capsules").respond( + 202, json={"id": "sb-1", "status": "starting"} + ) + Capsule(template="other-template", api_key=API_KEY, base_url=BASE) + body = json.loads(route.calls[0].request.content) + assert body["template"] == "other-template" + + @respx.mock + def test_create_classmethod(self): + respx.post(f"{BASE}/v1/capsules").respond( + 202, json={"id": "sb-2", "status": "starting"} + ) + c = Capsule.create(api_key=API_KEY, base_url=BASE) + assert c.capsule_id == "sb-2" + + @respx.mock + def test_default_kernel_name(self): + respx.post(f"{BASE}/v1/capsules").respond( + 202, json={"id": "sb-1", "status": "starting"} + ) + c = Capsule(api_key=API_KEY, base_url=BASE) + assert c._kernel_name == DEFAULT_KERNEL == "wrenn" + + @respx.mock + def test_custom_kernel_name(self): + respx.post(f"{BASE}/v1/capsules").respond( + 202, json={"id": "sb-1", "status": "starting"} + ) + c = Capsule(kernel="python3", api_key=API_KEY, base_url=BASE) + assert c._kernel_name == "python3" + + +class TestCtorFailureSafe: + """Bug regression: __del__ must not crash when ctor fails before + _proxy_client is initialised.""" + + @respx.mock + def test_del_safe_when_ctor_fails(self): + respx.post(f"{BASE}/v1/capsules").respond( + 404, + json={"error": {"code": "not_found", "message": "no template"}}, + ) + from wrenn.exceptions import WrennNotFoundError + + with pytest.raises(WrennNotFoundError): + Capsule(api_key=API_KEY, base_url=BASE) + # If we got here without an AttributeError on __del__, we're good. + + @respx.mock + def test_close_idempotent(self): + c = _make_capsule() + c.close() + c.close() # second call must not raise + + +# ───────────────────────── _ensure_kernel ───────────────────────── + + +class TestEnsureKernel: + @respx.mock + def test_creates_kernel_with_wrenn_name_when_none_exist(self): + c = _make_capsule() + proxy_base = "https://8888-sb-1.app.wrenn.dev" + list_route = respx.get(f"{proxy_base}/api/kernels").respond(200, json=[]) + create_route = respx.post(f"{proxy_base}/api/kernels").respond( + 201, json={"id": "k-new", "name": "wrenn"} + ) + + kid = c._ensure_kernel() + assert kid == "k-new" + # Body must request the wrenn kernelspec. + body = json.loads(create_route.calls[0].request.content) + assert body == {"name": "wrenn"} + assert list_route.called + + @respx.mock + def test_reuses_existing_wrenn_kernel(self): + c = _make_capsule() + proxy_base = "https://8888-sb-1.app.wrenn.dev" + respx.get(f"{proxy_base}/api/kernels").respond( + 200, + json=[ + {"id": "k-other", "name": "python3"}, + {"id": "k-wrenn", "name": "wrenn"}, + ], + ) + create = respx.post(f"{proxy_base}/api/kernels").respond(201, json={}) + kid = c._ensure_kernel() + assert kid == "k-wrenn" + assert not create.called + + @respx.mock + def test_creates_when_only_other_kernels_exist(self): + c = _make_capsule() + proxy_base = "https://8888-sb-1.app.wrenn.dev" + respx.get(f"{proxy_base}/api/kernels").respond( + 200, json=[{"id": "k-other", "name": "python3"}] + ) + respx.post(f"{proxy_base}/api/kernels").respond( + 201, json={"id": "k-new", "name": "wrenn"} + ) + kid = c._ensure_kernel() + assert kid == "k-new" + + @respx.mock + def test_caches_kernel_id(self): + c = _make_capsule() + proxy_base = "https://8888-sb-1.app.wrenn.dev" + route = respx.get(f"{proxy_base}/api/kernels").respond( + 200, json=[{"id": "k-1", "name": "wrenn"}] + ) + c._ensure_kernel() + c._ensure_kernel() + assert route.call_count == 1 + + @respx.mock + def test_custom_kernel_name_sent(self): + respx.post(f"{BASE}/v1/capsules").respond( + 202, json={"id": "sb-1", "status": "starting"} + ) + c = Capsule(kernel="python3", api_key=API_KEY, base_url=BASE) + proxy_base = "https://8888-sb-1.app.wrenn.dev" + respx.get(f"{proxy_base}/api/kernels").respond(200, json=[]) + create = respx.post(f"{proxy_base}/api/kernels").respond( + 201, json={"id": "k-py", "name": "python3"} + ) + c._ensure_kernel() + body = json.loads(create.calls[0].request.content) + assert body == {"name": "python3"} + + @respx.mock + def test_retries_on_5xx_then_succeeds(self): + c = _make_capsule() + proxy_base = "https://8888-sb-1.app.wrenn.dev" + responses = [ + httpx.Response(503), + httpx.Response(200, json=[{"id": "k-1", "name": "wrenn"}]), + ] + respx.get(f"{proxy_base}/api/kernels").mock(side_effect=responses) + with patch("time.sleep"): + kid = c._ensure_kernel(jupyter_timeout=5) + assert kid == "k-1" + + @respx.mock + def test_raises_on_4xx(self): + c = _make_capsule() + proxy_base = "https://8888-sb-1.app.wrenn.dev" + respx.get(f"{proxy_base}/api/kernels").respond(401) + with pytest.raises(httpx.HTTPStatusError): + c._ensure_kernel(jupyter_timeout=2) + + @respx.mock + def test_timeout_raises(self): + c = _make_capsule() + proxy_base = "https://8888-sb-1.app.wrenn.dev" + respx.get(f"{proxy_base}/api/kernels").respond(503) + with patch("time.sleep"): + with pytest.raises(TimeoutError): + c._ensure_kernel(jupyter_timeout=0.01) + + +# ───────────────────────── _jupyter_execute_request ───────────────────────── + + +class TestJupyterRequest: + def test_structure(self): + msg = Capsule._jupyter_execute_request("print(1)") + assert msg["channel"] == "shell" + assert msg["header"]["msg_type"] == "execute_request" + assert msg["content"]["code"] == "print(1)" + assert msg["content"]["silent"] is False + assert msg["content"]["store_history"] is True + assert msg["content"]["allow_stdin"] is False + assert msg["content"]["stop_on_error"] is True + # msg_id must be a uuid-shaped string + assert len(msg["header"]["msg_id"]) == 36 + + def test_unique_msg_id_per_call(self): + a = Capsule._jupyter_execute_request("x") + b = Capsule._jupyter_execute_request("x") + assert a["header"]["msg_id"] != b["header"]["msg_id"] + + +# ───────────────────────── run_code (WS-mocked) ───────────────────────── + + +def _wrap(msg_type: str, parent_id: str, content: dict) -> dict: + return { + "msg_type": msg_type, + "header": {"msg_type": msg_type}, + "parent_header": {"msg_id": parent_id}, + "content": content, + } + + +class _FakeWS: + """Minimal sync httpx_ws-shaped fake.""" + + def __init__(self, frames_factory): + self._frames_factory = frames_factory + self._sent: list[str] = [] + self._iter = None + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def send_text(self, s: str) -> None: + self._sent.append(s) + parent_id = json.loads(s)["header"]["msg_id"] + self._iter = iter(self._frames_factory(parent_id)) + + def receive_json(self, timeout: float = 0): + assert self._iter is not None + try: + return next(self._iter) + except StopIteration: + raise TimeoutError("no more frames") + + +class _FakeAsyncWS: + def __init__(self, frames_factory): + self._frames_factory = frames_factory + self._iter = None + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def send_text(self, s: str) -> None: + parent_id = json.loads(s)["header"]["msg_id"] + self._iter = iter(self._frames_factory(parent_id)) + + async def receive_json(self, timeout: float = 0): + assert self._iter is not None + try: + return next(self._iter) + except StopIteration: + raise TimeoutError("no more frames") + + +class TestRunCode: + @respx.mock + def _make_ready(self): + c = _make_capsule() + # Pre-populate kernel so run_code skips ensure. + c._kernel_id = "k-1" + return c + + def test_stream_stdout_and_stderr(self): + c = self._make_ready() + + def frames(pid): + yield _wrap("stream", pid, {"name": "stdout", "text": "hello\n"}) + yield _wrap("stream", pid, {"name": "stderr", "text": "warn\n"}) + yield _wrap("status", pid, {"execution_state": "idle"}) + + stdout_chunks, stderr_chunks = [], [] + with patch( + "wrenn.code_runner.capsule.httpx_ws.connect_ws", + return_value=_FakeWS(frames), + ): + ex = c.run_code( + "print('hello')", + on_stdout=stdout_chunks.append, + on_stderr=stderr_chunks.append, + ) + assert ex.logs.stdout == ["hello\n"] + assert ex.logs.stderr == ["warn\n"] + assert stdout_chunks == ["hello\n"] + assert stderr_chunks == ["warn\n"] + assert ex.error is None + + def test_execute_result_main_and_display_data(self): + c = self._make_ready() + + def frames(pid): + yield _wrap( + "display_data", + pid, + {"data": {"image/png": "BASE64"}}, + ) + yield _wrap( + "execute_result", + pid, + { + "execution_count": 7, + "data": {"text/plain": "'42'"}, + }, + ) + yield _wrap("status", pid, {"execution_state": "idle"}) + + results = [] + with patch( + "wrenn.code_runner.capsule.httpx_ws.connect_ws", + return_value=_FakeWS(frames), + ): + ex = c.run_code("'42'", on_result=results.append) + assert ex.execution_count == 7 + assert len(ex.results) == 2 + main = [r for r in ex.results if r.is_main_result] + assert len(main) == 1 + assert main[0].text == "'42'" # text/plain preserved verbatim + display = [r for r in ex.results if not r.is_main_result] + assert display[0].png == "BASE64" + assert ex.text == "'42'" + assert len(results) == 2 + + def test_error_message(self): + c = self._make_ready() + + def frames(pid): + yield _wrap( + "error", + pid, + { + "ename": "NameError", + "evalue": "name 'x' is not defined", + "traceback": ["line1", "line2"], + }, + ) + yield _wrap("status", pid, {"execution_state": "idle"}) + + errors = [] + with patch( + "wrenn.code_runner.capsule.httpx_ws.connect_ws", + return_value=_FakeWS(frames), + ): + ex = c.run_code("x", on_error=errors.append) + assert ex.error is not None + assert ex.error.name == "NameError" + assert ex.error.value == "name 'x' is not defined" + assert ex.error.traceback == "line1\nline2" + assert len(errors) == 1 + + def test_ignores_frames_with_other_parent(self): + c = self._make_ready() + + def frames(pid): + yield _wrap("stream", "other-id", {"name": "stdout", "text": "drop\n"}) + yield _wrap("stream", pid, {"name": "stdout", "text": "keep\n"}) + yield _wrap("status", pid, {"execution_state": "idle"}) + + with patch( + "wrenn.code_runner.capsule.httpx_ws.connect_ws", + return_value=_FakeWS(frames), + ): + ex = c.run_code("print('keep')") + assert ex.logs.stdout == ["keep\n"] + + def test_unsupported_language_raises(self): + c = self._make_ready() + with pytest.raises(ValueError, match="not supported"): + c.run_code("console.log('x')", language="javascript") + + def test_idle_status_terminates_loop(self): + c = self._make_ready() + called = {"n": 0} + + def frames(pid): + yield _wrap("status", pid, {"execution_state": "idle"}) + # Following frame must never be consumed. + called["n"] += 1 + yield _wrap("stream", pid, {"name": "stdout", "text": "post-idle\n"}) + + with patch( + "wrenn.code_runner.capsule.httpx_ws.connect_ws", + return_value=_FakeWS(frames), + ): + ex = c.run_code("pass") + assert ex.logs.stdout == [] + + +class TestAsyncRunCode: + @respx.mock + def _make_ready(self): + respx.post(f"{BASE}/v1/capsules").respond( + 202, json={"id": "sb-1", "status": "starting"} + ) + from wrenn.client import AsyncWrennClient + from wrenn.models import Capsule as CapsuleModel + + client = AsyncWrennClient(api_key=API_KEY, base_url=BASE) + info = CapsuleModel(id="sb-1") + c = AsyncCapsule(_capsule_id="sb-1", _client=client, _info=info) + c._kernel_id = "k-1" + return c + + @pytest.mark.asyncio + async def test_stream_and_result(self): + c = self._make_ready() + + def frames(pid): + yield _wrap("stream", pid, {"name": "stdout", "text": "hi\n"}) + yield _wrap( + "execute_result", + pid, + {"execution_count": 1, "data": {"text/plain": "7"}}, + ) + yield _wrap("status", pid, {"execution_state": "idle"}) + + with patch( + "wrenn.code_runner.async_capsule.httpx_ws.aconnect_ws", + return_value=_FakeAsyncWS(frames), + ): + ex = await c.run_code("7") + assert ex.logs.stdout == ["hi\n"] + assert ex.text == "7" + assert ex.execution_count == 1 + await c.close() + + @pytest.mark.asyncio + async def test_async_default_kernel(self): + c = self._make_ready() + assert c._kernel_name == "wrenn" + await c.close() + + +class TestAsyncCtorFailureSafe: + def test_del_safe_when_not_constructed(self): + # Build without ever calling __init__'s parent path that needs network, + # by hand-poking attributes the way create() failure would leave them. + c = AsyncCapsule.__new__(AsyncCapsule) + # __del__ should be safe even with no attrs. + c.__del__() -- 2.49.0 From b2ec7f9ab31c8c7c4a794a838092b6ef51668974 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 20 May 2026 05:23:38 +0600 Subject: [PATCH 35/44] refactor: extract jupyter protocol, harden error paths, dedup git ops - code_runner: split shared Jupyter message/URL helpers into `_protocol.py`; surface kernel disconnects and run_code timeouts as ExecutionError; add gif and plotly MIME types to Result. - capsule: introduce `_build_http_proxy_url` so HTTP proxy callers stop munging ws:// URLs; `proxy_url()` now returns http(s). - _git: collapse `_run` + `_check_result` into `_run_op` across sync and async Git; drop unused `build_has_upstream`. - pty: classify unknown msg_types as non-fatal error events instead of raising ValueError. - files: add `Transfer-Encoding: chunked` to streaming uploads. - ci: remove unused Woodpecker check.yml. - tests: expand unit coverage for code_runner and capsule features. Co-Authored-By: Claude Opus 4.7 (1M context) --- .woodpecker/check.yml | 28 - docs/reference.md | 992 ++++++++++++++----------- src/wrenn/_git/__init__.py | 160 ++-- src/wrenn/_git/_cmd.py | 5 - src/wrenn/async_capsule.py | 12 +- src/wrenn/capsule.py | 26 +- src/wrenn/code_runner/_protocol.py | 51 ++ src/wrenn/code_runner/async_capsule.py | 89 +-- src/wrenn/code_runner/capsule.py | 89 +-- src/wrenn/code_runner/models.py | 28 +- src/wrenn/files.py | 6 +- src/wrenn/pty.py | 11 +- tests/test_capsule_features.py | 204 ++++- tests/test_code_runner_unit.py | 271 ++++++- 14 files changed, 1311 insertions(+), 661 deletions(-) delete mode 100644 .woodpecker/check.yml create mode 100644 src/wrenn/code_runner/_protocol.py diff --git a/.woodpecker/check.yml b/.woodpecker/check.yml deleted file mode 100644 index 6f7273b..0000000 --- a/.woodpecker/check.yml +++ /dev/null @@ -1,28 +0,0 @@ -steps: - unit-tests: - image: ghcr.io/astral-sh/uv:python3.13-bookworm - when: - event: push - path: - - "src/**" - - "tests/**" - commands: - - uv sync --dev - - uv run pytest -m "not integration" -v - - integration-tests: - image: ghcr.io/astral-sh/uv:python3.13-bookworm - when: - event: pull_request - branch: - - main - - dev - path: - - "src/**" - - "tests/**" - environment: - WRENN_API_KEY: - from_secret: WRENN_API_KEY - commands: - - uv sync --dev - - uv run pytest -m integration -v diff --git a/docs/reference.md b/docs/reference.md index 9a406df..49870ff 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -709,7 +709,8 @@ Connect to a running background process and stream its output. #### stream ```python -def stream(cmd: str, args: list[str] | None = None) -> Iterator[StreamEvent] +def stream(cmd: str, + args: builtins.list[str] | None = None) -> Iterator[StreamEvent] ``` Execute a command via WebSocket, streaming output as events. @@ -836,8 +837,9 @@ Connect to a running background process and stream its output. #### stream ```python -async def stream(cmd: str, - args: list[str] | None = None) -> AsyncIterator[StreamEvent] +async def stream( + cmd: str, + args: builtins.list[str] | None = None) -> AsyncIterator[StreamEvent] ``` Execute a command via WebSocket, streaming output as events. @@ -1271,407 +1273,28 @@ in memory. # wrenn.code\_interpreter.models - - -## ExecutionError Objects - -```python -@dataclass -class ExecutionError() -``` - -Error raised during code execution. - -**Attributes**: - -- `name` - Exception class name (e.g. ``"NameError"``). -- `value` - Exception message. -- `traceback` - Full traceback string. - - - -## Logs Objects - -```python -@dataclass -class Logs() -``` - -Captured stdout/stderr streams. - -Each element in the list is one chunk of text as it arrived from -the kernel. - - - -## Result Objects - -```python -@dataclass -class Result() -``` - -A single rich output from code execution. - -Jupyter cells can produce multiple outputs — one ``execute_result`` -(the expression value) and zero or more ``display_data`` messages -(from ``plt.show()``, ``display()``, etc.). Each becomes a -``Result``. - -Known MIME types are unpacked into named attributes; anything else -lands in :pyattr:`extra`. - - - -#### text - -``text/plain`` representation. - - - -#### html - -``text/html`` representation. - - - -#### markdown - -``text/markdown`` representation. - - - -#### svg - -``image/svg+xml`` representation. - - - -#### png - -``image/png`` — base64-encoded. - - - -#### jpeg - -``image/jpeg`` — base64-encoded. - - - -#### pdf - -``application/pdf`` — base64-encoded. - - - -#### latex - -``text/latex`` representation. - - - -#### json - -``application/json`` representation. - - - -#### javascript - -``application/javascript`` representation. - - - -#### extra - -MIME types not covered by the named fields above. - - - -#### is\_main\_result - -``True`` when this came from an ``execute_result`` message -(i.e. the value of the last expression in the cell). ``False`` -for ``display_data`` outputs. - - - -#### from\_bundle - -```python -@classmethod -def from_bundle(cls, - bundle: dict[str, str], - *, - is_main_result: bool = False) -> Result -``` - -Build a ``Result`` from a Jupyter MIME bundle dict. - - - -#### formats - -```python -def formats() -> list[str] -``` - -Return names of non-``None`` MIME-type fields. - - - -## Execution Objects - -```python -@dataclass -class Execution() -``` - -Complete result of a ``run_code`` call. - -**Attributes**: - -- `results` - All rich outputs produced by the cell — charts, tables, - images, expression values, etc. -- `logs` - Captured stdout/stderr text. -- `error` - Populated when the cell raised an exception. -- `execution_count` - Jupyter execution counter (the ``[N]`` number). - - - -#### text - -```python -@property -def text() -> str | None -``` - -Convenience — ``text/plain`` of the main ``execute_result``, -or ``None`` if the cell had no expression value. +Deprecated — use :mod:`wrenn.code_runner.models`. # wrenn.code\_interpreter.async\_capsule - - -## AsyncCapsule Objects - -```python -class AsyncCapsule(BaseAsyncCapsule) -``` - -Async code interpreter capsule with ``run_code`` support. - -Uses ``code-runner-beta`` template by default:: - -from wrenn.code_interpreter import AsyncCapsule - -capsule = await AsyncCapsule.create() -result = await capsule.run_code("print('hello')") - - - -#### create - -```python -@classmethod -async def create(cls, - template: str | None = None, - vcpus: int | None = None, - memory_mb: int | None = None, - timeout: int | None = None, - *, - wait: bool = False, - api_key: str | None = None, - base_url: str | None = None) -> AsyncCapsule -``` - -Create a new async code interpreter capsule. - -**Arguments**: - -- `template` _str | None_ - Template to boot from. Defaults to - ``"code-runner-beta"``. -- `vcpus` _int | None_ - Number of virtual CPUs. -- `memory_mb` _int | None_ - Memory in MiB. -- `timeout` _int | None_ - Inactivity TTL in seconds before auto-pause. -- `wait` _bool_ - Await until the capsule reaches ``running`` status. -- `api_key` _str | None_ - Wrenn API key. Falls back to - ``WRENN_API_KEY`` env var. -- `base_url` _str | None_ - API base URL override. - - -**Returns**: - -- `AsyncCapsule` - A new async code interpreter capsule instance. - - - -#### run\_code - -```python -async def run_code( - code: str, - language: str = "python", - timeout: float = 30, - jupyter_timeout: float = 30, - on_result: Callable[[Result], Any] | None = None, - on_stdout: Callable[[str], Any] | None = None, - on_stderr: Callable[[str], Any] | None = None, - on_error: Callable[[ExecutionError], Any] | None = None) -> Execution -``` - -Execute code in a persistent Jupyter kernel (async). - -**Arguments**: - -- `code` - Code string to execute. -- `language` - Execution backend language. Currently only ``"python"``. -- `timeout` - Maximum seconds to wait for execution to complete. -- `jupyter_timeout` - Maximum seconds to wait for Jupyter to become - available. -- `on_result` - Called for each rich output (charts, images, expression - values). -- `on_stdout` - Called for each stdout chunk. -- `on_stderr` - Called for each stderr chunk. -- `on_error` - Called when the cell raises an exception. - - -**Returns**: - - An :class:`Execution` with ``.results``, ``.logs``, ``.error``, - and a convenience ``.text`` property. +Deprecated — use :mod:`wrenn.code_runner.async_capsule`. # wrenn.code\_interpreter +Deprecated alias for :mod:`wrenn.code_runner`. + +Importing from ``wrenn.code_interpreter`` emits a ``FutureWarning``. +Use ``wrenn.code_runner`` instead. + # wrenn.code\_interpreter.capsule - - -## Capsule Objects - -```python -class Capsule(BaseCapsule) -``` - -Code interpreter capsule with ``run_code`` support. - -Uses ``code-runner-beta`` template by default:: - -from wrenn.code_interpreter import Capsule - -capsule = Capsule() -result = capsule.run_code("print('hello')") -print(result.logs.stdout) # ["hello\n"] - - - -#### \_\_init\_\_ - -```python -def __init__(template: str | None = None, - vcpus: int | None = None, - memory_mb: int | None = None, - timeout: int | None = None, - *, - api_key: str | None = None, - base_url: str | None = None, - **kwargs) -> None -``` - -Create a code interpreter capsule. - -**Arguments**: - -- `template` _str | None_ - Template to boot from. Defaults to - ``"code-runner-beta"``. -- `vcpus` _int | None_ - Number of virtual CPUs. -- `memory_mb` _int | None_ - Memory in MiB. -- `timeout` _int | None_ - Inactivity TTL in seconds before auto-pause. -- `api_key` _str | None_ - Wrenn API key. Falls back to - ``WRENN_API_KEY`` env var. -- `base_url` _str | None_ - API base URL override. - - - -#### create - -```python -@classmethod -def create(cls, - template: str | None = None, - vcpus: int | None = None, - memory_mb: int | None = None, - timeout: int | None = None, - *, - wait: bool = False, - api_key: str | None = None, - base_url: str | None = None) -> Capsule -``` - -Create a new code interpreter capsule. - -**Arguments**: - -- `template` _str | None_ - Template to boot from. Defaults to - ``"code-runner-beta"``. -- `vcpus` _int | None_ - Number of virtual CPUs. -- `memory_mb` _int | None_ - Memory in MiB. -- `timeout` _int | None_ - Inactivity TTL in seconds before auto-pause. -- `wait` _bool_ - Block until the capsule reaches ``running`` status. -- `api_key` _str | None_ - Wrenn API key. Falls back to - ``WRENN_API_KEY`` env var. -- `base_url` _str | None_ - API base URL override. - - -**Returns**: - -- `Capsule` - A new code interpreter capsule instance. - - - -#### run\_code - -```python -def run_code( - code: str, - language: str = "python", - timeout: float = 30, - jupyter_timeout: float = 30, - on_result: Callable[[Result], Any] | None = None, - on_stdout: Callable[[str], Any] | None = None, - on_stderr: Callable[[str], Any] | None = None, - on_error: Callable[[ExecutionError], Any] | None = None) -> Execution -``` - -Execute code in a persistent Jupyter kernel. - -Variables, imports, and function definitions survive across calls. - -**Arguments**: - -- `code` - Code string to execute. -- `language` - Execution backend language. Currently only ``"python"``. -- `timeout` - Maximum seconds to wait for execution to complete. -- `jupyter_timeout` - Maximum seconds to wait for Jupyter to become - available. -- `on_result` - Called for each rich output (charts, images, expression - values). -- `on_stdout` - Called for each stdout chunk. -- `on_stderr` - Called for each stderr chunk. -- `on_error` - Called when the cell raises an exception. - - -**Returns**: - - An :class:`Execution` with ``.results``, ``.logs``, ``.error``, - and a convenience ``.text`` property. +Deprecated — use :mod:`wrenn.code_runner.capsule`. @@ -1964,25 +1587,15 @@ inactivity TTL is set. #### wait\_ready ```python -async def wait_ready(timeout: float = 30) -> None +async def wait_ready(timeout: float = _DEFAULT_WAIT_TIMEOUT) -> None ``` -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**: - -- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``. - +Await until capsule status is ``running``. **Raises**: -- `TimeoutError` - If the capsule does not reach ``running`` state - within ``timeout`` seconds. -- `RuntimeError` - If the capsule enters an error, stopped, or paused - state while waiting. +- `TimeoutError` - If capsule does not reach ``running`` within ``timeout``. +- `RuntimeError` - If capsule enters error/stopped/missing while waiting. @@ -2032,7 +1645,7 @@ List all capsules belonging to the team. ```python @asynccontextmanager async def pty(cmd: str = "/bin/bash", - args: list[str] | None = None, + args: builtins.list[str] | None = None, cols: int = 80, rows: int = 24, envs: dict[str, str] | None = None, @@ -2094,7 +1707,7 @@ Reconnect to an existing PTY session by tag. def get_url(port: int) -> str ``` -Get the proxy URL for a port exposed inside this capsule. +Get the HTTP proxy URL for a port exposed inside this capsule. **Arguments**: @@ -2103,8 +1716,10 @@ Get the proxy URL for a port exposed inside this capsule. **Returns**: -- `str` - A ``wss://`` (or ``ws://``) URL that proxies to the given - port inside the capsule. +- `str` - A ``https://`` (or ``http://``) URL that proxies HTTP + requests to the given port inside the capsule. For raw + WebSocket access, see the lower-level ``_build_proxy_url`` + helper or the ``pty()`` API. @@ -2309,6 +1924,18 @@ Send SIGKILL to the PTY process. # wrenn.models.\_generated + + +## SessionResponse Objects + +```python +class SessionResponse(BaseModel) +``` + +Returned by login, activate, and switch-team. The actual auth credential +is the wrenn_sid cookie set on the response. The body carries identity +data the SPA needs to bootstrap. + ## Peaks Objects @@ -2349,6 +1976,29 @@ class Type2(StrEnum) Host type. Regular hosts are shared; BYOC hosts belong to a team. + + +## Outcome Objects + +```python +class Outcome(StrEnum) +``` + +Present for action events (capsule.* except state.changed, +template.snapshot.*). Absent for host.up/down, capsule.state.changed, +and the connected sentinel. + + + +## SSEEvent Objects + +```python +class SSEEvent(BaseModel) +``` + +Wire format of one SSE message body. The event name (`event:` line) is +the `kind` and the JSON below is the `data:` line. + # wrenn.models @@ -2536,25 +2186,15 @@ inactivity TTL is set. #### wait\_ready ```python -def wait_ready(timeout: float = 30) -> None +def wait_ready(timeout: float = _DEFAULT_WAIT_TIMEOUT) -> None ``` -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**: - -- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``. - +Block until capsule status is ``running``. **Raises**: -- `TimeoutError` - If the capsule does not reach ``running`` state - within ``timeout`` seconds. -- `RuntimeError` - If the capsule enters an error, stopped, or paused - state while waiting. +- `TimeoutError` - If capsule does not reach ``running`` within ``timeout``. +- `RuntimeError` - If capsule enters error/stopped/missing while waiting. @@ -2604,7 +2244,7 @@ List all capsules belonging to the team. ```python @contextmanager def pty(cmd: str = "/bin/bash", - args: list[str] | None = None, + args: builtins.list[str] | None = None, cols: int = 80, rows: int = 24, envs: dict[str, str] | None = None, @@ -2665,7 +2305,7 @@ Reconnect to an existing PTY session by tag. def get_url(port: int) -> str ``` -Get the proxy URL for a port exposed inside this capsule. +Get the HTTP proxy URL for a port exposed inside this capsule. **Arguments**: @@ -2674,8 +2314,10 @@ Get the proxy URL for a port exposed inside this capsule. **Returns**: -- `str` - A ``wss://`` (or ``ws://``) URL that proxies to the given - port inside the capsule. +- `str` - A ``https://`` (or ``http://``) URL that proxies HTTP + requests to the given port inside the capsule. For raw + WebSocket access, see the lower-level ``_build_proxy_url`` + helper or the ``pty()`` API. @@ -2700,6 +2342,494 @@ Create a snapshot template from this capsule's current state. - `Template` - The created snapshot template. + + +# wrenn.code\_runner.models + + + +## ExecutionError Objects + +```python +@dataclass +class ExecutionError() +``` + +Error raised during code execution. + +**Attributes**: + +- `name` - Exception class name (e.g. ``"NameError"``). +- `value` - Exception message. +- `traceback` - Full traceback string. + + + +## Logs Objects + +```python +@dataclass +class Logs() +``` + +Captured stdout/stderr streams. + +Each element in the list is one chunk of text as it arrived from +the kernel. + + + +## Result Objects + +```python +@dataclass +class Result() +``` + +A single rich output from code execution. + +Jupyter cells can produce multiple outputs — one ``execute_result`` +(the expression value) and zero or more ``display_data`` messages +(from ``plt.show()``, ``display()``, etc.). Each becomes a +``Result``. + +Known MIME types are unpacked into named attributes; anything else +lands in :pyattr:`extra`. + + + +#### text + +``text/plain`` representation. + + + +#### html + +``text/html`` representation. + + + +#### markdown + +``text/markdown`` representation. + + + +#### svg + +``image/svg+xml`` representation. + + + +#### png + +``image/png`` — base64-encoded. + + + +#### jpeg + +``image/jpeg`` — base64-encoded. + + + +#### gif + +``image/gif`` — base64-encoded. + + + +#### pdf + +``application/pdf`` — base64-encoded. + + + +#### latex + +``text/latex`` representation. + + + +#### json + +``application/json`` representation. + + + +#### javascript + +``application/javascript`` representation. + + + +#### plotly + +``application/vnd.plotly.v1+json`` representation. + + + +#### extra + +MIME types not covered by the named fields above. + + + +#### is\_main\_result + +``True`` when this came from an ``execute_result`` message +(i.e. the value of the last expression in the cell). ``False`` +for ``display_data`` outputs. + + + +#### from\_bundle + +```python +@classmethod +def from_bundle(cls, + bundle: dict[str, str], + *, + is_main_result: bool = False) -> Result +``` + +Build a ``Result`` from a Jupyter MIME bundle dict. + + + +#### formats + +```python +def formats() -> list[str] +``` + +Return names of non-``None`` MIME-type fields. + + + +## Execution Objects + +```python +@dataclass +class Execution() +``` + +Complete result of a ``run_code`` call. + +**Attributes**: + +- `results` - All rich outputs produced by the cell — charts, tables, + images, expression values, etc. +- `logs` - Captured stdout/stderr text. +- `error` - Populated when the cell raised an exception. +- `execution_count` - Jupyter execution counter (the ``[N]`` number). + + + +#### timed\_out + +``True`` when execution was cut short by the ``timeout`` parameter +(or by the kernel WebSocket dropping). Pairs with ``error`` of name +``"Timeout"`` or ``"Disconnected"``. + + + +#### text + +```python +@property +def text() -> str | None +``` + +Convenience — ``text/plain`` of the main ``execute_result``, +or ``None`` if the cell had no expression value. + + + +# wrenn.code\_runner.async\_capsule + + + +## AsyncCapsule Objects + +```python +class AsyncCapsule(BaseAsyncCapsule) +``` + +Async code runner capsule with ``run_code`` support. + +Uses ``code-runner-beta`` template and the ``wrenn`` Jupyter +kernelspec by default:: + +from wrenn.code_runner import AsyncCapsule + +capsule = await AsyncCapsule.create() +result = await capsule.run_code("print('hello')") + + + +#### create + +```python +@classmethod +async def create(cls, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + kernel: str | None = None, + wait: bool = False, + api_key: str | None = None, + base_url: str | None = None) -> AsyncCapsule +``` + +Create a new async code runner capsule. + +**Arguments**: + +- `template` _str | None_ - Template to boot from. Defaults to + ``"code-runner-beta"``. +- `vcpus` _int | None_ - Number of virtual CPUs. +- `memory_mb` _int | None_ - Memory in MiB. +- `timeout` _int | None_ - Inactivity TTL in seconds before auto-pause. +- `kernel` _str | None_ - Jupyter kernelspec name. Defaults to + ``"wrenn"``. +- `wait` _bool_ - Await until the capsule reaches ``running`` status. +- `api_key` _str | None_ - Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. +- `base_url` _str | None_ - API base URL override. + + +**Returns**: + +- `AsyncCapsule` - A new async code runner capsule instance. + + + +#### run\_code + +```python +async def run_code( + code: str, + language: str = "python", + timeout: float = 30, + jupyter_timeout: float = 30, + on_result: Callable[[Result], Any] | None = None, + on_stdout: Callable[[str], Any] | None = None, + on_stderr: Callable[[str], Any] | None = None, + on_error: Callable[[ExecutionError], Any] | None = None) -> Execution +``` + +Execute code in a persistent Jupyter kernel (async). + +**Arguments**: + +- `code` - Code string to execute. +- `language` - Execution backend language. Currently only ``"python"``. +- `timeout` - Maximum seconds to wait for execution to complete. +- `jupyter_timeout` - Maximum seconds to wait for Jupyter to become + available. +- `on_result` - Called for each rich output (charts, images, expression + values). +- `on_stdout` - Called for each stdout chunk. +- `on_stderr` - Called for each stderr chunk. +- `on_error` - Called when the cell raises an exception. + + +**Returns**: + + An :class:`Execution` with ``.results``, ``.logs``, ``.error``, + and a convenience ``.text`` property. + + + +# wrenn.code\_runner + +Code runner — execute code in persistent Jupyter kernels. + +Uses the ``code-runner-beta`` template and the ``wrenn`` Jupyter +kernelspec by default. + +Example:: + +from wrenn.code_runner import Capsule + +with Capsule(wait=True) as capsule: +result = capsule.run_code("print('hello')") +print(result.logs.stdout) + + + +# wrenn.code\_runner.capsule + + + +## Capsule Objects + +```python +class Capsule(BaseCapsule) +``` + +Code runner capsule with ``run_code`` support. + +Uses ``code-runner-beta`` template and the ``wrenn`` Jupyter +kernelspec by default:: + +from wrenn.code_runner import Capsule + +capsule = Capsule() +result = capsule.run_code("print('hello')") +print(result.logs.stdout) # ["hello\n"] + + + +#### \_\_init\_\_ + +```python +def __init__(template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + kernel: str | None = None, + api_key: str | None = None, + base_url: str | None = None, + **kwargs) -> None +``` + +Create a code runner capsule. + +**Arguments**: + +- `template` _str | None_ - Template to boot from. Defaults to + ``"code-runner-beta"``. +- `vcpus` _int | None_ - Number of virtual CPUs. +- `memory_mb` _int | None_ - Memory in MiB. +- `timeout` _int | None_ - Inactivity TTL in seconds before auto-pause. +- `kernel` _str | None_ - Jupyter kernelspec name. Defaults to + ``"wrenn"``. +- `api_key` _str | None_ - Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. +- `base_url` _str | None_ - API base URL override. + + + +#### create + +```python +@classmethod +def create(cls, + template: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + timeout: int | None = None, + *, + kernel: str | None = None, + wait: bool = False, + api_key: str | None = None, + base_url: str | None = None) -> Capsule +``` + +Create a new code runner capsule. + +**Arguments**: + +- `template` _str | None_ - Template to boot from. Defaults to + ``"code-runner-beta"``. +- `vcpus` _int | None_ - Number of virtual CPUs. +- `memory_mb` _int | None_ - Memory in MiB. +- `timeout` _int | None_ - Inactivity TTL in seconds before auto-pause. +- `kernel` _str | None_ - Jupyter kernelspec name. Defaults to + ``"wrenn"``. +- `wait` _bool_ - Block until the capsule reaches ``running`` status. +- `api_key` _str | None_ - Wrenn API key. Falls back to + ``WRENN_API_KEY`` env var. +- `base_url` _str | None_ - API base URL override. + + +**Returns**: + +- `Capsule` - A new code runner capsule instance. + + + +#### run\_code + +```python +def run_code( + code: str, + language: str = "python", + timeout: float = 30, + jupyter_timeout: float = 30, + on_result: Callable[[Result], Any] | None = None, + on_stdout: Callable[[str], Any] | None = None, + on_stderr: Callable[[str], Any] | None = None, + on_error: Callable[[ExecutionError], Any] | None = None) -> Execution +``` + +Execute code in a persistent Jupyter kernel. + +Variables, imports, and function definitions survive across calls. + +**Arguments**: + +- `code` - Code string to execute. +- `language` - Execution backend language. Currently only ``"python"`` + is supported; passing anything else raises ``ValueError``. + To target a non-Python kernel, set ``kernel=`` on the + capsule constructor. +- `timeout` - Maximum seconds to wait for execution to complete. +- `jupyter_timeout` - Maximum seconds to wait for Jupyter to become + available. +- `on_result` - Called for each rich output (charts, images, expression + values). +- `on_stdout` - Called for each stdout chunk. +- `on_stderr` - Called for each stderr chunk. +- `on_error` - Called when the cell raises an exception. + + +**Returns**: + + An :class:`Execution` with ``.results``, ``.logs``, ``.error``, + and a convenience ``.text`` property. + + + +# wrenn.code\_runner.\_protocol + +Shared Jupyter protocol helpers used by both sync and async capsules. + +Pure functions only — no I/O, no sync/async coupling. + + + +#### build\_execute\_request + +```python +def build_execute_request(code: str) -> dict +``` + +Build a Jupyter ``execute_request`` message envelope. + +**Returns**: + +- `dict` - A fully-formed Jupyter shell-channel message ready to be + JSON-serialized over the kernel WebSocket. The caller is + expected to read ``msg["header"]["msg_id"]`` to correlate + responses. + + + +#### build\_ws\_url + +```python +def build_ws_url(base_url: str, capsule_id: str, kernel_id: str) -> str +``` + +Build the Jupyter kernel WebSocket URL for the given capsule. + # wrenn.\_config @@ -3158,16 +3288,6 @@ def build_config_get(key: str, Build ``git config --get`` arguments. - - -#### build\_has\_upstream - -```python -def build_has_upstream() -> list[str] -``` - -Build arguments to check if current branch has upstream tracking. - #### parse\_status diff --git a/src/wrenn/_git/__init__.py b/src/wrenn/_git/__init__.py index fa59564..05d2722 100644 --- a/src/wrenn/_git/__init__.py +++ b/src/wrenn/_git/__init__.py @@ -153,6 +153,20 @@ class Git: timeout=timeout, ) + def _run_op( + self, + argv: list[str], + *, + op: str, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """``_run`` + :func:`_check_result` in one call. Raises on failure.""" + result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op=op) + return result + # ── Repository setup ─────────────────────────────────────── def clone( @@ -203,8 +217,7 @@ class Git: clone_url = embed_credentials(url, username, password) argv = build_clone(clone_url, dest, branch=branch, depth=depth) - result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="clone") + result = self._run_op(argv, op="clone", cwd=cwd, envs=envs, timeout=timeout) if username and password and not dangerously_store_credentials: sanitized = strip_credentials(clone_url) @@ -248,8 +261,7 @@ class Git: GitCommandError: If init failed. """ argv = build_init(path, bare=bare, initial_branch=initial_branch) - result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="init") + result = self._run_op(argv, op="init", cwd=cwd, envs=envs, timeout=timeout) return result # ── Staging and committing ───────────────────────────────── @@ -280,8 +292,7 @@ class Git: GitCommandError: If add failed. """ argv = build_add(paths, all=all) - result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="add") + result = self._run_op(argv, op="add", cwd=cwd, envs=envs, timeout=timeout) return result def commit( @@ -318,8 +329,7 @@ class Git: author_name=author_name, author_email=author_email, ) - result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="commit") + result = self._run_op(argv, op="commit", cwd=cwd, envs=envs, timeout=timeout) return result # ── Remote sync ──────────────────────────────────────────── @@ -375,8 +385,7 @@ class Git: ) argv = build_push(remote, branch, force=force, set_upstream=set_upstream) - result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="push") + result = self._run_op(argv, op="push", cwd=cwd, envs=envs, timeout=timeout) return result def pull( @@ -430,8 +439,7 @@ class Git: ) argv = build_pull(remote, branch, rebase=rebase, ff_only=ff_only) - result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="pull") + result = self._run_op(argv, op="pull", cwd=cwd, envs=envs, timeout=timeout) return result # ── Status and branches ──────────────────────────────────── @@ -456,8 +464,9 @@ class Git: Raises: GitCommandError: If the command failed. """ - result = self._run(build_status(), cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="status") + result = self._run_op( + build_status(), op="status", cwd=cwd, envs=envs, timeout=timeout + ) return parse_status(result.stdout) def branches( @@ -480,8 +489,9 @@ class Git: Raises: GitCommandError: If the command failed. """ - result = self._run(build_branches(), cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="branches") + result = self._run_op( + build_branches(), op="branches", cwd=cwd, envs=envs, timeout=timeout + ) return parse_branches(result.stdout) def create_branch( @@ -509,8 +519,9 @@ class Git: GitCommandError: If the command failed. """ argv = build_create_branch(name, start_point=start_point) - result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="create_branch") + result = self._run_op( + argv, op="create_branch", cwd=cwd, envs=envs, timeout=timeout + ) return result def checkout_branch( @@ -536,8 +547,9 @@ class Git: GitCommandError: If the command failed. """ argv = build_checkout(name) - result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="checkout_branch") + result = self._run_op( + argv, op="checkout_branch", cwd=cwd, envs=envs, timeout=timeout + ) return result def delete_branch( @@ -565,8 +577,9 @@ class Git: GitCommandError: If the command failed. """ argv = build_delete_branch(name, force=force) - result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="delete_branch") + result = self._run_op( + argv, op="delete_branch", cwd=cwd, envs=envs, timeout=timeout + ) return result # ── Remotes ──────────────────────────────────────────────── @@ -598,8 +611,9 @@ class Git: GitCommandError: If the command failed. """ argv = build_remote_add(name, url, fetch=fetch) - result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="remote_add") + result = self._run_op( + argv, op="remote_add", cwd=cwd, envs=envs, timeout=timeout + ) return result def remote_get( @@ -661,8 +675,7 @@ class Git: GitCommandError: If the command failed. """ argv = build_reset(mode=mode, ref=ref, paths=paths) - result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="reset") + result = self._run_op(argv, op="reset", cwd=cwd, envs=envs, timeout=timeout) return result def restore( @@ -694,8 +707,7 @@ class Git: GitCommandError: If the command failed. """ argv = build_restore(paths, staged=staged, worktree=worktree, source=source) - result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="restore") + result = self._run_op(argv, op="restore", cwd=cwd, envs=envs, timeout=timeout) return result # ── Configuration ────────────────────────────────────────── @@ -729,8 +741,9 @@ class Git: GitCommandError: If the command failed. """ argv = build_config_set(key, value, scope=scope, repo_path=cwd) - result = self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="set_config") + result = self._run_op( + argv, op="set_config", cwd=cwd, envs=envs, timeout=timeout + ) return result def get_config( @@ -957,6 +970,20 @@ class AsyncGit: timeout=timeout, ) + async def _run_op( + self, + argv: list[str], + *, + op: str, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | None = 30, + ) -> CommandResult: + """``_run`` + :func:`_check_result` in one call. Raises on failure.""" + result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) + _check_result(result, op=op) + return result + # ── Repository setup ─────────────────────────────────────── async def clone( @@ -984,8 +1011,9 @@ class AsyncGit: clone_url = embed_credentials(url, username, password) argv = build_clone(clone_url, dest, branch=branch, depth=depth) - result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="clone") + result = await self._run_op( + argv, op="clone", cwd=cwd, envs=envs, timeout=timeout + ) if username and password and not dangerously_store_credentials: sanitized = strip_credentials(clone_url) @@ -1014,8 +1042,9 @@ class AsyncGit: ) -> CommandResult: """Initialize a new git repository.""" argv = build_init(path, bare=bare, initial_branch=initial_branch) - result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="init") + result = await self._run_op( + argv, op="init", cwd=cwd, envs=envs, timeout=timeout + ) return result # ── Staging and committing ───────────────────────────────── @@ -1031,8 +1060,7 @@ class AsyncGit: ) -> CommandResult: """Stage files for commit.""" argv = build_add(paths, all=all) - result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="add") + result = await self._run_op(argv, op="add", cwd=cwd, envs=envs, timeout=timeout) return result async def commit( @@ -1053,8 +1081,9 @@ class AsyncGit: author_name=author_name, author_email=author_email, ) - result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="commit") + result = await self._run_op( + argv, op="commit", cwd=cwd, envs=envs, timeout=timeout + ) return result # ── Remote sync ──────────────────────────────────────────── @@ -1095,8 +1124,9 @@ class AsyncGit: ) argv = build_push(remote, branch, force=force, set_upstream=set_upstream) - result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="push") + result = await self._run_op( + argv, op="push", cwd=cwd, envs=envs, timeout=timeout + ) return result async def pull( @@ -1135,8 +1165,9 @@ class AsyncGit: ) argv = build_pull(remote, branch, rebase=rebase, ff_only=ff_only) - result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="pull") + result = await self._run_op( + argv, op="pull", cwd=cwd, envs=envs, timeout=timeout + ) return result # ── Status and branches ──────────────────────────────────── @@ -1149,8 +1180,9 @@ class AsyncGit: timeout: int | None = 30, ) -> GitStatus: """Get repository status.""" - result = await self._run(build_status(), cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="status") + result = await self._run_op( + build_status(), op="status", cwd=cwd, envs=envs, timeout=timeout + ) return parse_status(result.stdout) async def branches( @@ -1161,8 +1193,9 @@ class AsyncGit: timeout: int | None = 30, ) -> list[GitBranch]: """List local branches.""" - result = await self._run(build_branches(), cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="branches") + result = await self._run_op( + build_branches(), op="branches", cwd=cwd, envs=envs, timeout=timeout + ) return parse_branches(result.stdout) async def create_branch( @@ -1176,8 +1209,9 @@ class AsyncGit: ) -> CommandResult: """Create and check out a new branch.""" argv = build_create_branch(name, start_point=start_point) - result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="create_branch") + result = await self._run_op( + argv, op="create_branch", cwd=cwd, envs=envs, timeout=timeout + ) return result async def checkout_branch( @@ -1190,8 +1224,9 @@ class AsyncGit: ) -> CommandResult: """Check out an existing branch.""" argv = build_checkout(name) - result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="checkout_branch") + result = await self._run_op( + argv, op="checkout_branch", cwd=cwd, envs=envs, timeout=timeout + ) return result async def delete_branch( @@ -1205,8 +1240,9 @@ class AsyncGit: ) -> CommandResult: """Delete a branch.""" argv = build_delete_branch(name, force=force) - result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="delete_branch") + result = await self._run_op( + argv, op="delete_branch", cwd=cwd, envs=envs, timeout=timeout + ) return result # ── Remotes ──────────────────────────────────────────────── @@ -1223,8 +1259,9 @@ class AsyncGit: ) -> CommandResult: """Add a remote.""" argv = build_remote_add(name, url, fetch=fetch) - result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="remote_add") + result = await self._run_op( + argv, op="remote_add", cwd=cwd, envs=envs, timeout=timeout + ) return result async def remote_get( @@ -1258,8 +1295,9 @@ class AsyncGit: ) -> CommandResult: """Reset the current HEAD.""" argv = build_reset(mode=mode, ref=ref, paths=paths) - result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="reset") + result = await self._run_op( + argv, op="reset", cwd=cwd, envs=envs, timeout=timeout + ) return result async def restore( @@ -1275,8 +1313,9 @@ class AsyncGit: ) -> CommandResult: """Restore working-tree files or unstage changes.""" argv = build_restore(paths, staged=staged, worktree=worktree, source=source) - result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="restore") + result = await self._run_op( + argv, op="restore", cwd=cwd, envs=envs, timeout=timeout + ) return result # ── Configuration ────────────────────────────────────────── @@ -1293,8 +1332,9 @@ class AsyncGit: ) -> CommandResult: """Set a git config value.""" argv = build_config_set(key, value, scope=scope, repo_path=cwd) - result = await self._run(argv, cwd=cwd, envs=envs, timeout=timeout) - _check_result(result, op="set_config") + result = await self._run_op( + argv, op="set_config", cwd=cwd, envs=envs, timeout=timeout + ) return result async def get_config( diff --git a/src/wrenn/_git/_cmd.py b/src/wrenn/_git/_cmd.py index 8e929bf..45bf595 100644 --- a/src/wrenn/_git/_cmd.py +++ b/src/wrenn/_git/_cmd.py @@ -351,11 +351,6 @@ def build_config_get( return args -def build_has_upstream() -> list[str]: - """Build arguments to check if current branch has upstream tracking.""" - return ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"] - - # ── Parsers ──────────────────────────────────────────────────────── diff --git a/src/wrenn/async_capsule.py b/src/wrenn/async_capsule.py index 4cf4c96..f091649 100644 --- a/src/wrenn/async_capsule.py +++ b/src/wrenn/async_capsule.py @@ -18,7 +18,7 @@ from wrenn.capsule import ( _RESUME_INTERVAL, _START_INTERVAL, _DualMethod, - _build_proxy_url, + _build_http_proxy_url, ) from wrenn.client import AsyncWrennClient from wrenn.commands import AsyncCommands @@ -423,16 +423,18 @@ class AsyncCapsule: # ── Proxy helpers ─────────────────────────────────────────── def get_url(self, port: int) -> str: - """Get the proxy URL for a port exposed inside this capsule. + """Get the HTTP proxy URL for a port exposed inside this capsule. Args: port (int): Port number to proxy. Returns: - str: A ``wss://`` (or ``ws://``) URL that proxies to the given - port inside the capsule. + str: A ``https://`` (or ``http://``) URL that proxies HTTP + requests to the given port inside the capsule. For raw + WebSocket access, see the lower-level ``_build_proxy_url`` + helper or the ``pty()`` API. """ - return _build_proxy_url(self._client._base_url, self._id, port) + return _build_http_proxy_url(self._client._base_url, self._id, port) # ── Snapshots ─────────────────────────────────────────────── diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py index f533205..a5545d5 100644 --- a/src/wrenn/capsule.py +++ b/src/wrenn/capsule.py @@ -21,6 +21,7 @@ from wrenn.pty import PtySession def _build_proxy_url(base_url: str, capsule_id: str | None, port: int) -> str: + """Build the WebSocket proxy URL (``ws://`` / ``wss://``).""" parsed = httpx.URL(base_url) host = parsed.host if parsed.port: @@ -29,6 +30,21 @@ def _build_proxy_url(base_url: str, capsule_id: str | None, port: int) -> str: return f"{scheme}://{port}-{capsule_id}.{host}" +def _build_http_proxy_url(base_url: str, capsule_id: str | None, port: int) -> str: + """Build the HTTP proxy URL (``http://`` / ``https://``). + + The capsule's API base URL typically carries an ``/api`` path suffix + (e.g. ``https://app.wrenn.dev/api``). The proxy host is derived from + the URL's host only — any path is discarded. + """ + parsed = httpx.URL(base_url) + host = parsed.host + if parsed.port: + host = f"{host}:{parsed.port}" + scheme = "http" if parsed.scheme in ("http", "ws") else "https" + return f"{scheme}://{port}-{capsule_id}.{host}" + + _RESUME_INTERVAL = 0.5 _DESTROY_INTERVAL = 0.5 _PAUSE_INTERVAL = 2.0 @@ -499,16 +515,18 @@ class Capsule: # ── Proxy helpers ─────────────────────────────────────────── def get_url(self, port: int) -> str: - """Get the proxy URL for a port exposed inside this capsule. + """Get the HTTP proxy URL for a port exposed inside this capsule. Args: port (int): Port number to proxy. Returns: - str: A ``wss://`` (or ``ws://``) URL that proxies to the given - port inside the capsule. + str: A ``https://`` (or ``http://``) URL that proxies HTTP + requests to the given port inside the capsule. For raw + WebSocket access, see the lower-level ``_build_proxy_url`` + helper or the ``pty()`` API. """ - return _build_proxy_url(self._client._base_url, self._id, port) + return _build_http_proxy_url(self._client._base_url, self._id, port) # ── Snapshots ─────────────────────────────────────────────── diff --git a/src/wrenn/code_runner/_protocol.py b/src/wrenn/code_runner/_protocol.py new file mode 100644 index 0000000..42b5978 --- /dev/null +++ b/src/wrenn/code_runner/_protocol.py @@ -0,0 +1,51 @@ +"""Shared Jupyter protocol helpers used by both sync and async capsules. + +Pure functions only — no I/O, no sync/async coupling. +""" + +from __future__ import annotations + +import time +import uuid + +from wrenn.capsule import _build_proxy_url + + +def build_execute_request(code: str) -> dict: + """Build a Jupyter ``execute_request`` message envelope. + + Returns: + dict: A fully-formed Jupyter shell-channel message ready to be + JSON-serialized over the kernel WebSocket. The caller is + expected to read ``msg["header"]["msg_id"]`` to correlate + responses. + """ + msg_id = str(uuid.uuid4()) + return { + "header": { + "msg_id": msg_id, + "msg_type": "execute_request", + "username": "wrenn-sdk", + "session": str(uuid.uuid4()), + "date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), + "version": "5.3", + }, + "parent_header": {}, + "metadata": {}, + "content": { + "code": code, + "silent": False, + "store_history": True, + "user_expressions": {}, + "allow_stdin": False, + "stop_on_error": True, + }, + "buffers": [], + "channel": "shell", + } + + +def build_ws_url(base_url: str, capsule_id: str, kernel_id: str) -> str: + """Build the Jupyter kernel WebSocket URL for the given capsule.""" + proxy = _build_proxy_url(base_url, capsule_id, 8888) + return f"{proxy}/api/kernels/{kernel_id}/channels" diff --git a/src/wrenn/code_runner/async_capsule.py b/src/wrenn/code_runner/async_capsule.py index b8607b3..e11dca0 100644 --- a/src/wrenn/code_runner/async_capsule.py +++ b/src/wrenn/code_runner/async_capsule.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio import json import time -import uuid from collections.abc import Callable from typing import Any @@ -11,8 +10,9 @@ import httpx import httpx_ws from wrenn.async_capsule import AsyncCapsule as BaseAsyncCapsule -from wrenn.capsule import _build_proxy_url +from wrenn.capsule import _build_http_proxy_url from wrenn.client import AsyncWrennClient +from wrenn.code_runner._protocol import build_execute_request, build_ws_url from wrenn.code_runner.capsule import DEFAULT_KERNEL, DEFAULT_TEMPLATE from wrenn.code_runner.models import ( Execution, @@ -110,11 +110,7 @@ class AsyncCapsule(BaseAsyncCapsule): def _get_proxy_client(self) -> httpx.AsyncClient: if self._proxy_client is None: - url = ( - _build_proxy_url(self._client._base_url, self._id, 8888) - .replace("ws://", "http://") - .replace("wss://", "https://") - ) + url = _build_http_proxy_url(self._client._base_url, self._id, 8888) self._proxy_client = httpx.AsyncClient( base_url=url, headers={"X-API-Key": self._client._api_key}, @@ -164,36 +160,6 @@ class AsyncCapsule(BaseAsyncCapsule): f"Jupyter not available within {jupyter_timeout}s: {last_exc}" ) - def _jupyter_ws_url(self, kernel_id: str) -> str: - proxy = _build_proxy_url(self._client._base_url, self._id, 8888) - return f"{proxy}/api/kernels/{kernel_id}/channels" - - @staticmethod - def _jupyter_execute_request(code: str) -> dict: - msg_id = str(uuid.uuid4()) - return { - "header": { - "msg_id": msg_id, - "msg_type": "execute_request", - "username": "wrenn-sdk", - "session": str(uuid.uuid4()), - "date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), - "version": "5.3", - }, - "parent_header": {}, - "metadata": {}, - "content": { - "code": code, - "silent": False, - "store_history": True, - "user_expressions": {}, - "allow_stdin": False, - "stop_on_error": True, - }, - "buffers": [], - "channel": "shell", - } - async def run_code( self, code: str, @@ -230,24 +196,42 @@ class AsyncCapsule(BaseAsyncCapsule): "non-Python kernelspec." ) kernel_id = await self._ensure_kernel(jupyter_timeout=jupyter_timeout) - ws_url = self._jupyter_ws_url(kernel_id) + ws_url = build_ws_url(self._client._base_url, self._id, kernel_id) - msg = self._jupyter_execute_request(code) + msg = build_execute_request(code) msg_id = msg["header"]["msg_id"] execution = Execution() deadline = time.monotonic() + timeout headers = {"X-API-Key": self._client._api_key} + saw_idle = False + + def _emit_error(err: ExecutionError) -> None: + execution.error = err + if on_error is not None: + on_error(err) async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.AsyncWebSocketSession await ws.send_text(json.dumps(msg)) - while time.monotonic() < deadline: + while True: time_left = deadline - time.monotonic() if time_left <= 0: break try: data = await asyncio.wait_for(ws.receive_json(), timeout=time_left) - except Exception: + except (asyncio.TimeoutError, TimeoutError): + break + except ( + httpx_ws.WebSocketDisconnect, + httpx_ws.WebSocketNetworkError, + ) as exc: + execution.timed_out = True + _emit_error( + ExecutionError( + name="Disconnected", + value=f"kernel WebSocket closed: {exc}", + ) + ) break if not data: break @@ -280,17 +264,26 @@ class AsyncCapsule(BaseAsyncCapsule): if on_result is not None: on_result(result) elif msg_type == "error": - err = ExecutionError( - name=content.get("ename", ""), - value=content.get("evalue", ""), - traceback="\n".join(content.get("traceback", [])), + _emit_error( + ExecutionError( + name=content.get("ename", ""), + value=content.get("evalue", ""), + traceback="\n".join(content.get("traceback", [])), + ) ) - execution.error = err - if on_error is not None: - on_error(err) elif msg_type == "status" and content.get("execution_state") == "idle": + saw_idle = True break + if not saw_idle and execution.error is None: + execution.timed_out = True + _emit_error( + ExecutionError( + name="Timeout", + value=f"run_code exceeded {timeout}s", + ) + ) + return execution async def __aexit__(self, *args) -> None: diff --git a/src/wrenn/code_runner/capsule.py b/src/wrenn/code_runner/capsule.py index cac94b0..782e812 100644 --- a/src/wrenn/code_runner/capsule.py +++ b/src/wrenn/code_runner/capsule.py @@ -2,7 +2,6 @@ from __future__ import annotations import json import time -import uuid from collections.abc import Callable from typing import Any @@ -10,7 +9,8 @@ import httpx import httpx_ws from wrenn.capsule import Capsule as BaseCapsule -from wrenn.capsule import _build_proxy_url +from wrenn.capsule import _build_http_proxy_url +from wrenn.code_runner._protocol import build_execute_request, build_ws_url from wrenn.code_runner.models import ( Execution, ExecutionError, @@ -138,11 +138,7 @@ class Capsule(BaseCapsule): def _get_proxy_client(self) -> httpx.Client: if self._proxy_client is None: - url = ( - _build_proxy_url(self._client._base_url, self._id, 8888) - .replace("ws://", "http://") - .replace("wss://", "https://") - ) + url = _build_http_proxy_url(self._client._base_url, self._id, 8888) self._proxy_client = httpx.Client( base_url=url, headers={"X-API-Key": self._client._api_key}, @@ -194,36 +190,6 @@ class Capsule(BaseCapsule): f"Jupyter not available within {jupyter_timeout}s: {last_exc}" ) - def _jupyter_ws_url(self, kernel_id: str) -> str: - proxy = _build_proxy_url(self._client._base_url, self._id, 8888) - return f"{proxy}/api/kernels/{kernel_id}/channels" - - @staticmethod - def _jupyter_execute_request(code: str) -> dict: - msg_id = str(uuid.uuid4()) - return { - "header": { - "msg_id": msg_id, - "msg_type": "execute_request", - "username": "wrenn-sdk", - "session": str(uuid.uuid4()), - "date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), - "version": "5.3", - }, - "parent_header": {}, - "metadata": {}, - "content": { - "code": code, - "silent": False, - "store_history": True, - "user_expressions": {}, - "allow_stdin": False, - "stop_on_error": True, - }, - "buffers": [], - "channel": "shell", - } - def run_code( self, code: str, @@ -265,24 +231,42 @@ class Capsule(BaseCapsule): "non-Python kernelspec." ) kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout) - ws_url = self._jupyter_ws_url(kernel_id) + ws_url = build_ws_url(self._client._base_url, self._id, kernel_id) - msg = self._jupyter_execute_request(code) + msg = build_execute_request(code) msg_id = msg["header"]["msg_id"] execution = Execution() deadline = time.monotonic() + timeout headers = {"X-API-Key": self._client._api_key} + saw_idle = False + + def _emit_error(err: ExecutionError) -> None: + execution.error = err + if on_error is not None: + on_error(err) with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.WebSocketSession ws.send_text(json.dumps(msg)) - while time.monotonic() < deadline: + while True: time_left = deadline - time.monotonic() if time_left <= 0: break try: data = ws.receive_json(timeout=time_left) - except Exception: + except TimeoutError: + break + except ( + httpx_ws.WebSocketDisconnect, + httpx_ws.WebSocketNetworkError, + ) as exc: + execution.timed_out = True + _emit_error( + ExecutionError( + name="Disconnected", + value=f"kernel WebSocket closed: {exc}", + ) + ) break if not data: break @@ -315,17 +299,26 @@ class Capsule(BaseCapsule): if on_result is not None: on_result(result) elif msg_type == "error": - err = ExecutionError( - name=content.get("ename", ""), - value=content.get("evalue", ""), - traceback="\n".join(content.get("traceback", [])), + _emit_error( + ExecutionError( + name=content.get("ename", ""), + value=content.get("evalue", ""), + traceback="\n".join(content.get("traceback", [])), + ) ) - execution.error = err - if on_error is not None: - on_error(err) elif msg_type == "status" and content.get("execution_state") == "idle": + saw_idle = True break + if not saw_idle and execution.error is None: + execution.timed_out = True + _emit_error( + ExecutionError( + name="Timeout", + value=f"run_code exceeded {timeout}s", + ) + ) + return execution def __exit__(self, *args) -> None: diff --git a/src/wrenn/code_runner/models.py b/src/wrenn/code_runner/models.py index 39a1e64..42d40ca 100644 --- a/src/wrenn/code_runner/models.py +++ b/src/wrenn/code_runner/models.py @@ -9,10 +9,12 @@ _MIME_MAP: dict[str, str] = { "image/svg+xml": "svg", "image/png": "png", "image/jpeg": "jpeg", + "image/gif": "gif", "application/pdf": "pdf", "text/latex": "latex", "application/json": "json", "application/javascript": "javascript", + "application/vnd.plotly.v1+json": "plotly", } @@ -69,6 +71,8 @@ class Result: """``image/png`` — base64-encoded.""" jpeg: str | None = None """``image/jpeg`` — base64-encoded.""" + gif: str | None = None + """``image/gif`` — base64-encoded.""" pdf: str | None = None """``application/pdf`` — base64-encoded.""" latex: str | None = None @@ -77,6 +81,8 @@ class Result: """``application/json`` representation.""" javascript: str | None = None """``application/javascript`` representation.""" + plotly: dict | None = None + """``application/vnd.plotly.v1+json`` representation.""" extra: dict[str, str] | None = None """MIME types not covered by the named fields above.""" @@ -104,21 +110,9 @@ class Result: def formats(self) -> list[str]: """Return names of non-``None`` MIME-type fields.""" - out: list[str] = [] - for attr in ( - "text", - "html", - "markdown", - "svg", - "png", - "jpeg", - "pdf", - "latex", - "json", - "javascript", - ): - if getattr(self, attr) is not None: - out.append(attr) + out: list[str] = [ + attr for attr in _MIME_MAP.values() if getattr(self, attr) is not None + ] if self.extra: out.extend(self.extra) return out @@ -140,6 +134,10 @@ class Execution: logs: Logs = field(default_factory=Logs) error: ExecutionError | None = None execution_count: int | None = None + timed_out: bool = False + """``True`` when execution was cut short by the ``timeout`` parameter + (or by the kernel WebSocket dropping). Pairs with ``error`` of name + ``"Timeout"`` or ``"Disconnected"``.""" @property def text(self) -> str | None: diff --git a/src/wrenn/files.py b/src/wrenn/files.py index 5a99289..291ff8b 100644 --- a/src/wrenn/files.py +++ b/src/wrenn/files.py @@ -199,7 +199,8 @@ class Files: f"/v1/capsules/{self._capsule_id}/files/stream/write", content=_multipart(), headers={ - "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}" + "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}", + "Transfer-Encoding": "chunked", }, ) _raise_for_status(resp) @@ -392,7 +393,8 @@ class AsyncFiles: f"/v1/capsules/{self._capsule_id}/files/stream/write", content=_multipart(), headers={ - "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}" + "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}", + "Transfer-Encoding": "chunked", }, ) _raise_for_status(resp) diff --git a/src/wrenn/pty.py b/src/wrenn/pty.py index 63dd26f..0b7ff77 100644 --- a/src/wrenn/pty.py +++ b/src/wrenn/pty.py @@ -53,7 +53,16 @@ def _parse_pty_event(raw: dict[str, Any]) -> PtyEvent: ) if msg_type == "ping": return PtyEvent(type=PtyEventType.ping) - return PtyEvent(type=PtyEventType(msg_type) if msg_type else PtyEventType.ping) + if not msg_type: + return PtyEvent(type=PtyEventType.ping) + try: + return PtyEvent(type=PtyEventType(msg_type)) + except ValueError: + return PtyEvent( + type=PtyEventType.error, + data=f"unknown msg_type: {msg_type!r}", + fatal=False, + ) class PtySession: diff --git a/tests/test_capsule_features.py b/tests/test_capsule_features.py index 7cfe624..186d247 100644 --- a/tests/test_capsule_features.py +++ b/tests/test_capsule_features.py @@ -1,12 +1,14 @@ from __future__ import annotations import httpx +import pytest import respx -from wrenn.capsule import Capsule, _build_proxy_url +from wrenn.capsule import Capsule, _build_http_proxy_url, _build_proxy_url from wrenn.code_runner.models import Execution, ExecutionError, Logs, Result BASE = "https://app.wrenn.dev/api" +API_KEY = "wrn_test1234567890abcdef12345678" class TestBuildProxyUrl: @@ -27,6 +29,23 @@ class TestBuildProxyUrl: assert url == "ws://5000-sb-2.192.168.1.1" +class TestBuildHttpProxyUrl: + """``get_url`` returns an HTTP(S) URL; ``/api`` path on the base URL is + discarded — only the host is used to build the proxy subdomain.""" + + def test_https_production_strips_api_path(self): + url = _build_http_proxy_url("https://app.wrenn.dev/api", "cl-abc", 8080) + assert url == "https://8080-cl-abc.app.wrenn.dev" + + def test_http_localhost_preserves_port(self): + url = _build_http_proxy_url("http://localhost:8080/api", "cl-abc", 3000) + assert url == "http://3000-cl-abc.localhost:8080" + + def test_https_custom_port(self): + url = _build_http_proxy_url("https://api.example.com:9443", "sb-1", 80) + assert url == "https://80-sb-1.api.example.com:9443" + + class TestCapsuleCreate: @respx.mock def test_capsule_constructor_creates(self): @@ -194,6 +213,189 @@ class TestExecutionModels: assert "".join(logs.stderr) == "warn\n" +class TestGetUrlPublic: + """``Capsule.get_url`` returns the HTTP proxy URL.""" + + @respx.mock + def test_sync_get_url_default_base(self): + respx.post(f"{BASE}/v1/capsules").respond( + 202, json={"id": "cl-99", "status": "starting"} + ) + cap = Capsule(api_key=API_KEY, base_url=BASE) + assert cap.get_url(8080) == "https://8080-cl-99.app.wrenn.dev" + + @respx.mock + def test_sync_get_url_localhost(self): + local_base = "http://localhost:8080/api" + respx.post(f"{local_base}/v1/capsules").respond( + 202, json={"id": "cl-42", "status": "starting"} + ) + cap = Capsule(api_key=API_KEY, base_url=local_base) + assert cap.get_url(3000) == "http://3000-cl-42.localhost:8080" + + @pytest.mark.asyncio + @respx.mock + async def test_async_get_url(self): + from wrenn.async_capsule import AsyncCapsule + + respx.post(f"{BASE}/v1/capsules").respond( + 202, json={"id": "cl-async", "status": "starting"} + ) + cap = await AsyncCapsule.create(api_key=API_KEY, base_url=BASE) + assert cap.get_url(5000) == "https://5000-cl-async.app.wrenn.dev" + await cap._client.aclose() + + +class TestPtyConnect: + """``pty_connect`` reconnects to an existing PTY session by tag.""" + + def _capsule(self): + with respx.mock: + respx.post(f"{BASE}/v1/capsules").respond( + 202, json={"id": "cl-1", "status": "starting"} + ) + return Capsule(api_key=API_KEY, base_url=BASE) + + def test_sync_pty_connect_sends_connect_frame(self): + from unittest.mock import MagicMock, patch + + cap = self._capsule() + ws = MagicMock() + ctx = MagicMock() + ctx.__enter__.return_value = ws + ctx.__exit__.return_value = False + + with patch("wrenn.capsule.httpx_ws.connect_ws", return_value=ctx): + with cap.pty_connect("tag-xyz") as session: + assert session is not None + # First send_text call must be a ``connect`` frame with the tag. + import json as _json + + sent = ws.send_text.call_args_list[0].args[0] + payload = _json.loads(sent) + assert payload == {"type": "connect", "tag": "tag-xyz"} + + @pytest.mark.asyncio + @respx.mock + async def test_async_pty_connect_sends_connect_frame(self): + from unittest.mock import AsyncMock, MagicMock, patch + + from wrenn.async_capsule import AsyncCapsule + + respx.post(f"{BASE}/v1/capsules").respond( + 202, json={"id": "cl-1", "status": "starting"} + ) + cap = await AsyncCapsule.create(api_key=API_KEY, base_url=BASE) + ws = MagicMock() + ws.send_text = AsyncMock() + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=ws) + ctx.__aexit__ = AsyncMock(return_value=False) + + with patch("wrenn.async_capsule.httpx_ws.aconnect_ws", return_value=ctx): + async with cap.pty_connect("tag-async") as session: + assert session is not None + import json as _json + + sent = ws.send_text.call_args_list[0].args[0] + payload = _json.loads(sent) + assert payload == {"type": "connect", "tag": "tag-async"} + await cap._client.aclose() + + +class TestCreateSnapshot: + @respx.mock + def test_sync_create_snapshot_posts_capsule_id(self): + respx.post(f"{BASE}/v1/capsules").respond( + 202, json={"id": "cl-1", "status": "starting"} + ) + snap_route = respx.post(f"{BASE}/v1/snapshots").respond( + 201, + json={"name": "my-snap"}, + ) + cap = Capsule(api_key=API_KEY, base_url=BASE) + tpl = cap.create_snapshot(name="my-snap", overwrite=True) + import json as _json + + req = snap_route.calls[0].request + body = _json.loads(req.content) + assert body["sandbox_id"] == "cl-1" + assert body["name"] == "my-snap" + assert req.url.params["overwrite"] == "true" + assert tpl.name == "my-snap" + + @pytest.mark.asyncio + @respx.mock + async def test_async_create_snapshot(self): + from wrenn.async_capsule import AsyncCapsule + + respx.post(f"{BASE}/v1/capsules").respond( + 202, json={"id": "cl-1", "status": "starting"} + ) + respx.post(f"{BASE}/v1/snapshots").respond( + 201, + json={"name": "auto-named"}, + ) + cap = await AsyncCapsule.create(api_key=API_KEY, base_url=BASE) + tpl = await cap.create_snapshot() + assert tpl.name == "auto-named" + await cap._client.aclose() + + +class TestUploadStreamChunked: + """``upload_stream`` must declare ``Transfer-Encoding: chunked`` and + deliver the multipart body without buffering.""" + + @respx.mock + def test_sync_upload_stream_chunked(self): + respx.post(f"{BASE}/v1/capsules").respond( + 202, json={"id": "cl-1", "status": "starting"} + ) + route = respx.post(f"{BASE}/v1/capsules/cl-1/files/stream/write").respond( + 200, json={} + ) + cap = Capsule(api_key=API_KEY, base_url=BASE) + + def chunks(): + yield b"hello " + yield b"world\n" + + cap.files.upload_stream("/tmp/out.txt", chunks()) + req = route.calls[0].request + assert req.headers["transfer-encoding"] == "chunked" + ct = req.headers["content-type"] + assert ct.startswith("multipart/form-data; boundary=") + body = bytes(req.content) + assert b'name="path"' in body + assert b"/tmp/out.txt" in body + assert b'name="file"' in body + assert b"hello world\n" in body + + @pytest.mark.asyncio + @respx.mock + async def test_async_upload_stream_chunked(self): + from wrenn.async_capsule import AsyncCapsule + + respx.post(f"{BASE}/v1/capsules").respond( + 202, json={"id": "cl-1", "status": "starting"} + ) + route = respx.post(f"{BASE}/v1/capsules/cl-1/files/stream/write").respond( + 200, json={} + ) + cap = await AsyncCapsule.create(api_key=API_KEY, base_url=BASE) + + async def chunks(): + yield b"abc" + yield b"def" + + await cap.files.upload_stream("/tmp/out.bin", chunks()) + req = route.calls[0].request + assert req.headers["transfer-encoding"] == "chunked" + body = bytes(req.content) + assert b"abcdef" in body + await cap._client.aclose() + + class TestDeprecationWarnings: def test_import_sandbox_from_wrenn_warns(self): import sys diff --git a/tests/test_code_runner_unit.py b/tests/test_code_runner_unit.py index c1e3873..94571e0 100644 --- a/tests/test_code_runner_unit.py +++ b/tests/test_code_runner_unit.py @@ -362,12 +362,14 @@ class TestEnsureKernel: c._ensure_kernel(jupyter_timeout=0.01) -# ───────────────────────── _jupyter_execute_request ───────────────────────── +# ───────────────────────── build_execute_request ───────────────────────── class TestJupyterRequest: def test_structure(self): - msg = Capsule._jupyter_execute_request("print(1)") + from wrenn.code_runner._protocol import build_execute_request + + msg = build_execute_request("print(1)") assert msg["channel"] == "shell" assert msg["header"]["msg_type"] == "execute_request" assert msg["content"]["code"] == "print(1)" @@ -379,8 +381,10 @@ class TestJupyterRequest: assert len(msg["header"]["msg_id"]) == 36 def test_unique_msg_id_per_call(self): - a = Capsule._jupyter_execute_request("x") - b = Capsule._jupyter_execute_request("x") + from wrenn.code_runner._protocol import build_execute_request + + a = build_execute_request("x") + b = build_execute_request("x") assert a["header"]["msg_id"] != b["header"]["msg_id"] @@ -397,7 +401,12 @@ def _wrap(msg_type: str, parent_id: str, content: dict) -> dict: class _FakeWS: - """Minimal sync httpx_ws-shaped fake.""" + """Minimal sync httpx_ws-shaped fake. + + If ``frames_factory`` yields an ``Exception`` instance, the fake + raises it instead of returning the value — useful for testing + disconnect / network-error paths. + """ def __init__(self, frames_factory): self._frames_factory = frames_factory @@ -418,9 +427,12 @@ class _FakeWS: def receive_json(self, timeout: float = 0): assert self._iter is not None try: - return next(self._iter) + nxt = next(self._iter) except StopIteration: raise TimeoutError("no more frames") + if isinstance(nxt, BaseException): + raise nxt + return nxt class _FakeAsyncWS: @@ -438,12 +450,15 @@ class _FakeAsyncWS: parent_id = json.loads(s)["header"]["msg_id"] self._iter = iter(self._frames_factory(parent_id)) - async def receive_json(self, timeout: float = 0): + async def receive_json(self): assert self._iter is not None try: - return next(self._iter) + nxt = next(self._iter) except StopIteration: raise TimeoutError("no more frames") + if isinstance(nxt, BaseException): + raise nxt + return nxt class TestRunCode: @@ -630,3 +645,243 @@ class TestAsyncCtorFailureSafe: c = AsyncCapsule.__new__(AsyncCapsule) # __del__ should be safe even with no attrs. c.__del__() + + +# ───────────────────────── run_code error-path regressions (B2) ───────────── + + +class TestRunCodeErrorPaths: + """Sync run_code timeout / disconnect / unexpected-exception behavior.""" + + def _ready(self): + return TestRunCode()._make_ready() + + def test_timeout_when_no_idle_received(self): + c = self._ready() + + def frames(pid): + yield _wrap("stream", pid, {"name": "stdout", "text": "partial\n"}) + # No idle frame; loop exits via StopIteration → TimeoutError. + + errors = [] + with patch( + "wrenn.code_runner.capsule.httpx_ws.connect_ws", + return_value=_FakeWS(frames), + ): + ex = c.run_code("x", on_error=errors.append) + assert ex.timed_out is True + assert ex.error is not None + assert ex.error.name == "Timeout" + assert "exceeded" in ex.error.value + assert ex.logs.stdout == ["partial\n"] + assert len(errors) == 1 + + def test_disconnect_sets_disconnected_error(self): + c = self._ready() + import httpx_ws + + def frames(pid): + yield _wrap("stream", pid, {"name": "stdout", "text": "hi\n"}) + yield httpx_ws.WebSocketDisconnect(code=1000, reason="bye") + + errors = [] + with patch( + "wrenn.code_runner.capsule.httpx_ws.connect_ws", + return_value=_FakeWS(frames), + ): + ex = c.run_code("x", on_error=errors.append) + assert ex.timed_out is True + assert ex.error is not None + assert ex.error.name == "Disconnected" + assert ex.logs.stdout == ["hi\n"] + assert len(errors) == 1 + + def test_unexpected_exception_propagates(self): + c = self._ready() + + def frames(pid): + yield RuntimeError("WS broken in unexpected way") + + with patch( + "wrenn.code_runner.capsule.httpx_ws.connect_ws", + return_value=_FakeWS(frames), + ): + with pytest.raises(RuntimeError, match="WS broken"): + c.run_code("x") + + def test_clean_exit_does_not_set_timed_out(self): + c = self._ready() + + def frames(pid): + yield _wrap("status", pid, {"execution_state": "idle"}) + + with patch( + "wrenn.code_runner.capsule.httpx_ws.connect_ws", + return_value=_FakeWS(frames), + ): + ex = c.run_code("pass") + assert ex.timed_out is False + assert ex.error is None + + +# ───────────────────────── Async run_code parity ────────────────────────── + + +class TestAsyncRunCodeErrorPaths: + def _ready(self): + return TestAsyncRunCode()._make_ready() + + @pytest.mark.asyncio + async def test_async_timeout_when_no_idle(self): + c = self._ready() + + def frames(pid): + yield _wrap("stream", pid, {"name": "stdout", "text": "partial\n"}) + + errors = [] + with patch( + "wrenn.code_runner.async_capsule.httpx_ws.aconnect_ws", + return_value=_FakeAsyncWS(frames), + ): + ex = await c.run_code("x", on_error=errors.append) + assert ex.timed_out is True + assert ex.error is not None + assert ex.error.name == "Timeout" + assert ex.logs.stdout == ["partial\n"] + assert len(errors) == 1 + await c.close() + + @pytest.mark.asyncio + async def test_async_disconnect_sets_disconnected_error(self): + c = self._ready() + import httpx_ws + + def frames(pid): + yield httpx_ws.WebSocketNetworkError("network blip") + + errors = [] + with patch( + "wrenn.code_runner.async_capsule.httpx_ws.aconnect_ws", + return_value=_FakeAsyncWS(frames), + ): + ex = await c.run_code("x", on_error=errors.append) + assert ex.timed_out is True + assert ex.error is not None + assert ex.error.name == "Disconnected" + assert len(errors) == 1 + await c.close() + + @pytest.mark.asyncio + async def test_async_unexpected_exception_propagates(self): + c = self._ready() + + def frames(pid): + yield RuntimeError("unexpected WS death") + + with patch( + "wrenn.code_runner.async_capsule.httpx_ws.aconnect_ws", + return_value=_FakeAsyncWS(frames), + ): + with pytest.raises(RuntimeError, match="unexpected WS"): + await c.run_code("x") + await c.close() + + @pytest.mark.asyncio + async def test_async_unsupported_language_raises(self): + c = self._ready() + with pytest.raises(ValueError, match="not supported"): + await c.run_code("console.log('x')", language="javascript") + await c.close() + + +# ───────────────────────── Async _ensure_kernel parity ─────────────────────── + + +@respx.mock +def _make_async_capsule(capsule_id: str = "sb-1") -> AsyncCapsule: + """Construct an AsyncCapsule without going through ``create()``.""" + from wrenn.client import AsyncWrennClient + from wrenn.models import Capsule as CapsuleModel + + client = AsyncWrennClient(api_key=API_KEY, base_url=BASE) + info = CapsuleModel(id=capsule_id) + return AsyncCapsule(_capsule_id=capsule_id, _client=client, _info=info) + + +class TestAsyncEnsureKernel: + @pytest.mark.asyncio + @respx.mock + async def test_async_creates_kernel_when_none_exist(self): + c = _make_async_capsule() + proxy_base = "https://8888-sb-1.app.wrenn.dev" + list_route = respx.get(f"{proxy_base}/api/kernels").respond(200, json=[]) + create_route = respx.post(f"{proxy_base}/api/kernels").respond( + 201, json={"id": "k-new", "name": "wrenn"} + ) + kid = await c._ensure_kernel() + assert kid == "k-new" + body = json.loads(create_route.calls[0].request.content) + assert body == {"name": "wrenn"} + assert list_route.called + await c.close() + + @pytest.mark.asyncio + @respx.mock + async def test_async_reuses_existing_wrenn_kernel(self): + c = _make_async_capsule() + proxy_base = "https://8888-sb-1.app.wrenn.dev" + respx.get(f"{proxy_base}/api/kernels").respond( + 200, + json=[ + {"id": "k-other", "name": "python3"}, + {"id": "k-wrenn", "name": "wrenn"}, + ], + ) + create = respx.post(f"{proxy_base}/api/kernels").respond(201, json={}) + kid = await c._ensure_kernel() + assert kid == "k-wrenn" + assert not create.called + await c.close() + + @pytest.mark.asyncio + @respx.mock + async def test_async_retries_on_5xx_then_succeeds(self): + c = _make_async_capsule() + proxy_base = "https://8888-sb-1.app.wrenn.dev" + responses = [ + httpx.Response(503), + httpx.Response(200, json=[{"id": "k-1", "name": "wrenn"}]), + ] + respx.get(f"{proxy_base}/api/kernels").mock(side_effect=responses) + with patch("asyncio.sleep") as sleep_mock: + + async def _noop(_s): + return None + + sleep_mock.side_effect = _noop + kid = await c._ensure_kernel(jupyter_timeout=5) + assert kid == "k-1" + await c.close() + + @pytest.mark.asyncio + @respx.mock + async def test_async_raises_on_4xx(self): + c = _make_async_capsule() + proxy_base = "https://8888-sb-1.app.wrenn.dev" + respx.get(f"{proxy_base}/api/kernels").respond(401) + with pytest.raises(httpx.HTTPStatusError): + await c._ensure_kernel(jupyter_timeout=2) + await c.close() + + @pytest.mark.asyncio + @respx.mock + async def test_async_caches_kernel_id(self): + c = _make_async_capsule() + proxy_base = "https://8888-sb-1.app.wrenn.dev" + route = respx.get(f"{proxy_base}/api/kernels").respond( + 200, json=[{"id": "k-1", "name": "wrenn"}] + ) + await c._ensure_kernel() + await c._ensure_kernel() + assert route.call_count == 1 + await c.close() -- 2.49.0 From 005871441a8b796a514765a57d746343e44bd2b0 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 20 May 2026 05:25:19 +0600 Subject: [PATCH 36/44] ci: split Woodpecker pipelines by scope - unit.yml: unit tests on every push and pull_request, all branches. - code-runner.yml: PR to dev/main, gated on src/wrenn/code_runner/** or tests/test_code_runner_*.py; runs `make test-code-runner`. - integration.yml: PR to dev/main, gated on src/** excluding src/wrenn/code_runner/**; runs `make test-integration`. E2E pipelines require a src/** change, so docs/test-only PRs only trigger the unit pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) --- .woodpecker/code-runner.yml | 18 ++++++++++++++++++ .woodpecker/integration.yml | 21 +++++++++++++++++++++ .woodpecker/unit.yml | 11 +++++++++++ 3 files changed, 50 insertions(+) create mode 100644 .woodpecker/code-runner.yml create mode 100644 .woodpecker/integration.yml create mode 100644 .woodpecker/unit.yml diff --git a/.woodpecker/code-runner.yml b/.woodpecker/code-runner.yml new file mode 100644 index 0000000..96bff9d --- /dev/null +++ b/.woodpecker/code-runner.yml @@ -0,0 +1,18 @@ +# E2E — code_runner. PR to dev/main when code_runner sources/tests change. +when: + - event: pull_request + branch: [main, dev] + path: + include: + - "src/wrenn/code_runner/**" + - "tests/test_code_runner_*.py" + +steps: + test-code-runner: + image: ghcr.io/astral-sh/uv:python3.13-bookworm + environment: + WRENN_API_KEY: + from_secret: WRENN_API_KEY + commands: + - uv sync --dev + - make test-code-runner diff --git a/.woodpecker/integration.yml b/.woodpecker/integration.yml new file mode 100644 index 0000000..6195b13 --- /dev/null +++ b/.woodpecker/integration.yml @@ -0,0 +1,21 @@ +# E2E — integration. PR to dev/main when non-code_runner src changes. +# Path filter: include src/** but exclude src/wrenn/code_runner/** so the +# dedicated code-runner pipeline owns that surface. +when: + - event: pull_request + branch: [main, dev] + path: + include: + - "src/**" + exclude: + - "src/wrenn/code_runner/**" + +steps: + test-integration: + image: ghcr.io/astral-sh/uv:python3.13-bookworm + environment: + WRENN_API_KEY: + from_secret: WRENN_API_KEY + commands: + - uv sync --dev + - make test-integration diff --git a/.woodpecker/unit.yml b/.woodpecker/unit.yml new file mode 100644 index 0000000..4def478 --- /dev/null +++ b/.woodpecker/unit.yml @@ -0,0 +1,11 @@ +# Unit tests — every push and pull_request, all branches. +when: + - event: push + - event: pull_request + +steps: + unit-tests: + image: ghcr.io/astral-sh/uv:python3.13-bookworm + commands: + - uv sync --dev + - uv run pytest -m "not integration" -v -- 2.49.0 From db48e3cfbf19d4ab585a762e2a1c0574e9cd19e3 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 20 May 2026 06:42:44 +0600 Subject: [PATCH 37/44] ci: scope e2e pipelines, exclude code_runner from integration --- .woodpecker/code-runner.yml | 2 ++ .woodpecker/integration.yml | 4 ++++ Makefile | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.woodpecker/code-runner.yml b/.woodpecker/code-runner.yml index 96bff9d..9125333 100644 --- a/.woodpecker/code-runner.yml +++ b/.woodpecker/code-runner.yml @@ -6,6 +6,8 @@ when: include: - "src/wrenn/code_runner/**" - "tests/test_code_runner_*.py" + - "pyproject.toml" + - "uv.lock" steps: test-code-runner: diff --git a/.woodpecker/integration.yml b/.woodpecker/integration.yml index 6195b13..a427437 100644 --- a/.woodpecker/integration.yml +++ b/.woodpecker/integration.yml @@ -7,8 +7,12 @@ when: path: include: - "src/**" + - "tests/**" + - "pyproject.toml" + - "uv.lock" exclude: - "src/wrenn/code_runner/**" + - "tests/test_code_runner_*.py" steps: test-integration: diff --git a/Makefile b/Makefile index 130c439..047d79e 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ test: uv run pytest tests/test_client.py tests/test_code_runner_unit.py -v test-integration: - uv run pytest tests/ -v -m "integration or not integration" + uv run pytest tests/ -v -m "integration or not integration" --ignore=tests/test_code_runner_e2e.py --ignore=tests/test_code_runner_unit.py test-code-runner: uv run pytest tests/test_code_runner_unit.py tests/test_code_runner_e2e.py -v -m "integration or not integration" -- 2.49.0 From 8a62b6207cbcb44133e196f09613d82f105e8e79 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Thu, 21 May 2026 01:46:04 +0600 Subject: [PATCH 38/44] feat(client): add proxy_domain + timeout kwargs, fix default proxy host - WrennClient/AsyncWrennClient accept proxy_domain= and timeout= kwargs - WRENN_PROXY_DOMAIN env var supported - Default proxy host: app.wrenn.dev -> wrenn.dev (was port-id.app.wrenn.dev) - Custom base_url preserves host verbatim (with port) - Default timeout: httpx.Timeout(30.0, connect=10.0) - _build_proxy_url/_build_http_proxy_url take optional proxy_domain - code_runner proxy + WS URL builders thread proxy_domain through --- CLAUDE.md | 23 ++++++++++ README.md | 23 +++++++++- src/wrenn/_config.py | 2 + src/wrenn/async_capsule.py | 7 ++- src/wrenn/capsule.py | 52 +++++++++++++++++------ src/wrenn/client.py | 59 +++++++++++++++++++++++++- src/wrenn/code_runner/_protocol.py | 9 +++- src/wrenn/code_runner/async_capsule.py | 14 +++++- src/wrenn/code_runner/capsule.py | 14 +++++- tests/test_capsule_features.py | 14 +++++- tests/test_client.py | 36 ++++++++++++++++ tests/test_code_runner_unit.py | 26 ++++++------ 12 files changed, 242 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 417a565..4fc6d7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,6 +192,29 @@ Jupyter kernel. `httpx.AsyncClient` must be closed via `await close()` or `async with`. +## Client Config + +`WrennClient` / `AsyncWrennClient` accept: +- `api_key` — falls back to `WRENN_API_KEY`. +- `base_url` — falls back to `WRENN_BASE_URL`, then `DEFAULT_BASE_URL` + (`https://app.wrenn.dev/api`). +- `proxy_domain` — host suffix for capsule proxy URLs + (`{port}-{capsule_id}.`). Resolution: + 1. explicit `proxy_domain=` kwarg + 2. `WRENN_PROXY_DOMAIN` env + 3. `wrenn.dev` when `base_url` host == `app.wrenn.dev` exactly + 4. else `base_url` host (with port) verbatim + Exact match in step 3 is intentional: staging/other Wrenn envs keep + their host so they don't accidentally collapse to prod `wrenn.dev`. +- `timeout` — `httpx.Timeout | float | None`. Default + `httpx.Timeout(30.0, connect=10.0)`. Helper `_resolve_timeout` + centralizes the float-or-Timeout coercion. + +`_build_proxy_url` / `_build_http_proxy_url` in `wrenn.capsule` now take +an optional `proxy_domain` arg. When omitted they fall back to the +`base_url` host (legacy behavior, preserved for direct callers/tests). +Production call sites pass `self._client._proxy_domain`. + ### Tests - `tests/test_code_runner_unit.py` — pure unit tests (respx + mocked diff --git a/README.md b/README.md index e5f1f6f..2a48a06 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,31 @@ Optionally override the API base URL: export WRENN_BASE_URL="https://app.wrenn.dev/api" # default ``` +For self-hosted deployments you can also override the capsule proxy domain +(used to build `{port}-{capsule_id}.` URLs returned by +`Capsule.get_url`): + +```bash +export WRENN_PROXY_DOMAIN="wrenn.example.com" +``` + +Resolution order: explicit `proxy_domain=` kwarg → `WRENN_PROXY_DOMAIN` env → +`wrenn.dev` when `base_url` is the default `app.wrenn.dev` host, else the +`base_url` host (with port) verbatim. + You can also pass credentials directly: ```python -from wrenn import Capsule +from wrenn import WrennClient, Capsule + +# WrennClient also accepts a timeout (httpx.Timeout or float seconds). +# Default: 30s read/write/pool, 10s connect. +client = WrennClient( + api_key="wrn_...", + base_url="https://...", + proxy_domain="wrenn.example.com", # optional override + timeout=30.0, # optional override +) capsule = Capsule(api_key="wrn_...", base_url="https://...") ``` diff --git a/src/wrenn/_config.py b/src/wrenn/_config.py index fbdc889..544dae9 100644 --- a/src/wrenn/_config.py +++ b/src/wrenn/_config.py @@ -1,5 +1,7 @@ from __future__ import annotations DEFAULT_BASE_URL = "https://app.wrenn.dev/api" +DEFAULT_PROXY_DOMAIN = "wrenn.dev" ENV_API_KEY = "WRENN_API_KEY" ENV_BASE_URL = "WRENN_BASE_URL" +ENV_PROXY_DOMAIN = "WRENN_PROXY_DOMAIN" diff --git a/src/wrenn/async_capsule.py b/src/wrenn/async_capsule.py index f091649..57f74af 100644 --- a/src/wrenn/async_capsule.py +++ b/src/wrenn/async_capsule.py @@ -434,7 +434,12 @@ class AsyncCapsule: WebSocket access, see the lower-level ``_build_proxy_url`` helper or the ``pty()`` API. """ - return _build_http_proxy_url(self._client._base_url, self._id, port) + return _build_http_proxy_url( + self._client._base_url, + self._id, + port, + self._client._proxy_domain, + ) # ── Snapshots ─────────────────────────────────────────────── diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py index a5545d5..5a8ddcb 100644 --- a/src/wrenn/capsule.py +++ b/src/wrenn/capsule.py @@ -20,27 +20,48 @@ from wrenn.models import Status, Template from wrenn.pty import PtySession -def _build_proxy_url(base_url: str, capsule_id: str | None, port: int) -> str: - """Build the WebSocket proxy URL (``ws://`` / ``wss://``).""" +def _build_proxy_url( + base_url: str, + capsule_id: str | None, + port: int, + proxy_domain: str | None = None, +) -> str: + """Build the WebSocket proxy URL (``ws://`` / ``wss://``). + + Scheme is derived from ``base_url``. The host portion comes from + ``proxy_domain`` if provided; otherwise falls back to the ``base_url`` + host (with port). + """ parsed = httpx.URL(base_url) - host = parsed.host - if parsed.port: - host = f"{host}:{parsed.port}" + if proxy_domain: + host = proxy_domain + else: + host = parsed.host + if parsed.port: + host = f"{host}:{parsed.port}" scheme = "ws" if parsed.scheme == "http" else "wss" return f"{scheme}://{port}-{capsule_id}.{host}" -def _build_http_proxy_url(base_url: str, capsule_id: str | None, port: int) -> str: +def _build_http_proxy_url( + base_url: str, + capsule_id: str | None, + port: int, + proxy_domain: str | None = None, +) -> str: """Build the HTTP proxy URL (``http://`` / ``https://``). - The capsule's API base URL typically carries an ``/api`` path suffix - (e.g. ``https://app.wrenn.dev/api``). The proxy host is derived from - the URL's host only — any path is discarded. + Scheme is derived from ``base_url``. The host portion comes from + ``proxy_domain`` if provided; otherwise falls back to the ``base_url`` + host (with port). Any path on ``base_url`` is discarded. """ parsed = httpx.URL(base_url) - host = parsed.host - if parsed.port: - host = f"{host}:{parsed.port}" + if proxy_domain: + host = proxy_domain + else: + host = parsed.host + if parsed.port: + host = f"{host}:{parsed.port}" scheme = "http" if parsed.scheme in ("http", "ws") else "https" return f"{scheme}://{port}-{capsule_id}.{host}" @@ -526,7 +547,12 @@ class Capsule: WebSocket access, see the lower-level ``_build_proxy_url`` helper or the ``pty()`` API. """ - return _build_http_proxy_url(self._client._base_url, self._id, port) + return _build_http_proxy_url( + self._client._base_url, + self._id, + port, + self._client._proxy_domain, + ) # ── Snapshots ─────────────────────────────────────────────── diff --git a/src/wrenn/client.py b/src/wrenn/client.py index ceece27..46500c6 100644 --- a/src/wrenn/client.py +++ b/src/wrenn/client.py @@ -4,7 +4,13 @@ import os import httpx -from wrenn._config import DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL +from wrenn._config import ( + DEFAULT_BASE_URL, + DEFAULT_PROXY_DOMAIN, + ENV_API_KEY, + ENV_BASE_URL, + ENV_PROXY_DOMAIN, +) from wrenn.exceptions import handle_response from wrenn.models import ( @@ -15,6 +21,7 @@ from wrenn.models import ( ) _LONG_TIMEOUT = httpx.Timeout(60.0) +_DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=10.0) def _resolve_api_key(api_key: str | None) -> str: @@ -26,6 +33,36 @@ def _resolve_api_key(api_key: str | None) -> str: return resolved +def _resolve_timeout( + timeout: httpx.Timeout | float | None, +) -> httpx.Timeout: + if timeout is None: + return _DEFAULT_TIMEOUT + if isinstance(timeout, httpx.Timeout): + return timeout + return httpx.Timeout(timeout) + + +def _resolve_proxy_domain(base_url: str, override: str | None) -> str: + """Resolve proxy host suffix for ``{port}-{capsule_id}.`` URLs. + + Precedence: explicit ``override`` arg, ``WRENN_PROXY_DOMAIN`` env, then + ``wrenn.dev`` only when ``base_url`` is the default Wrenn host + (``app.wrenn.dev``). Otherwise the ``base_url`` host (with port) is used + verbatim — appropriate for local dev or custom deployments. + """ + resolved = override or os.environ.get(ENV_PROXY_DOMAIN) + if resolved: + return resolved + parsed = httpx.URL(base_url) + host = parsed.host + if host == "app.wrenn.dev": + return DEFAULT_PROXY_DOMAIN + if parsed.port: + return f"{host}:{parsed.port}" + return host + + class CapsulesResource: """Sync capsule control-plane operations.""" @@ -394,18 +431,28 @@ class WrennClient: Args: api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var. base_url: Wrenn API base URL. + proxy_domain: Host suffix for capsule proxy URLs + (``{port}-{capsule_id}.``). Falls back to + ``WRENN_PROXY_DOMAIN`` env, then ``wrenn.dev`` when ``base_url`` + is the default ``app.wrenn.dev`` host, else the ``base_url`` host. + timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds), + or ``None`` for the default (30s read/write/pool, 10s connect). """ def __init__( self, api_key: str | None = None, base_url: str | None = None, + proxy_domain: str | None = None, + timeout: httpx.Timeout | float | None = None, ) -> None: self._api_key = _resolve_api_key(api_key) self._base_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL) + self._proxy_domain = _resolve_proxy_domain(self._base_url, proxy_domain) self._http = httpx.Client( base_url=self._base_url, headers={"X-API-Key": self._api_key}, + timeout=_resolve_timeout(timeout), ) self.capsules = CapsulesResource(self._http) @@ -440,18 +487,28 @@ class AsyncWrennClient: Args: api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var. base_url: Wrenn API base URL. Falls back to ``WRENN_BASE_URL`` env var. + proxy_domain: Host suffix for capsule proxy URLs + (``{port}-{capsule_id}.``). Falls back to + ``WRENN_PROXY_DOMAIN`` env, then ``wrenn.dev`` when ``base_url`` + is the default ``app.wrenn.dev`` host, else the ``base_url`` host. + timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds), + or ``None`` for the default (30s read/write/pool, 10s connect). """ def __init__( self, api_key: str | None = None, base_url: str | None = None, + proxy_domain: str | None = None, + timeout: httpx.Timeout | float | None = None, ) -> None: self._api_key = _resolve_api_key(api_key) self._base_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL) + self._proxy_domain = _resolve_proxy_domain(self._base_url, proxy_domain) self._http = httpx.AsyncClient( base_url=self._base_url, headers={"X-API-Key": self._api_key}, + timeout=_resolve_timeout(timeout), ) self.capsules = AsyncCapsulesResource(self._http) diff --git a/src/wrenn/code_runner/_protocol.py b/src/wrenn/code_runner/_protocol.py index 42b5978..100da36 100644 --- a/src/wrenn/code_runner/_protocol.py +++ b/src/wrenn/code_runner/_protocol.py @@ -45,7 +45,12 @@ def build_execute_request(code: str) -> dict: } -def build_ws_url(base_url: str, capsule_id: str, kernel_id: str) -> str: +def build_ws_url( + base_url: str, + capsule_id: str, + kernel_id: str, + proxy_domain: str | None = None, +) -> str: """Build the Jupyter kernel WebSocket URL for the given capsule.""" - proxy = _build_proxy_url(base_url, capsule_id, 8888) + proxy = _build_proxy_url(base_url, capsule_id, 8888, proxy_domain) return f"{proxy}/api/kernels/{kernel_id}/channels" diff --git a/src/wrenn/code_runner/async_capsule.py b/src/wrenn/code_runner/async_capsule.py index e11dca0..9dadb7f 100644 --- a/src/wrenn/code_runner/async_capsule.py +++ b/src/wrenn/code_runner/async_capsule.py @@ -110,7 +110,12 @@ class AsyncCapsule(BaseAsyncCapsule): def _get_proxy_client(self) -> httpx.AsyncClient: if self._proxy_client is None: - url = _build_http_proxy_url(self._client._base_url, self._id, 8888) + url = _build_http_proxy_url( + self._client._base_url, + self._id, + 8888, + self._client._proxy_domain, + ) self._proxy_client = httpx.AsyncClient( base_url=url, headers={"X-API-Key": self._client._api_key}, @@ -196,7 +201,12 @@ class AsyncCapsule(BaseAsyncCapsule): "non-Python kernelspec." ) kernel_id = await self._ensure_kernel(jupyter_timeout=jupyter_timeout) - ws_url = build_ws_url(self._client._base_url, self._id, kernel_id) + ws_url = build_ws_url( + self._client._base_url, + self._id, + kernel_id, + self._client._proxy_domain, + ) msg = build_execute_request(code) msg_id = msg["header"]["msg_id"] diff --git a/src/wrenn/code_runner/capsule.py b/src/wrenn/code_runner/capsule.py index 782e812..b84e1e5 100644 --- a/src/wrenn/code_runner/capsule.py +++ b/src/wrenn/code_runner/capsule.py @@ -138,7 +138,12 @@ class Capsule(BaseCapsule): def _get_proxy_client(self) -> httpx.Client: if self._proxy_client is None: - url = _build_http_proxy_url(self._client._base_url, self._id, 8888) + url = _build_http_proxy_url( + self._client._base_url, + self._id, + 8888, + self._client._proxy_domain, + ) self._proxy_client = httpx.Client( base_url=url, headers={"X-API-Key": self._client._api_key}, @@ -231,7 +236,12 @@ class Capsule(BaseCapsule): "non-Python kernelspec." ) kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout) - ws_url = build_ws_url(self._client._base_url, self._id, kernel_id) + ws_url = build_ws_url( + self._client._base_url, + self._id, + kernel_id, + self._client._proxy_domain, + ) msg = build_execute_request(code) msg_id = msg["header"]["msg_id"] diff --git a/tests/test_capsule_features.py b/tests/test_capsule_features.py index 186d247..7566bd7 100644 --- a/tests/test_capsule_features.py +++ b/tests/test_capsule_features.py @@ -45,6 +45,16 @@ class TestBuildHttpProxyUrl: url = _build_http_proxy_url("https://api.example.com:9443", "sb-1", 80) assert url == "https://80-sb-1.api.example.com:9443" + def test_proxy_domain_override_http(self): + url = _build_http_proxy_url( + "https://app.wrenn.dev/api", "cl-abc", 8080, "wrenn.dev" + ) + assert url == "https://8080-cl-abc.wrenn.dev" + + def test_proxy_domain_override_ws(self): + url = _build_proxy_url("https://app.wrenn.dev/api", "cl-abc", 8888, "wrenn.dev") + assert url == "wss://8888-cl-abc.wrenn.dev" + class TestCapsuleCreate: @respx.mock @@ -222,7 +232,7 @@ class TestGetUrlPublic: 202, json={"id": "cl-99", "status": "starting"} ) cap = Capsule(api_key=API_KEY, base_url=BASE) - assert cap.get_url(8080) == "https://8080-cl-99.app.wrenn.dev" + assert cap.get_url(8080) == "https://8080-cl-99.wrenn.dev" @respx.mock def test_sync_get_url_localhost(self): @@ -242,7 +252,7 @@ class TestGetUrlPublic: 202, json={"id": "cl-async", "status": "starting"} ) cap = await AsyncCapsule.create(api_key=API_KEY, base_url=BASE) - assert cap.get_url(5000) == "https://5000-cl-async.app.wrenn.dev" + assert cap.get_url(5000) == "https://5000-cl-async.wrenn.dev" await cap._client.aclose() diff --git a/tests/test_client.py b/tests/test_client.py index 1269233..3bc31ed 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -261,3 +261,39 @@ class TestAsyncClient: ) with pytest.raises(WrennNotFoundError): await async_client.capsules.get("nope") + + +class TestClientResolution: + def test_default_base_url_strips_app_subdomain(self): + with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: + assert c._proxy_domain == "wrenn.dev" + + def test_custom_base_url_preserves_host(self): + with WrennClient( + api_key="wrn_test1234567890abcdef12345678", + base_url="http://localhost:8080/api", + ) as c: + assert c._proxy_domain == "localhost:8080" + + def test_explicit_proxy_domain_wins(self): + with WrennClient( + api_key="wrn_test1234567890abcdef12345678", + base_url="https://app.wrenn.dev/api", + proxy_domain="custom.example.com", + ) as c: + assert c._proxy_domain == "custom.example.com" + + def test_env_proxy_domain(self, monkeypatch): + monkeypatch.setenv("WRENN_PROXY_DOMAIN", "env.example.com") + with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: + assert c._proxy_domain == "env.example.com" + + def test_default_timeout(self): + with WrennClient(api_key="wrn_test1234567890abcdef12345678") as c: + t = c._http.timeout + assert t.connect == 10.0 + assert t.read == 30.0 + + def test_timeout_float_override(self): + with WrennClient(api_key="wrn_test1234567890abcdef12345678", timeout=5.0) as c: + assert c._http.timeout.connect == 5.0 diff --git a/tests/test_code_runner_unit.py b/tests/test_code_runner_unit.py index 94571e0..d181749 100644 --- a/tests/test_code_runner_unit.py +++ b/tests/test_code_runner_unit.py @@ -263,7 +263,7 @@ class TestEnsureKernel: @respx.mock def test_creates_kernel_with_wrenn_name_when_none_exist(self): c = _make_capsule() - proxy_base = "https://8888-sb-1.app.wrenn.dev" + proxy_base = "https://8888-sb-1.wrenn.dev" list_route = respx.get(f"{proxy_base}/api/kernels").respond(200, json=[]) create_route = respx.post(f"{proxy_base}/api/kernels").respond( 201, json={"id": "k-new", "name": "wrenn"} @@ -279,7 +279,7 @@ class TestEnsureKernel: @respx.mock def test_reuses_existing_wrenn_kernel(self): c = _make_capsule() - proxy_base = "https://8888-sb-1.app.wrenn.dev" + proxy_base = "https://8888-sb-1.wrenn.dev" respx.get(f"{proxy_base}/api/kernels").respond( 200, json=[ @@ -295,7 +295,7 @@ class TestEnsureKernel: @respx.mock def test_creates_when_only_other_kernels_exist(self): c = _make_capsule() - proxy_base = "https://8888-sb-1.app.wrenn.dev" + proxy_base = "https://8888-sb-1.wrenn.dev" respx.get(f"{proxy_base}/api/kernels").respond( 200, json=[{"id": "k-other", "name": "python3"}] ) @@ -308,7 +308,7 @@ class TestEnsureKernel: @respx.mock def test_caches_kernel_id(self): c = _make_capsule() - proxy_base = "https://8888-sb-1.app.wrenn.dev" + proxy_base = "https://8888-sb-1.wrenn.dev" route = respx.get(f"{proxy_base}/api/kernels").respond( 200, json=[{"id": "k-1", "name": "wrenn"}] ) @@ -322,7 +322,7 @@ class TestEnsureKernel: 202, json={"id": "sb-1", "status": "starting"} ) c = Capsule(kernel="python3", api_key=API_KEY, base_url=BASE) - proxy_base = "https://8888-sb-1.app.wrenn.dev" + proxy_base = "https://8888-sb-1.wrenn.dev" respx.get(f"{proxy_base}/api/kernels").respond(200, json=[]) create = respx.post(f"{proxy_base}/api/kernels").respond( 201, json={"id": "k-py", "name": "python3"} @@ -334,7 +334,7 @@ class TestEnsureKernel: @respx.mock def test_retries_on_5xx_then_succeeds(self): c = _make_capsule() - proxy_base = "https://8888-sb-1.app.wrenn.dev" + proxy_base = "https://8888-sb-1.wrenn.dev" responses = [ httpx.Response(503), httpx.Response(200, json=[{"id": "k-1", "name": "wrenn"}]), @@ -347,7 +347,7 @@ class TestEnsureKernel: @respx.mock def test_raises_on_4xx(self): c = _make_capsule() - proxy_base = "https://8888-sb-1.app.wrenn.dev" + proxy_base = "https://8888-sb-1.wrenn.dev" respx.get(f"{proxy_base}/api/kernels").respond(401) with pytest.raises(httpx.HTTPStatusError): c._ensure_kernel(jupyter_timeout=2) @@ -355,7 +355,7 @@ class TestEnsureKernel: @respx.mock def test_timeout_raises(self): c = _make_capsule() - proxy_base = "https://8888-sb-1.app.wrenn.dev" + proxy_base = "https://8888-sb-1.wrenn.dev" respx.get(f"{proxy_base}/api/kernels").respond(503) with patch("time.sleep"): with pytest.raises(TimeoutError): @@ -813,7 +813,7 @@ class TestAsyncEnsureKernel: @respx.mock async def test_async_creates_kernel_when_none_exist(self): c = _make_async_capsule() - proxy_base = "https://8888-sb-1.app.wrenn.dev" + proxy_base = "https://8888-sb-1.wrenn.dev" list_route = respx.get(f"{proxy_base}/api/kernels").respond(200, json=[]) create_route = respx.post(f"{proxy_base}/api/kernels").respond( 201, json={"id": "k-new", "name": "wrenn"} @@ -829,7 +829,7 @@ class TestAsyncEnsureKernel: @respx.mock async def test_async_reuses_existing_wrenn_kernel(self): c = _make_async_capsule() - proxy_base = "https://8888-sb-1.app.wrenn.dev" + proxy_base = "https://8888-sb-1.wrenn.dev" respx.get(f"{proxy_base}/api/kernels").respond( 200, json=[ @@ -847,7 +847,7 @@ class TestAsyncEnsureKernel: @respx.mock async def test_async_retries_on_5xx_then_succeeds(self): c = _make_async_capsule() - proxy_base = "https://8888-sb-1.app.wrenn.dev" + proxy_base = "https://8888-sb-1.wrenn.dev" responses = [ httpx.Response(503), httpx.Response(200, json=[{"id": "k-1", "name": "wrenn"}]), @@ -867,7 +867,7 @@ class TestAsyncEnsureKernel: @respx.mock async def test_async_raises_on_4xx(self): c = _make_async_capsule() - proxy_base = "https://8888-sb-1.app.wrenn.dev" + proxy_base = "https://8888-sb-1.wrenn.dev" respx.get(f"{proxy_base}/api/kernels").respond(401) with pytest.raises(httpx.HTTPStatusError): await c._ensure_kernel(jupyter_timeout=2) @@ -877,7 +877,7 @@ class TestAsyncEnsureKernel: @respx.mock async def test_async_caches_kernel_id(self): c = _make_async_capsule() - proxy_base = "https://8888-sb-1.app.wrenn.dev" + proxy_base = "https://8888-sb-1.wrenn.dev" route = respx.get(f"{proxy_base}/api/kernels").respond( 200, json=[{"id": "k-1", "name": "wrenn"}] ) -- 2.49.0 From 7291dbe669971430a8cf625b7ae6ce1ef40b5548 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Thu, 21 May 2026 01:55:43 +0600 Subject: [PATCH 39/44] version bump --- pyproject.toml | 1 + uv.lock | 409 +++++++++++++++++++++++++++---------------------- 2 files changed, 229 insertions(+), 181 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ba7402e..1b78b84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ + "certifi>=2026.2.25", "email-validator>=2.3.0", "httpx>=0.28.1", "httpx-ws>=0.9.0", diff --git a/uv.lock b/uv.lock index 097de50..d35d4ae 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.13" resolution-markers = [ - "python_full_version >= '3.14'", + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", "python_full_version < '3.14'", ] @@ -36,9 +37,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + [[package]] name = "black" -version = "26.3.1" +version = "26.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -48,28 +89,28 @@ dependencies = [ { name = "platformdirs" }, { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, - { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, - { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, - { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, - { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, - { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, - { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, - { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, ] [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] @@ -140,14 +181,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.2" +version = "8.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, ] [[package]] @@ -201,7 +242,7 @@ wheels = [ [[package]] name = "datamodel-code-generator" -version = "0.56.0" +version = "0.57.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argcomplete" }, @@ -213,9 +254,9 @@ dependencies = [ { name = "pydantic" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/7d/7fc2bb3d8946ca45851da3f23497a2c6e252e92558ccbd89d609cf1e13d4/datamodel_code_generator-0.56.0.tar.gz", hash = "sha256:e7c003fb5421b890aabe12f66ae65b57198b04cfe1da7c40810798020835b3a8", size = 837708, upload-time = "2026-04-04T09:46:19.636Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/44/87d5980f813a1e323c5d726b3ac5fec8c915ce8a77fcdceaf9c00457dbae/datamodel_code_generator-0.57.0.tar.gz", hash = "sha256:0eda778ea06eaa476e542a5f1fe1d14cc3bbf686edb33a0ad6151c7d19089906", size = 932941, upload-time = "2026-05-07T16:21:55.819Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/3a/7f169ffc7a2d69a4f9158b1ac083f685b7f4a1a8a1db5d1e4abbb4e741b7/datamodel_code_generator-0.56.0-py3-none-any.whl", hash = "sha256:a0559683fbe90cdf2ce9b6637e3adae3e3a8056a8d0516df581d486e2834ead2", size = 256545, upload-time = "2026-04-04T09:46:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/4fb9a44bb4a305b860c5a5b1866dcccfac3b76f5f170a9e68fc7733e16d2/datamodel_code_generator-0.57.0-py3-none-any.whl", hash = "sha256:d26bf5defe5154493d0aa5a822b7725332b9e9dd2abccc2f8856052286aa83b5", size = 259343, upload-time = "2026-05-07T16:21:53.823Z" }, ] [package.optional-dependencies] @@ -381,11 +422,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -433,49 +474,49 @@ wheels = [ [[package]] name = "librt" -version = "0.8.1" +version = "0.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, - { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, - { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, - { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, - { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, - { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, - { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, - { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, - { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, - { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, - { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, - { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, - { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, - { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] [[package]] @@ -532,47 +573,48 @@ wheels = [ [[package]] name = "more-itertools" -version = "11.0.1" +version = "11.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/24/e0acc4bf54cba50c1d432c70a72a3df96db4a321b2c4c68432a60759044f/more_itertools-11.0.1.tar.gz", hash = "sha256:fefaf25b7ab08f0b45fa9f1892cae93b9fc0089ef034d39213bce15f1cc9e199", size = 144739, upload-time = "2026-04-02T16:17:45.061Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/f4/5e52c7319b8087acef603ed6e50dc325c02eaa999355414830468611f13c/more_itertools-11.0.1-py3-none-any.whl", hash = "sha256:eaf287826069452a8f61026c597eae2428b2d1ba2859083abbf240b46842ce6d", size = 72182, upload-time = "2026-04-02T16:17:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, ] [[package]] name = "mypy" -version = "1.20.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "ast-serialize" }, { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" }, - { url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" }, - { url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" }, - { url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" }, - { url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" }, - { url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" }, - { url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" }, - { url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" }, - { url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" }, - { url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" }, - { url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" }, - { url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" }, - { url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" }, - { url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" }, - { url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" }, - { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, ] [[package]] @@ -626,20 +668,20 @@ wheels = [ [[package]] name = "packaging" -version = "26.0" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "pathspec" -version = "1.0.4" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] @@ -678,7 +720,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -686,62 +728,65 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] [[package]] @@ -808,15 +853,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.2.2" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, ] [[package]] @@ -881,7 +926,7 @@ wheels = [ [[package]] name = "requests" -version = "2.33.1" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -889,9 +934,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -908,27 +953,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.10" +version = "0.15.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, - { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, - { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, - { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, - { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, - { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, - { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, - { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, - { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, - { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, - { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, ] [[package]] @@ -990,14 +1035,14 @@ wheels = [ [[package]] name = "typeguard" -version = "4.5.1" +version = "4.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/e8/66e25efcc18542d58706ce4e50415710593721aae26e794ab1dec34fb66f/typeguard-4.5.1.tar.gz", hash = "sha256:f6f8ecbbc819c9bc749983cc67c02391e16a9b43b8b27f15dc70ed7c4a007274", size = 80121, upload-time = "2026-02-19T16:09:03.392Z" } +sdist = { url = "https://files.pythonhosted.org/packages/67/1c/dfba5c4633cafc4c701f237d2ba63b416805047fd6d96aab4cfc40969f98/typeguard-4.5.2.tar.gz", hash = "sha256:5a16dcac23502039299c97c8941651bc33d7ea8cc4b2f7d6bbb1b528f6eea423", size = 80240, upload-time = "2026-05-14T12:59:40.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/88/b55b3117287a8540b76dbdd87733808d4d01c8067a3b339408c250bb3600/typeguard-4.5.1-py3-none-any.whl", hash = "sha256:44d2bf329d49a244110a090b55f5f91aa82d9a9834ebfd30bcc73651e4a8cc40", size = 36745, upload-time = "2026-02-19T16:09:01.6Z" }, + { url = "https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl", hash = "sha256:fcf9de18bd945cdb4c7b996e12b4c51ce83f92f191314a6d7cf1739586ec98cf", size = 36748, upload-time = "2026-05-14T12:59:39.473Z" }, ] [[package]] @@ -1023,16 +1068,16 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] name = "virtualenv" -version = "21.3.0" +version = "21.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -1040,9 +1085,9 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/8b/6331f7a7fe70131c301106ec1e7cf23e2501bf7d4ca3636805801ca191bb/virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e", size = 7614069, upload-time = "2026-04-27T17:05:58.927Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/eb/03bfb1299d4c4510329e470f13f9a4ce793df7fcb5a2fd3510f911066f61/virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", size = 7594690, upload-time = "2026-04-27T17:05:55.468Z" }, + { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, ] [[package]] @@ -1124,6 +1169,7 @@ name = "wrenn" version = "0.1.4" source = { editable = "." } dependencies = [ + { name = "certifi" }, { name = "email-validator" }, { name = "httpx" }, { name = "httpx-ws" }, @@ -1144,6 +1190,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "certifi", specifier = ">=2026.2.25" }, { name = "email-validator", specifier = ">=2.3.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "httpx-ws", specifier = ">=0.9.0" }, -- 2.49.0 From 98028bab521518b399440ad306e36a1dffa487d3 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Thu, 21 May 2026 02:53:45 +0600 Subject: [PATCH 40/44] refactor: dry up sync/async pairs, fix resource leaks, sharpen consistency - fix async client leak in AsyncCapsule.create/connect on failure - fix websocket cm orphan when __enter__ raises mid-handshake - code_runner AsyncCapsule.create now delegates via base, mirrors sync - code_runner AsyncCapsule.__init__ accepts positional params - extract shared helpers in commands/files/client (payload, multipart, snapshot builders) - code_runner/_protocol gains apply_kernel_message, pick_kernel_id, validate_language; run_code + _ensure_kernel dedup'd sync/async - drop stale wrenn.code_runner.Sandbox alias - doc + timeout-catch tidy-ups in run_code --- docs/reference.md | 53 +++++- src/wrenn/async_capsule.py | 65 +++---- src/wrenn/capsule.py | 45 +++-- src/wrenn/client.py | 146 +++++++++++----- src/wrenn/code_runner/_protocol.py | 77 +++++++++ src/wrenn/code_runner/async_capsule.py | 223 ++++++++++++++----------- src/wrenn/code_runner/capsule.py | 180 +++++++++++--------- src/wrenn/commands.py | 122 +++++++------- src/wrenn/files.py | 78 ++++++--- tests/test_code_runner_e2e.py | 41 ++--- 10 files changed, 636 insertions(+), 394 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 49870ff..7c2d90c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -489,7 +489,13 @@ Authenticates with an API key. **Arguments**: - `api_key` - API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var. -- `base_url` - Wrenn API base URL. +- `base_url` - Wrenn API base URL. Falls back to ``WRENN_BASE_URL`` env var. +- `proxy_domain` - Host suffix for capsule proxy URLs + (``{port}-{capsule_id}.``). Falls back to + ``WRENN_PROXY_DOMAIN`` env, then ``wrenn.dev`` when ``base_url`` + is the default ``app.wrenn.dev`` host, else the ``base_url`` host. +- `timeout` - HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds), + or ``None`` for the default (30s read/write/pool, 10s connect). @@ -528,6 +534,12 @@ Authenticates with an API key. - `api_key` - API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var. - `base_url` - Wrenn API base URL. Falls back to ``WRENN_BASE_URL`` env var. +- `proxy_domain` - Host suffix for capsule proxy URLs + (``{port}-{capsule_id}.``). Falls back to + ``WRENN_PROXY_DOMAIN`` env, then ``wrenn.dev`` when ``base_url`` + is the default ``app.wrenn.dev`` host, else the ``base_url`` host. +- `timeout` - HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds), + or ``None`` for the default (30s read/write/pool, 10s connect). @@ -2624,10 +2636,15 @@ async def run_code( Execute code in a persistent Jupyter kernel (async). +Variables, imports, and function definitions survive across calls. + **Arguments**: - `code` - Code string to execute. -- `language` - Execution backend language. Currently only ``"python"``. +- `language` - Execution backend language. Currently only ``"python"`` + is supported; passing anything else raises ``ValueError``. + To target a non-Python kernel, set ``kernel=`` on the + capsule constructor. - `timeout` - Maximum seconds to wait for execution to complete. - `jupyter_timeout` - Maximum seconds to wait for Jupyter to become available. @@ -2820,12 +2837,42 @@ Build a Jupyter ``execute_request`` message envelope. expected to read ``msg["header"]["msg_id"]`` to correlate responses. + + +#### pick\_kernel\_id + +```python +def pick_kernel_id(kernels: list[dict], kernel_name: str) -> str | None +``` + +Return the ID of the first kernel matching ``kernel_name``, else ``None``. + + + +#### apply\_kernel\_message + +```python +def apply_kernel_message(data: dict, msg_id: str, execution: Execution, + emit_error: Callable[[ExecutionError], None], + on_result: Callable[[Result], Any] | None, + on_stdout: Callable[[str], Any] | None, + on_stderr: Callable[[str], Any] | None) -> bool +``` + +Apply one Jupyter IOPub message to ``execution``. + +Returns ``True`` when the message marks idle (cell done); the caller +should stop reading further messages. + #### build\_ws\_url ```python -def build_ws_url(base_url: str, capsule_id: str, kernel_id: str) -> str +def build_ws_url(base_url: str, + capsule_id: str, + kernel_id: str, + proxy_domain: str | None = None) -> str ``` Build the Jupyter kernel WebSocket URL for the given capsule. diff --git a/src/wrenn/async_capsule.py b/src/wrenn/async_capsule.py index 57f74af..292941d 100644 --- a/src/wrenn/async_capsule.py +++ b/src/wrenn/async_capsule.py @@ -137,21 +137,26 @@ class AsyncCapsule: AsyncCapsule: A new capsule instance. """ client = AsyncWrennClient(api_key=api_key, base_url=base_url) - info = await client.capsules.create( - template=template, - vcpus=vcpus, - memory_mb=memory_mb, - timeout_sec=timeout, - ) - assert info.id is not None - capsule = cls( - _capsule_id=info.id, - _client=client, - _info=info, - ) - if wait: - await capsule.wait_ready() - return capsule + try: + info = await client.capsules.create( + template=template, + vcpus=vcpus, + memory_mb=memory_mb, + timeout_sec=timeout, + ) + if info.id is None: + raise RuntimeError("API returned a capsule without an ID") + capsule = cls( + _capsule_id=info.id, + _client=client, + _info=info, + ) + if wait: + await capsule.wait_ready() + return capsule + except BaseException: + await client.aclose() + raise @classmethod async def connect( @@ -176,22 +181,26 @@ class AsyncCapsule: WrennNotFoundError: If no capsule with the given ID exists. """ client = AsyncWrennClient(api_key=api_key, base_url=base_url) - info = await client.capsules.get(capsule_id) + try: + info = await client.capsules.get(capsule_id) - capsule = cls( - _capsule_id=capsule_id, - _client=client, - _info=info, - ) + capsule = cls( + _capsule_id=capsule_id, + _client=client, + _info=info, + ) - if info.status == Status.pausing: - info = await capsule._wait_for_status({Status.paused}, _PAUSE_INTERVAL) - if info.status == Status.paused: - await client.capsules.resume(capsule_id) - if info.status != Status.running: - await capsule.wait_ready() + if info.status == Status.pausing: + info = await capsule._wait_for_status({Status.paused}, _PAUSE_INTERVAL) + if info.status == Status.paused: + await client.capsules.resume(capsule_id) + if info.status != Status.running: + await capsule.wait_ready() - return capsule + return capsule + except BaseException: + await client.aclose() + raise # ── Dual instance/static lifecycle ────────────────────────── diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py index 5a8ddcb..9814076 100644 --- a/src/wrenn/capsule.py +++ b/src/wrenn/capsule.py @@ -20,18 +20,14 @@ from wrenn.models import Status, Template from wrenn.pty import PtySession -def _build_proxy_url( +def _proxy_url( base_url: str, capsule_id: str | None, port: int, - proxy_domain: str | None = None, + proxy_domain: str | None, + *, + websocket: bool, ) -> str: - """Build the WebSocket proxy URL (``ws://`` / ``wss://``). - - Scheme is derived from ``base_url``. The host portion comes from - ``proxy_domain`` if provided; otherwise falls back to the ``base_url`` - host (with port). - """ parsed = httpx.URL(base_url) if proxy_domain: host = proxy_domain @@ -39,31 +35,32 @@ def _build_proxy_url( host = parsed.host if parsed.port: host = f"{host}:{parsed.port}" - scheme = "ws" if parsed.scheme == "http" else "wss" + secure = parsed.scheme not in ("http", "ws") + if websocket: + scheme = "wss" if secure else "ws" + else: + scheme = "https" if secure else "http" return f"{scheme}://{port}-{capsule_id}.{host}" +def _build_proxy_url( + base_url: str, + capsule_id: str | None, + port: int, + proxy_domain: str | None = None, +) -> str: + """Build the WebSocket proxy URL (``ws://`` / ``wss://``).""" + return _proxy_url(base_url, capsule_id, port, proxy_domain, websocket=True) + + def _build_http_proxy_url( base_url: str, capsule_id: str | None, port: int, proxy_domain: str | None = None, ) -> str: - """Build the HTTP proxy URL (``http://`` / ``https://``). - - Scheme is derived from ``base_url``. The host portion comes from - ``proxy_domain`` if provided; otherwise falls back to the ``base_url`` - host (with port). Any path on ``base_url`` is discarded. - """ - parsed = httpx.URL(base_url) - if proxy_domain: - host = proxy_domain - else: - host = parsed.host - if parsed.port: - host = f"{host}:{parsed.port}" - scheme = "http" if parsed.scheme in ("http", "ws") else "https" - return f"{scheme}://{port}-{capsule_id}.{host}" + """Build the HTTP proxy URL (``http://`` / ``https://``).""" + return _proxy_url(base_url, capsule_id, port, proxy_domain, websocket=False) _RESUME_INTERVAL = 0.5 diff --git a/src/wrenn/client.py b/src/wrenn/client.py index 46500c6..58ef09b 100644 --- a/src/wrenn/client.py +++ b/src/wrenn/client.py @@ -1,6 +1,8 @@ from __future__ import annotations +import asyncio import os +import time import httpx @@ -23,6 +25,55 @@ from wrenn.models import ( _LONG_TIMEOUT = httpx.Timeout(60.0) _DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=10.0) +_RETRY_EXCEPTIONS: tuple[type[BaseException], ...] = ( + httpx.ReadError, + httpx.RemoteProtocolError, + httpx.ConnectError, + httpx.ReadTimeout, +) +_RETRY_METHODS = frozenset({"GET", "HEAD", "DELETE", "OPTIONS", "PUT"}) +_MAX_RETRIES = 3 +_BACKOFF_BASE = 0.3 + + +def _should_retry(request: httpx.Request, attempt: int) -> bool: + return attempt < _MAX_RETRIES - 1 and request.method.upper() in _RETRY_METHODS + + +def _backoff_delay(attempt: int) -> float: + return _BACKOFF_BASE * (2**attempt) + + +class _RetryingClient(httpx.Client): + """httpx.Client that retries transient TLS/connection errors on + idempotent methods (GET/HEAD/DELETE/OPTIONS/PUT). Non-idempotent + requests (POST/PATCH) propagate immediately.""" + + def send(self, request: httpx.Request, **kwargs): # type: ignore[override] + for attempt in range(_MAX_RETRIES): + try: + return super().send(request, **kwargs) + except _RETRY_EXCEPTIONS: + if not _should_retry(request, attempt): + raise + time.sleep(_backoff_delay(attempt)) + # Unreachable: loop either returns or raises. + raise RuntimeError("retry loop exited without result") + + +class _RetryingAsyncClient(httpx.AsyncClient): + """Async variant of :class:`_RetryingClient`.""" + + async def send(self, request: httpx.Request, **kwargs): # type: ignore[override] + for attempt in range(_MAX_RETRIES): + try: + return await super().send(request, **kwargs) + except _RETRY_EXCEPTIONS: + if not _should_retry(request, attempt): + raise + await asyncio.sleep(_backoff_delay(attempt)) + raise RuntimeError("retry loop exited without result") + def _resolve_api_key(api_key: str | None) -> str: resolved = api_key or os.environ.get(ENV_API_KEY) @@ -63,6 +114,43 @@ def _resolve_proxy_domain(base_url: str, override: str | None) -> str: return host +def _build_capsule_create_payload( + template: str | None, + vcpus: int | None, + memory_mb: int | None, + timeout_sec: int | None, +) -> dict: + payload: dict = {} + if template is not None: + payload["template"] = template + if vcpus is not None: + payload["vcpus"] = vcpus + if memory_mb is not None: + payload["memory_mb"] = memory_mb + if timeout_sec is not None: + payload["timeout_sec"] = timeout_sec + return payload + + +def _build_snapshot_create( + capsule_id: str, name: str | None, overwrite: bool +) -> tuple[dict, dict]: + payload: dict = {"sandbox_id": capsule_id} + if name is not None: + payload["name"] = name + params: dict = {} + if overwrite: + params["overwrite"] = "true" + return payload, params + + +def _snapshot_list_params(type: str | None) -> dict: + params: dict = {} + if type is not None: + params["type"] = type + return params + + class CapsulesResource: """Sync capsule control-plane operations.""" @@ -88,16 +176,10 @@ class CapsulesResource: Returns: CapsuleModel: The newly created capsule. """ - payload: dict = {} - if template is not None: - payload["template"] = template - if vcpus is not None: - payload["vcpus"] = vcpus - if memory_mb is not None: - payload["memory_mb"] = memory_mb - if timeout_sec is not None: - payload["timeout_sec"] = timeout_sec - resp = self._http.post("/v1/capsules", json=payload) + resp = self._http.post( + "/v1/capsules", + json=_build_capsule_create_payload(template, vcpus, memory_mb, timeout_sec), + ) return CapsuleModel.model_validate(handle_response(resp)) def list(self) -> list[CapsuleModel]: @@ -204,16 +286,10 @@ class AsyncCapsulesResource: Returns: CapsuleModel: The newly created capsule. """ - payload: dict = {} - if template is not None: - payload["template"] = template - if vcpus is not None: - payload["vcpus"] = vcpus - if memory_mb is not None: - payload["memory_mb"] = memory_mb - if timeout_sec is not None: - payload["timeout_sec"] = timeout_sec - resp = await self._http.post("/v1/capsules", json=payload) + resp = await self._http.post( + "/v1/capsules", + json=_build_capsule_create_payload(template, vcpus, memory_mb, timeout_sec), + ) return CapsuleModel.model_validate(handle_response(resp)) async def list(self) -> list[CapsuleModel]: @@ -319,12 +395,7 @@ class SnapshotsResource: Returns: Template: The created snapshot template. """ - payload: dict = {"sandbox_id": capsule_id} - if name is not None: - payload["name"] = name - params: dict = {} - if overwrite: - params["overwrite"] = "true" + payload, params = _build_snapshot_create(capsule_id, name, overwrite) resp = self._http.post( "/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT ) @@ -340,10 +411,7 @@ class SnapshotsResource: Returns: list[Template]: Matching snapshot templates. """ - params: dict = {} - if type is not None: - params["type"] = type - resp = self._http.get("/v1/snapshots", params=params) + resp = self._http.get("/v1/snapshots", params=_snapshot_list_params(type)) return [Template.model_validate(item) for item in handle_response(resp)] def delete(self, name: str) -> None: @@ -383,12 +451,7 @@ class AsyncSnapshotsResource: Returns: Template: The created snapshot template. """ - payload: dict = {"sandbox_id": capsule_id} - if name is not None: - payload["name"] = name - params: dict = {} - if overwrite: - params["overwrite"] = "true" + payload, params = _build_snapshot_create(capsule_id, name, overwrite) resp = await self._http.post( "/v1/snapshots", json=payload, params=params, timeout=_LONG_TIMEOUT ) @@ -404,10 +467,7 @@ class AsyncSnapshotsResource: Returns: list[Template]: Matching snapshot templates. """ - params: dict = {} - if type is not None: - params["type"] = type - resp = await self._http.get("/v1/snapshots", params=params) + resp = await self._http.get("/v1/snapshots", params=_snapshot_list_params(type)) return [Template.model_validate(item) for item in handle_response(resp)] async def delete(self, name: str) -> None: @@ -430,7 +490,7 @@ class WrennClient: Args: api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var. - base_url: Wrenn API base URL. + base_url: Wrenn API base URL. Falls back to ``WRENN_BASE_URL`` env var. proxy_domain: Host suffix for capsule proxy URLs (``{port}-{capsule_id}.``). Falls back to ``WRENN_PROXY_DOMAIN`` env, then ``wrenn.dev`` when ``base_url`` @@ -449,7 +509,7 @@ class WrennClient: self._api_key = _resolve_api_key(api_key) self._base_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL) self._proxy_domain = _resolve_proxy_domain(self._base_url, proxy_domain) - self._http = httpx.Client( + self._http = _RetryingClient( base_url=self._base_url, headers={"X-API-Key": self._api_key}, timeout=_resolve_timeout(timeout), @@ -505,7 +565,7 @@ class AsyncWrennClient: self._api_key = _resolve_api_key(api_key) self._base_url = base_url or os.environ.get(ENV_BASE_URL, DEFAULT_BASE_URL) self._proxy_domain = _resolve_proxy_domain(self._base_url, proxy_domain) - self._http = httpx.AsyncClient( + self._http = _RetryingAsyncClient( base_url=self._base_url, headers={"X-API-Key": self._api_key}, timeout=_resolve_timeout(timeout), diff --git a/src/wrenn/code_runner/_protocol.py b/src/wrenn/code_runner/_protocol.py index 100da36..751a8a6 100644 --- a/src/wrenn/code_runner/_protocol.py +++ b/src/wrenn/code_runner/_protocol.py @@ -7,8 +7,15 @@ from __future__ import annotations import time import uuid +from collections.abc import Callable +from typing import Any from wrenn.capsule import _build_proxy_url +from wrenn.code_runner.models import ( + Execution, + ExecutionError, + Result, +) def build_execute_request(code: str) -> dict: @@ -45,6 +52,76 @@ def build_execute_request(code: str) -> dict: } +def pick_kernel_id(kernels: list[dict], kernel_name: str) -> str | None: + """Return the ID of the first kernel matching ``kernel_name``, else ``None``.""" + for k in kernels: + if k.get("name") == kernel_name: + return k.get("id") + return None + + +def apply_kernel_message( + data: dict, + msg_id: str, + execution: Execution, + emit_error: Callable[[ExecutionError], None], + on_result: Callable[[Result], Any] | None, + on_stdout: Callable[[str], Any] | None, + on_stderr: Callable[[str], Any] | None, +) -> bool: + """Apply one Jupyter IOPub message to ``execution``. + + Returns ``True`` when the message marks idle (cell done); the caller + should stop reading further messages. + """ + parent = data.get("parent_header", {}).get("msg_id") + if parent != msg_id: + return False + msg_type = data.get("msg_type") or data.get("header", {}).get("msg_type") + content = data.get("content", {}) + + if msg_type == "stream": + text = content.get("text", "") + name = content.get("name", "stdout") + if name == "stderr": + execution.logs.stderr.append(text) + if on_stderr is not None: + on_stderr(text) + else: + execution.logs.stdout.append(text) + if on_stdout is not None: + on_stdout(text) + elif msg_type in ("execute_result", "display_data"): + bundle = content.get("data", {}) + is_main = msg_type == "execute_result" + result = Result.from_bundle(bundle, is_main_result=is_main) + execution.results.append(result) + if is_main: + execution.execution_count = content.get("execution_count") + if on_result is not None: + on_result(result) + elif msg_type == "error": + emit_error( + ExecutionError( + name=content.get("ename", ""), + value=content.get("evalue", ""), + traceback="\n".join(content.get("traceback", [])), + ) + ) + elif msg_type == "status" and content.get("execution_state") == "idle": + return True + return False + + +def validate_language(language: str) -> None: + if language != "python": + raise ValueError( + f"language={language!r} is not supported; only 'python'. " + "Use the ``kernel=`` constructor argument to target a " + "non-Python kernelspec." + ) + + def build_ws_url( base_url: str, capsule_id: str, diff --git a/src/wrenn/code_runner/async_capsule.py b/src/wrenn/code_runner/async_capsule.py index 9dadb7f..e96f329 100644 --- a/src/wrenn/code_runner/async_capsule.py +++ b/src/wrenn/code_runner/async_capsule.py @@ -12,7 +12,13 @@ import httpx_ws from wrenn.async_capsule import AsyncCapsule as BaseAsyncCapsule from wrenn.capsule import _build_http_proxy_url from wrenn.client import AsyncWrennClient -from wrenn.code_runner._protocol import build_execute_request, build_ws_url +from wrenn.code_runner._protocol import ( + apply_kernel_message, + build_execute_request, + build_ws_url, + pick_kernel_id, + validate_language, +) from wrenn.code_runner.capsule import DEFAULT_KERNEL, DEFAULT_TEMPLATE from wrenn.code_runner.models import ( Execution, @@ -36,6 +42,8 @@ class AsyncCapsule(BaseAsyncCapsule): _kernel_id: str | None _kernel_name: str _proxy_client: httpx.AsyncClient | None + _ws: httpx_ws.AsyncWebSocketSession | None + _ws_cm: Any def __init__(self, *, kernel: str | None = None, **kwargs) -> None: # Set attrs before super().__init__ so __del__ never sees a @@ -43,9 +51,45 @@ class AsyncCapsule(BaseAsyncCapsule): self._kernel_id = None self._kernel_name = kernel or DEFAULT_KERNEL self._proxy_client = None + self._ws = None + self._ws_cm = None super().__init__(**kwargs) + async def _close_ws(self) -> None: + cm = getattr(self, "_ws_cm", None) + if cm is not None: + try: + await cm.__aexit__(None, None, None) + except Exception: + pass + self._ws = None + self._ws_cm = None + + async def _get_ws(self, kernel_id: str) -> httpx_ws.AsyncWebSocketSession: + if self._ws is not None: + return self._ws + ws_url = build_ws_url( + self._client._base_url, + self._id, + kernel_id, + self._client._proxy_domain, + ) + headers = {"X-API-Key": self._client._api_key} + cm: Any = httpx_ws.aconnect_ws(ws_url, headers=headers) + try: + ws = await cm.__aenter__() + except BaseException: + try: + await cm.__aexit__(None, None, None) + except Exception: + pass + raise + self._ws_cm = cm + self._ws = ws + return ws + async def close(self) -> None: + await self._close_ws() proxy = getattr(self, "_proxy_client", None) if proxy is not None: try: @@ -59,6 +103,13 @@ class AsyncCapsule(BaseAsyncCapsule): # reference and let httpx warn if the connection was never closed. # Users should call ``await close()`` or use ``async with``. self._proxy_client = None + self._ws = None + self._ws_cm = None + + async def _instance_destroy(self, wait: bool = False) -> None: + # Release WS + proxy client before destroying the capsule. + await self.close() + await super()._instance_destroy(wait=wait) @classmethod async def create( @@ -92,21 +143,27 @@ class AsyncCapsule(BaseAsyncCapsule): AsyncCapsule: A new async code runner capsule instance. """ client = AsyncWrennClient(api_key=api_key, base_url=base_url) - info = await client.capsules.create( - template=template or DEFAULT_TEMPLATE, - vcpus=vcpus, - memory_mb=memory_mb, - timeout_sec=timeout, - ) - capsule = cls( - kernel=kernel, - _capsule_id=info.id, - _client=client, - _info=info, - ) - if wait: - await capsule.wait_ready() - return capsule + try: + info = await client.capsules.create( + template=template or DEFAULT_TEMPLATE, + vcpus=vcpus, + memory_mb=memory_mb, + timeout_sec=timeout, + ) + if info.id is None: + raise RuntimeError("API returned a capsule without an ID") + capsule = cls( + kernel=kernel, + _capsule_id=info.id, + _client=client, + _info=info, + ) + if wait: + await capsule.wait_ready() + return capsule + except BaseException: + await client.aclose() + raise def _get_proxy_client(self) -> httpx.AsyncClient: if self._proxy_client is None: @@ -135,11 +192,10 @@ class AsyncCapsule(BaseAsyncCapsule): resp = await client.get("/api/kernels") if resp.status_code < 500: resp.raise_for_status() - kernels = resp.json() - for k in kernels: - if k.get("name") == self._kernel_name: - self._kernel_id = k["id"] - return self._kernel_id + matched = pick_kernel_id(resp.json(), self._kernel_name) + if matched is not None: + self._kernel_id = matched + return matched resp = await client.post( "/api/kernels", json={"name": self._kernel_name}, @@ -178,9 +234,14 @@ class AsyncCapsule(BaseAsyncCapsule): ) -> Execution: """Execute code in a persistent Jupyter kernel (async). + Variables, imports, and function definitions survive across calls. + Args: code: Code string to execute. - language: Execution backend language. Currently only ``"python"``. + language: Execution backend language. Currently only ``"python"`` + is supported; passing anything else raises ``ValueError``. + To target a non-Python kernel, set ``kernel=`` on the + capsule constructor. timeout: Maximum seconds to wait for execution to complete. jupyter_timeout: Maximum seconds to wait for Jupyter to become available. @@ -194,26 +255,14 @@ class AsyncCapsule(BaseAsyncCapsule): An :class:`Execution` with ``.results``, ``.logs``, ``.error``, and a convenience ``.text`` property. """ - if language != "python": - raise ValueError( - f"language={language!r} is not supported; only 'python'. " - "Use the ``kernel=`` constructor argument to target a " - "non-Python kernelspec." - ) + validate_language(language) kernel_id = await self._ensure_kernel(jupyter_timeout=jupyter_timeout) - ws_url = build_ws_url( - self._client._base_url, - self._id, - kernel_id, - self._client._proxy_domain, - ) msg = build_execute_request(code) msg_id = msg["header"]["msg_id"] execution = Execution() deadline = time.monotonic() + timeout - headers = {"X-API-Key": self._client._api_key} saw_idle = False def _emit_error(err: ExecutionError) -> None: @@ -221,69 +270,53 @@ class AsyncCapsule(BaseAsyncCapsule): if on_error is not None: on_error(err) - async with httpx_ws.aconnect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.AsyncWebSocketSession - await ws.send_text(json.dumps(msg)) - while True: - time_left = deadline - time.monotonic() - if time_left <= 0: - break - try: + reconnect_attempts = 1 + sent = False + while True: + try: + ws = await self._get_ws(kernel_id) + if not sent: + await ws.send_text(json.dumps(msg)) + sent = True + while True: + time_left = deadline - time.monotonic() + if time_left <= 0: + break data = await asyncio.wait_for(ws.receive_json(), timeout=time_left) - except (asyncio.TimeoutError, TimeoutError): - break - except ( - httpx_ws.WebSocketDisconnect, - httpx_ws.WebSocketNetworkError, - ) as exc: - execution.timed_out = True - _emit_error( - ExecutionError( - name="Disconnected", - value=f"kernel WebSocket closed: {exc}", - ) - ) - break - if not data: - break - parent = data.get("parent_header", {}).get("msg_id") - if parent != msg_id: + if not data: + break + if apply_kernel_message( + data, + msg_id, + execution, + _emit_error, + on_result, + on_stdout, + on_stderr, + ): + saw_idle = True + break + break + except TimeoutError: + break + except ( + httpx_ws.WebSocketDisconnect, + httpx_ws.WebSocketNetworkError, + httpx.ReadError, + httpx.RemoteProtocolError, + ) as exc: + await self._close_ws() + if reconnect_attempts > 0 and not sent: + reconnect_attempts -= 1 continue - msg_type = data.get("msg_type") or data.get("header", {}).get( - "msg_type" - ) - content = data.get("content", {}) - - if msg_type == "stream": - text = content.get("text", "") - name = content.get("name", "stdout") - if name == "stderr": - execution.logs.stderr.append(text) - if on_stderr is not None: - on_stderr(text) - else: - execution.logs.stdout.append(text) - if on_stdout is not None: - on_stdout(text) - elif msg_type in ("execute_result", "display_data"): - bundle = content.get("data", {}) - is_main = msg_type == "execute_result" - result = Result.from_bundle(bundle, is_main_result=is_main) - execution.results.append(result) - if is_main: - execution.execution_count = content.get("execution_count") - if on_result is not None: - on_result(result) - elif msg_type == "error": - _emit_error( - ExecutionError( - name=content.get("ename", ""), - value=content.get("evalue", ""), - traceback="\n".join(content.get("traceback", [])), - ) + _emit_error( + ExecutionError( + name="Disconnected", + value=f"kernel WebSocket closed: {exc}", ) - elif msg_type == "status" and content.get("execution_state") == "idle": - saw_idle = True - break + ) + execution.timed_out = True + break if not saw_idle and execution.error is None: execution.timed_out = True diff --git a/src/wrenn/code_runner/capsule.py b/src/wrenn/code_runner/capsule.py index b84e1e5..7fd7a40 100644 --- a/src/wrenn/code_runner/capsule.py +++ b/src/wrenn/code_runner/capsule.py @@ -10,7 +10,13 @@ import httpx_ws from wrenn.capsule import Capsule as BaseCapsule from wrenn.capsule import _build_http_proxy_url -from wrenn.code_runner._protocol import build_execute_request, build_ws_url +from wrenn.code_runner._protocol import ( + apply_kernel_message, + build_execute_request, + build_ws_url, + pick_kernel_id, + validate_language, +) from wrenn.code_runner.models import ( Execution, ExecutionError, @@ -37,6 +43,8 @@ class Capsule(BaseCapsule): _kernel_id: str | None _kernel_name: str _proxy_client: httpx.Client | None + _ws: httpx_ws.WebSocketSession | None + _ws_cm: Any def __init__( self, @@ -69,6 +77,8 @@ class Capsule(BaseCapsule): self._kernel_id = None self._kernel_name = kernel or DEFAULT_KERNEL self._proxy_client = None + self._ws = None + self._ws_cm = None super().__init__( template=template or DEFAULT_TEMPLATE, vcpus=vcpus, @@ -79,7 +89,41 @@ class Capsule(BaseCapsule): **kwargs, ) + def _close_ws(self) -> None: + cm = getattr(self, "_ws_cm", None) + if cm is not None: + try: + cm.__exit__(None, None, None) + except Exception: + pass + self._ws = None + self._ws_cm = None + + def _get_ws(self, kernel_id: str) -> httpx_ws.WebSocketSession: + if self._ws is not None: + return self._ws + ws_url = build_ws_url( + self._client._base_url, + self._id, + kernel_id, + self._client._proxy_domain, + ) + headers = {"X-API-Key": self._client._api_key} + cm: Any = httpx_ws.connect_ws(ws_url, headers=headers) + try: + ws = cm.__enter__() + except BaseException: + try: + cm.__exit__(None, None, None) + except Exception: + pass + raise + self._ws_cm = cm + self._ws = ws + return ws + def close(self) -> None: + self._close_ws() proxy = getattr(self, "_proxy_client", None) if proxy is not None: try: @@ -94,6 +138,13 @@ class Capsule(BaseCapsule): except Exception: pass + def _instance_destroy(self, wait: bool = False) -> None: + # Release WS threads + proxy client before destroying. + # httpx_ws sync sessions spawn non-daemon threads; not joining + # them keeps the interpreter alive after tests/scripts return. + self.close() + super()._instance_destroy(wait=wait) + @classmethod def create( cls, @@ -164,11 +215,10 @@ class Capsule(BaseCapsule): resp = client.get("/api/kernels") if resp.status_code < 500: resp.raise_for_status() - kernels = resp.json() - for k in kernels: - if k.get("name") == self._kernel_name: - self._kernel_id = k["id"] - return self._kernel_id + matched = pick_kernel_id(resp.json(), self._kernel_name) + if matched is not None: + self._kernel_id = matched + return matched # No matching kernel; create one with the requested spec. resp = client.post( "/api/kernels", @@ -229,26 +279,14 @@ class Capsule(BaseCapsule): An :class:`Execution` with ``.results``, ``.logs``, ``.error``, and a convenience ``.text`` property. """ - if language != "python": - raise ValueError( - f"language={language!r} is not supported; only 'python'. " - "Use the ``kernel=`` constructor argument to target a " - "non-Python kernelspec." - ) + validate_language(language) kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout) - ws_url = build_ws_url( - self._client._base_url, - self._id, - kernel_id, - self._client._proxy_domain, - ) msg = build_execute_request(code) msg_id = msg["header"]["msg_id"] execution = Execution() deadline = time.monotonic() + timeout - headers = {"X-API-Key": self._client._api_key} saw_idle = False def _emit_error(err: ExecutionError) -> None: @@ -256,69 +294,53 @@ class Capsule(BaseCapsule): if on_error is not None: on_error(err) - with httpx_ws.connect_ws(ws_url, headers=headers) as ws: # type: httpx_ws.WebSocketSession - ws.send_text(json.dumps(msg)) - while True: - time_left = deadline - time.monotonic() - if time_left <= 0: - break - try: + reconnect_attempts = 1 + sent = False + while True: + try: + ws = self._get_ws(kernel_id) + if not sent: + ws.send_text(json.dumps(msg)) + sent = True + while True: + time_left = deadline - time.monotonic() + if time_left <= 0: + break data = ws.receive_json(timeout=time_left) - except TimeoutError: - break - except ( - httpx_ws.WebSocketDisconnect, - httpx_ws.WebSocketNetworkError, - ) as exc: - execution.timed_out = True - _emit_error( - ExecutionError( - name="Disconnected", - value=f"kernel WebSocket closed: {exc}", - ) - ) - break - if not data: - break - parent = data.get("parent_header", {}).get("msg_id") - if parent != msg_id: + if not data: + break + if apply_kernel_message( + data, + msg_id, + execution, + _emit_error, + on_result, + on_stdout, + on_stderr, + ): + saw_idle = True + break + break + except TimeoutError: + break + except ( + httpx_ws.WebSocketDisconnect, + httpx_ws.WebSocketNetworkError, + httpx.ReadError, + httpx.RemoteProtocolError, + ) as exc: + self._close_ws() + if reconnect_attempts > 0 and not sent: + reconnect_attempts -= 1 continue - msg_type = data.get("msg_type") or data.get("header", {}).get( - "msg_type" - ) - content = data.get("content", {}) - - if msg_type == "stream": - text = content.get("text", "") - name = content.get("name", "stdout") - if name == "stderr": - execution.logs.stderr.append(text) - if on_stderr is not None: - on_stderr(text) - else: - execution.logs.stdout.append(text) - if on_stdout is not None: - on_stdout(text) - elif msg_type in ("execute_result", "display_data"): - bundle = content.get("data", {}) - is_main = msg_type == "execute_result" - result = Result.from_bundle(bundle, is_main_result=is_main) - execution.results.append(result) - if is_main: - execution.execution_count = content.get("execution_count") - if on_result is not None: - on_result(result) - elif msg_type == "error": - _emit_error( - ExecutionError( - name=content.get("ename", ""), - value=content.get("evalue", ""), - traceback="\n".join(content.get("traceback", [])), - ) + _emit_error( + ExecutionError( + name="Disconnected", + value=f"kernel WebSocket closed: {exc}", ) - elif msg_type == "status" and content.get("execution_state") == "idle": - saw_idle = True - break + ) + execution.timed_out = True + break if not saw_idle and execution.error is None: execution.timed_out = True diff --git a/src/wrenn/commands.py b/src/wrenn/commands.py index 2ad4957..dece7f7 100644 --- a/src/wrenn/commands.py +++ b/src/wrenn/commands.py @@ -111,6 +111,54 @@ def _parse_stream_event(raw: dict) -> StreamEvent: return StreamEvent(type=t or "unknown") +def _build_exec_payload( + cmd: str, + background: bool, + timeout: int | None, + envs: dict[str, str] | None, + cwd: str | None, + tag: str | None, +) -> dict: + payload: dict = { + "cmd": "/bin/sh", + "args": ["-c", cmd], + "background": background, + } + if timeout is not None and not background: + payload["timeout_sec"] = timeout + if envs is not None: + payload["envs"] = envs + if cwd is not None: + payload["cwd"] = cwd + if tag is not None: + payload["tag"] = tag + return payload + + +def _exec_http_timeout(background: bool, timeout: int | None) -> httpx.Timeout | None: + if not background and timeout is not None: + return httpx.Timeout(timeout + 10, connect=5.0) + return None + + +def _decode_exec_run( + data: dict, capsule_id: str, background: bool +) -> CommandResult | CommandHandle: + if background: + return CommandHandle( + pid=data.get("pid", 0), + tag=data.get("tag", ""), + capsule_id=capsule_id, + ) + return _decode_exec_response(data) + + +def _build_stream_start(cmd: str, args: builtins.list[str] | None) -> dict: + if args: + return {"type": "start", "cmd": cmd, "args": args} + return {"type": "start", "cmd": "/bin/sh", "args": ["-c", cmd]} + + def _decode_exec_response(data: dict) -> CommandResult: stdout = data.get("stdout") or "" stderr = data.get("stderr") or "" @@ -189,39 +237,14 @@ class Commands: CommandHandle: PID and tag for background commands (``background=True``). """ - payload: dict = { - "cmd": "/bin/sh", - "args": ["-c", cmd], - "background": background, - } - if timeout is not None and not background: - payload["timeout_sec"] = timeout - if envs is not None: - payload["envs"] = envs - if cwd is not None: - payload["cwd"] = cwd - 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 = self._http.post( f"/v1/capsules/{self._capsule_id}/exec", - json=payload, - timeout=http_timeout, + json=_build_exec_payload(cmd, background, timeout, envs, cwd, tag), + timeout=_exec_http_timeout(background, timeout), ) data = handle_response(resp) assert isinstance(data, dict) - - if background: - return CommandHandle( - pid=data.get("pid", 0), - tag=data.get("tag", ""), - capsule_id=self._capsule_id, - ) - return _decode_exec_response(data) + return _decode_exec_run(data, self._capsule_id, background) def list(self) -> list[ProcessInfo]: """List all running background processes in the capsule. @@ -299,11 +322,7 @@ class Commands: f"/v1/capsules/{self._capsule_id}/exec/stream", self._http, ) as ws: # type: httpx_ws.WebSocketSession - if args: - start_msg: dict = {"type": "start", "cmd": cmd, "args": args} - else: - start_msg = {"type": "start", "cmd": "/bin/sh", "args": ["-c", cmd]} - ws.send_text(json.dumps(start_msg)) + ws.send_text(json.dumps(_build_stream_start(cmd, args))) while True: try: raw = ws.receive_json() @@ -378,39 +397,14 @@ class AsyncCommands: CommandHandle: PID and tag for background commands (``background=True``). """ - payload: dict = { - "cmd": "/bin/sh", - "args": ["-c", cmd], - "background": background, - } - if timeout is not None and not background: - payload["timeout_sec"] = timeout - if envs is not None: - payload["envs"] = envs - if cwd is not None: - payload["cwd"] = cwd - 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, - timeout=http_timeout, + json=_build_exec_payload(cmd, background, timeout, envs, cwd, tag), + timeout=_exec_http_timeout(background, timeout), ) data = handle_response(resp) assert isinstance(data, dict) - - if background: - return CommandHandle( - pid=data.get("pid", 0), - tag=data.get("tag", ""), - capsule_id=self._capsule_id, - ) - return _decode_exec_response(data) + return _decode_exec_run(data, self._capsule_id, background) async def list(self) -> list[ProcessInfo]: """List all running background processes in the capsule. @@ -490,11 +484,7 @@ class AsyncCommands: f"/v1/capsules/{self._capsule_id}/exec/stream", self._http, ) as ws: # type: httpx_ws.AsyncWebSocketSession - if args: - start_msg: dict = {"type": "start", "cmd": cmd, "args": args} - else: - start_msg = {"type": "start", "cmd": "/bin/sh", "args": ["-c", cmd]} - await ws.send_text(json.dumps(start_msg)) + await ws.send_text(json.dumps(_build_stream_start(cmd, args))) try: while True: raw = await ws.receive_json() diff --git a/src/wrenn/files.py b/src/wrenn/files.py index 291ff8b..08a7e5e 100644 --- a/src/wrenn/files.py +++ b/src/wrenn/files.py @@ -39,6 +39,46 @@ def _find_entry(list_fn, path: str) -> FileEntry | None: return None +async def _async_find_entry(list_fn, path: str) -> FileEntry | None: + parent = os.path.dirname(path) + name = os.path.basename(path) + try: + for entry in await list_fn(parent, depth=1): + if entry.name == name: + return entry + except WrennNotFoundError: + return None + return None + + +_MULTIPART_FILE_HEADER = ( + b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n' + b"Content-Type: application/octet-stream\r\n\r\n" +) + + +def _multipart_frame(path: str, boundary: bytes) -> tuple[bytes, bytes]: + """Return (preamble, trailer) bytes wrapping the file body chunks.""" + preamble = ( + b"--" + boundary + b"\r\n" + b'Content-Disposition: form-data; name="path"\r\n\r\n' + + path.encode("utf-8") + + b"\r\n--" + + boundary + + b"\r\n" + + _MULTIPART_FILE_HEADER + ) + trailer = b"\r\n--" + boundary + b"--\r\n" + return preamble, trailer + + +def _multipart_headers(boundary: bytes) -> dict[str, str]: + return { + "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}", + "Transfer-Encoding": "chunked", + } + + class Files: """Sync filesystem interface. Accessed via ``capsule.files``.""" @@ -183,25 +223,18 @@ class Files: stream (Iterator[bytes]): Iterable of byte chunks to upload. """ boundary = os.urandom(16).hex().encode("utf-8") + preamble, trailer = _multipart_frame(path, boundary) def _multipart() -> Iterator[bytes]: - yield b"--" + boundary + b"\r\n" - yield b'Content-Disposition: form-data; name="path"\r\n\r\n' - yield path.encode("utf-8") + b"\r\n" - yield b"--" + boundary + b"\r\n" - yield b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n' - yield b"Content-Type: application/octet-stream\r\n\r\n" + yield preamble for chunk in stream: yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") - yield b"\r\n--" + boundary + b"--\r\n" + yield trailer resp = self._http.post( f"/v1/capsules/{self._capsule_id}/files/stream/write", content=_multipart(), - headers={ - "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}", - "Transfer-Encoding": "chunked", - }, + headers=_multipart_headers(boundary), ) _raise_for_status(resp) @@ -340,11 +373,9 @@ class AsyncFiles: json={"path": path}, ) if _is_already_exists(resp): - parent = os.path.dirname(path) - name = os.path.basename(path) - for entry in await self.list(parent, depth=1): - if entry.name == name: - return entry + existing = await _async_find_entry(self.list, path) + if existing is not None: + return existing parsed = MakeDirResponse.model_validate(handle_response(resp)) if parsed.entry is None: raise RuntimeError("mkdir response missing entry") @@ -377,25 +408,18 @@ class AsyncFiles: upload. """ boundary = os.urandom(16).hex().encode("utf-8") + preamble, trailer = _multipart_frame(path, boundary) async def _multipart() -> AsyncIterator[bytes]: - yield b"--" + boundary + b"\r\n" - yield b'Content-Disposition: form-data; name="path"\r\n\r\n' - yield path.encode("utf-8") + b"\r\n" - yield b"--" + boundary + b"\r\n" - yield b'Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n' - yield b"Content-Type: application/octet-stream\r\n\r\n" + yield preamble async for chunk in stream: yield chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") - yield b"\r\n--" + boundary + b"--\r\n" + yield trailer resp = await self._http.post( f"/v1/capsules/{self._capsule_id}/files/stream/write", content=_multipart(), - headers={ - "Content-Type": f"multipart/form-data; boundary={boundary.decode('utf-8')}", - "Transfer-Encoding": "chunked", - }, + headers=_multipart_headers(boundary), ) _raise_for_status(resp) diff --git a/tests/test_code_runner_e2e.py b/tests/test_code_runner_e2e.py index dd233ff..12cae7c 100644 --- a/tests/test_code_runner_e2e.py +++ b/tests/test_code_runner_e2e.py @@ -481,58 +481,41 @@ class TestCodeRunnerAsync: @pytest.mark.asyncio async def test_async_simple(self): - c = await AsyncCapsule.create(wait=True) - try: + async with await AsyncCapsule.create(wait=True) as c: ex = await c.run_code("21 * 2") assert ex.error is None assert ex.text == "42" - finally: - await c.close() - await c.destroy() @pytest.mark.asyncio async def test_async_persistence(self): - c = await AsyncCapsule.create(wait=True) - try: + async with await AsyncCapsule.create(wait=True) as c: await c.run_code("v = 'persisted'") ex = await c.run_code("v") assert ex.text == "'persisted'" - finally: - await c.close() - await c.destroy() @pytest.mark.asyncio async def test_async_callbacks(self): - c = await AsyncCapsule.create(wait=True) - try: + async with await AsyncCapsule.create(wait=True) as c: chunks: list[str] = [] await c.run_code( "print('async out')", on_stdout=chunks.append, ) assert any("async out" in s for s in chunks) - finally: - await c.close() - await c.destroy() @pytest.mark.asyncio async def test_async_context_manager(self): - c = await AsyncCapsule.create(wait=True) - async with c: + async with await AsyncCapsule.create(wait=True) as c: ex = await c.run_code("'in-ctx'") assert ex.text == "'in-ctx'" @pytest.mark.asyncio async def test_async_concurrent_capsules(self): - c1 = await AsyncCapsule.create(wait=True) - c2 = await AsyncCapsule.create(wait=True) - try: - r1, r2 = await asyncio.gather( - c1.run_code("1 + 1"), - c2.run_code("10 * 10"), - ) - assert r1.text == "2" - assert r2.text == "100" - finally: - await asyncio.gather(c1.close(), c2.close(), return_exceptions=True) - await asyncio.gather(c1.destroy(), c2.destroy(), return_exceptions=True) + async with await AsyncCapsule.create(wait=True) as c1: + async with await AsyncCapsule.create(wait=True) as c2: + r1, r2 = await asyncio.gather( + c1.run_code("1 + 1"), + c2.run_code("10 * 10"), + ) + assert r1.text == "2" + assert r2.text == "100" -- 2.49.0 From 08314b172bf0f1c00893eb92356627bdb474e0bd Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sat, 23 May 2026 01:57:34 +0600 Subject: [PATCH 41/44] fix: update SDK for non-root user capsules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update default template from minimal to minimal-ubuntu - Replace /root and /home/user paths with /home/wrenn-user in tests - Update file ownership from root:root to wrenn-user:wrenn-user - Add sudo-related integration tests - Regenerate pydantic models from updated OpenAPI spec - Add snapshotting status, AdminTemplate schema, protected field - Rename Type1→Type5 enums to match new spec - Update OpenAPI spec for async snapshot endpoints --- api/openapi.yaml | 108 ++++++++++++++++++++++++----- src/wrenn/models/__init__.py | 2 - src/wrenn/models/_generated.py | 57 ++++++++++++--- tests/test_filesystem_pty.py | 36 +++++----- tests/test_integration.py | 10 +-- tests/test_integration_advanced.py | 86 ++++++++++++++++------- 6 files changed, 219 insertions(+), 80 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index f3fb110..c8ad59f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1421,10 +1421,19 @@ paths: - apiKeyAuth: [] - sessionAuth: [] description: | - Live snapshot: briefly pauses the capsule, writes its VM state + - memory + flattened rootfs to a new template directory, then resumes - the capsule. The source capsule keeps running after the snapshot; - the resulting template can be used to create new capsules. + Snapshot a capsule, processed asynchronously. The call returns + immediately with the capsule in the `snapshotting` state, then it + returns to its original state on completion. The capsule must be + `running` or `paused`. + + A `running` capsule is snapshotted live: it briefly pauses while its VM + state + memory + flattened rootfs are written to a new template, then + resumes to `running`. A `paused` capsule is snapshotted directly from + its on-disk state without reviving the VM, and stays `paused`. + + Because it is async, the response does NOT contain the template. Watch + for the `template.snapshot.create` SSE event (its `outcome` reports + success or failure) or poll `GET /v1/snapshots` to observe completion. Snapshots are immutable: each call must use a fresh name. Re-using an existing name returns 409 Conflict. @@ -1435,14 +1444,14 @@ paths: schema: $ref: "#/components/schemas/CreateSnapshotRequest" responses: - "201": - description: Snapshot created + "202": + description: Snapshot accepted; capsule is now snapshotting content: application/json: schema: - $ref: "#/components/schemas/Template" + $ref: "#/components/schemas/Capsule" "409": - description: Name already exists or capsule not running + description: Name already exists, or capsule is not running or paused content: application/json: schema: @@ -2813,7 +2822,7 @@ paths: schema: type: array items: - $ref: "#/components/schemas/Template" + $ref: "#/components/schemas/AdminTemplate" /v1/admin/templates/{name}: delete: @@ -2899,6 +2908,26 @@ paths: "204": description: Cancelled + /v1/admin/builds/{id}/stream: + get: + summary: Stream a build's live console (admin, WebSocket) + description: > + WebSocket endpoint. On connect, replays the completed-step history, + then live-tails JSON events (step-start, output, step-end, + build-status, ping) until the build finishes. + operationId: adminStreamBuild + tags: [admin] + security: + - sessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: {type: string} + responses: + "101": + description: WebSocket upgrade — streams build console events + /v1/admin/capsules: post: summary: Create a capsule on behalf of any team (admin) @@ -2969,6 +2998,10 @@ paths: summary: Create snapshot from any capsule (admin) operationId: adminCreateSnapshotFromCapsule tags: [admin] + description: | + Snapshots a `running` or `paused` capsule into a platform template, + processed asynchronously (see `POST /v1/snapshots`). A running capsule + resumes to `running`; a paused capsule stays `paused`. security: - sessionAuth: [] parameters: @@ -2977,21 +3010,22 @@ paths: required: true schema: {type: string} requestBody: - required: true + required: false content: application/json: schema: type: object - required: [name] properties: - name: {type: string} + name: + type: string + description: Optional; an auto-generated name is used when omitted. responses: - "201": - description: Snapshot created + "202": + description: Snapshot accepted; capsule is now snapshotting content: application/json: schema: - $ref: "#/components/schemas/Template" + $ref: "#/components/schemas/Capsule" /v1/admin/capsules/{id}/exec: parameters: @@ -3486,7 +3520,7 @@ components: properties: template: type: string - default: minimal + default: minimal-ubuntu vcpus: type: integer default: 1 @@ -3590,7 +3624,7 @@ components: type: string status: type: string - enum: [pending, starting, running, pausing, paused, resuming, stopping, hibernated, stopped, missing, error] + enum: [pending, starting, running, pausing, paused, snapshotting, resuming, stopping, hibernated, stopped, missing, error] template: type: string vcpus: @@ -3664,13 +3698,51 @@ components: type: boolean description: | True when the template is platform-managed (visible to all teams, - e.g. the built-in `minimal` rootfs). False for team-owned + e.g. the built-in `minimal-ubuntu` rootfs). False for team-owned snapshot templates. + protected: + type: boolean + description: | + True for built-in system base templates (minimal-ubuntu, + minimal-alpine, minimal-arch, minimal-fedora). Protected templates + cannot be deleted. metadata: type: object additionalProperties: {type: string} nullable: true + AdminTemplate: + type: object + description: | + Template as returned by the admin templates list. Unlike `Template` + (the team-facing snapshot shape), this includes the owning `team_id` + and omits `platform`/`metadata`. + properties: + name: + type: string + type: + type: string + enum: [base, snapshot] + vcpus: + type: integer + memory_mb: + type: integer + size_bytes: + type: integer + format: int64 + team_id: + type: string + description: Owning team ID (formatted, e.g. `team-…`). Platform team for global templates. + created_at: + type: string + format: date-time + protected: + type: boolean + description: | + True for built-in system base templates (minimal-ubuntu, + minimal-alpine, minimal-arch, minimal-fedora). Protected templates + cannot be deleted. + ExecRequest: type: object required: [cmd] diff --git a/src/wrenn/models/__init__.py b/src/wrenn/models/__init__.py index 6fe5eb8..52bdc62 100644 --- a/src/wrenn/models/__init__.py +++ b/src/wrenn/models/__init__.py @@ -27,7 +27,6 @@ from wrenn.models._generated import ( Status1, Template, Type, - Type1, Type2, ) @@ -60,6 +59,5 @@ __all__ = [ "Status1", "Template", "Type", - "Type1", "Type2", ] diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py index 8eb7425..e78331f 100644 --- a/src/wrenn/models/_generated.py +++ b/src/wrenn/models/_generated.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-05-19T08:54:50+00:00 +# timestamp: 2026-05-22T19:20:45+00:00 from __future__ import annotations from pydantic import AwareDatetime, BaseModel, EmailStr, Field @@ -65,7 +65,7 @@ class APIKeyResponse(BaseModel): class CreateCapsuleRequest(BaseModel): - template: str | None = "minimal" + template: str | None = "minimal-ubuntu" vcpus: int | None = 1 memory_mb: int | None = 512 disk_size_mb: Annotated[ @@ -148,6 +148,7 @@ class Status(StrEnum): running = "running" pausing = "pausing" paused = "paused" + snapshotting = "snapshotting" resuming = "resuming" stopping = "stopping" hibernated = "hibernated" @@ -203,12 +204,46 @@ class Template(BaseModel): platform: Annotated[ bool | None, Field( - description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal` rootfs). False for team-owned\nsnapshot templates.\n" + description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal-ubuntu` rootfs). False for team-owned\nsnapshot templates.\n" + ), + ] = None + protected: Annotated[ + bool | None, + Field( + description="True for built-in system base templates (minimal-ubuntu,\nminimal-alpine, minimal-arch, minimal-fedora). Protected templates\ncannot be deleted.\n" ), ] = None metadata: dict[str, str] | None = None +class AdminTemplate(BaseModel): + """ + Template as returned by the admin templates list. Unlike `Template` + (the team-facing snapshot shape), this includes the owning `team_id` + and omits `platform`/`metadata`. + + """ + + name: str | None = None + type: Type | None = None + vcpus: int | None = None + memory_mb: int | None = None + size_bytes: int | None = None + team_id: Annotated[ + str | None, + Field( + description="Owning team ID (formatted, e.g. `team-…`). Platform team for global templates." + ), + ] = None + created_at: AwareDatetime | None = None + protected: Annotated[ + bool | None, + Field( + description="True for built-in system base templates (minimal-ubuntu,\nminimal-alpine, minimal-arch, minimal-fedora). Protected templates\ncannot be deleted.\n" + ), + ] = None + + class ExecRequest(BaseModel): cmd: str args: list[str] | None = None @@ -296,7 +331,7 @@ class ListDirRequest(BaseModel): ] = 1 -class Type1(StrEnum): +class Type2(StrEnum): file = "file" directory = "directory" symlink = "symlink" @@ -305,7 +340,7 @@ class Type1(StrEnum): class FileEntry(BaseModel): name: str | None = None path: str | None = None - type: Type1 | None = None + type: Type2 | None = None size: int | None = None mode: int | None = None permissions: Annotated[ @@ -333,7 +368,7 @@ class RemoveRequest(BaseModel): path: Annotated[str, Field(description="Path to remove inside the capsule")] -class Type2(StrEnum): +class Type3(StrEnum): """ Host type. Regular hosts are shared; BYOC hosts belong to a team. """ @@ -344,7 +379,7 @@ class Type2(StrEnum): class CreateHostRequest(BaseModel): type: Annotated[ - Type2, + Type3, Field( description="Host type. Regular hosts are shared; BYOC hosts belong to a team." ), @@ -372,7 +407,7 @@ class RegisterHostRequest(BaseModel): address: Annotated[str, Field(description="Host agent address (ip:port).")] -class Type3(StrEnum): +class Type4(StrEnum): regular = "regular" byoc = "byoc" @@ -387,7 +422,7 @@ class Status1(StrEnum): class Host(BaseModel): id: str | None = None - type: Type3 | None = None + type: Type4 | None = None team_id: str | None = None provider: str | None = None availability_zone: str | None = None @@ -678,14 +713,14 @@ class Resource(BaseModel): type: str | None = None -class Type4(StrEnum): +class Type5(StrEnum): user = "user" api_key = "api_key" system = "system" class Actor(BaseModel): - type: Type4 | None = None + type: Type5 | None = None id: str | None = None name: str | None = None diff --git a/tests/test_filesystem_pty.py b/tests/test_filesystem_pty.py index 2ce3f40..1c963b9 100644 --- a/tests/test_filesystem_pty.py +++ b/tests/test_filesystem_pty.py @@ -74,32 +74,32 @@ class TestFilesList: "entries": [ { "name": "main.py", - "path": "/home/user/main.py", + "path": "/home/wrenn-user/main.py", "type": "file", "size": 1024, "mode": 33188, "permissions": "-rw-r--r--", - "owner": "root", - "group": "root", + "owner": "wrenn-user", + "group": "wrenn-user", "modified_at": 1712899200, "symlink_target": None, }, { "name": "config", - "path": "/home/user/config", + "path": "/home/wrenn-user/config", "type": "directory", "size": 4096, "mode": 16877, "permissions": "drwxr-xr-x", - "owner": "root", - "group": "root", + "owner": "wrenn-user", + "group": "wrenn-user", "modified_at": 1712899100, "symlink_target": None, }, ] }, ) - entries = cap.files.list("/home/user") + entries = cap.files.list("/home/wrenn-user") assert len(entries) == 2 assert isinstance(entries[0], FileEntry) assert entries[0].name == "main.py" @@ -113,7 +113,7 @@ class TestFilesList: route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond( 200, json={"entries": []} ) - cap.files.list("/home/user", depth=3) + cap.files.list("/home/wrenn-user", depth=3) body = json.loads(route.calls[0].request.content) assert body["depth"] == 3 @@ -136,19 +136,19 @@ class TestFilesMakeDir: json={ "entry": { "name": "data", - "path": "/home/user/data", + "path": "/home/wrenn-user/data", "type": "directory", "size": 4096, "mode": 16877, "permissions": "drwxr-xr-x", - "owner": "root", - "group": "root", + "owner": "wrenn-user", + "group": "wrenn-user", "modified_at": 1712899200, "symlink_target": None, } }, ) - entry = cap.files.make_dir("/home/user/data") + entry = cap.files.make_dir("/home/wrenn-user/data") assert isinstance(entry, FileEntry) assert entry.name == "data" assert entry.type == "directory" @@ -166,20 +166,20 @@ class TestFilesMakeDir: "entries": [ { "name": "data", - "path": "/home/user/data", + "path": "/home/wrenn-user/data", "type": "directory", "size": 4096, "mode": 16877, "permissions": "drwxr-xr-x", - "owner": "root", - "group": "root", + "owner": "wrenn-user", + "group": "wrenn-user", "modified_at": 1712899200, "symlink_target": None, } ] }, ) - entry = cap.files.make_dir("/home/user/data") + entry = cap.files.make_dir("/home/wrenn-user/data") assert entry.name == "data" @@ -188,7 +188,7 @@ class TestFilesRemove: def test_remove_succeeds(self): cap = _make_capsule() route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204) - cap.files.remove("/home/user/old_data") + cap.files.remove("/home/wrenn-user/old_data") assert route.called @respx.mock @@ -411,7 +411,7 @@ class TestPtySessionSendStart: cols=120, rows=40, envs={"TERM": "xterm-256color"}, - cwd="/home/user", + cwd="/home/wrenn-user", ) sent = json.loads(ws.send_text.call_args[0][0]) assert sent["cmd"] == "/bin/zsh" diff --git a/tests/test_integration.py b/tests/test_integration.py index 49eaab7..358065e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -323,7 +323,7 @@ class TestFiles: class TestGit: """Shared capsule for git operation tests. - Initializes a repo at /root (default cwd) since the exec API + Initializes a repo at /home/wrenn-user (default cwd) since the exec API does not support the cwd parameter. """ @@ -344,14 +344,14 @@ class TestGit: pass def test_init_created_repo(self): - assert self.capsule.files.exists("/root/.git") + assert self.capsule.files.exists("/home/wrenn-user/.git") def test_status_clean(self): status = self.capsule.git.status() assert status.branch == "main" def test_add_and_commit(self): - self.capsule.files.write("/root/hello.txt", "hello git") + self.capsule.files.write("/home/wrenn-user/hello.txt", "hello git") self.capsule.git.add(all=True) result = self.capsule.git.commit("initial commit") assert result.exit_code == 0 @@ -361,14 +361,14 @@ class TestGit: assert status.is_clean def test_status_with_changes(self): - self.capsule.files.write("/root/dirty.txt", "uncommitted") + self.capsule.files.write("/home/wrenn-user/dirty.txt", "uncommitted") try: status = self.capsule.git.status() assert not status.is_clean paths = [f.path for f in status.files] assert "dirty.txt" in paths finally: - self.capsule.files.remove("/root/dirty.txt") + self.capsule.files.remove("/home/wrenn-user/dirty.txt") def test_branches(self): branches = self.capsule.git.branches() diff --git a/tests/test_integration_advanced.py b/tests/test_integration_advanced.py index 3f5e343..12c7da1 100644 --- a/tests/test_integration_advanced.py +++ b/tests/test_integration_advanced.py @@ -75,7 +75,7 @@ class TestCommandEnvironment: def test_default_cwd_is_home(self): result = self.capsule.commands.run("pwd") - assert result.stdout.strip() == "/root" + assert result.stdout.strip() == "/home/wrenn-user" def test_cwd_resolves_relative_paths(self): self.capsule.files.make_dir("/tmp/cwd_probe/sub") @@ -90,7 +90,7 @@ class TestCommandEnvironment: # Each run is a fresh process — `cd` in one does not affect the next. self.capsule.commands.run("cd /tmp") result = self.capsule.commands.run("pwd") - assert result.stdout.strip() == "/root" + assert result.stdout.strip() == "/home/wrenn-user" def test_single_env_var(self): result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"}) @@ -115,9 +115,29 @@ class TestCommandEnvironment: def test_base_environment_present(self): result = self.capsule.commands.run("echo $HOME; echo $PATH") lines = result.stdout.strip().splitlines() - assert lines[0] == "/root" + assert lines[0] == "/home/wrenn-user" assert "/usr/bin" in lines[1] + def test_sudo_available(self): + result = self.capsule.commands.run("which sudo") + assert result.exit_code == 0 + + def test_sudo_runs_without_password(self): + result = self.capsule.commands.run("sudo whoami") + assert result.exit_code == 0 + assert result.stdout.strip() == "root" + + def test_sudo_can_write_to_protected_path(self): + result = self.capsule.commands.run( + "sudo touch /opt/sudo-test-marker && cat /opt/sudo-test-marker" + ) + assert result.exit_code == 0 + + def test_sudo_can_read_root_owned_file(self): + result = self.capsule.commands.run("sudo cat /etc/shadow | head -1") + assert result.exit_code == 0 + assert "root" in result.stdout + # ══════════════════════════════════════════════════════════════════ # Long-running commands @@ -143,7 +163,7 @@ class TestLongRunningCommands: def test_apt_get_install(self): result = self.capsule.commands.run( - "apt-get update -qq && apt-get install -y -qq cowsay", timeout=300 + "sudo apt-get update -qq && sudo apt-get install -y -qq cowsay", timeout=300 ) assert result.exit_code == 0 @@ -388,7 +408,9 @@ class TestGitClone: def setup_class(cls): _ensure_env() cls.capsule = Capsule(wait=True) - cls.capsule.git.clone(WRENN_REPO, "/root/wrenn", depth=1, timeout=300) + cls.capsule.git.clone( + WRENN_REPO, "/home/wrenn-user/wrenn", depth=1, timeout=300 + ) @classmethod def teardown_class(cls): @@ -398,66 +420,74 @@ class TestGitClone: pass def test_clone_created_repo(self): - assert self.capsule.files.exists("/root/wrenn/.git") + assert self.capsule.files.exists("/home/wrenn-user/wrenn/.git") def test_clone_checked_out_files(self): - entries = self.capsule.files.list("/root/wrenn") + entries = self.capsule.files.list("/home/wrenn-user/wrenn") names = [e.name for e in entries] assert "README.md" in names def test_status_of_clone_is_clean(self): - status = self.capsule.git.status(cwd="/root/wrenn") + status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn") assert status.branch == "main" assert status.is_clean def test_branches_lists_main(self): - branches = self.capsule.git.branches(cwd="/root/wrenn") + branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn") names = [b.name for b in branches] assert "main" in names assert any(b.is_current for b in branches) def test_remote_get_origin(self): - url = self.capsule.git.remote_get("origin", cwd="/root/wrenn") + url = self.capsule.git.remote_get("origin", cwd="/home/wrenn-user/wrenn") assert url is not None assert "wrennhq/wrenn" in url def test_git_log_has_commit(self): - result = self.capsule.commands.run("git log --oneline -1", cwd="/root/wrenn") + result = self.capsule.commands.run( + "git log --oneline -1", cwd="/home/wrenn-user/wrenn" + ) assert result.exit_code == 0 assert result.stdout.strip() def test_modify_add_commit(self): marker = uuid.uuid4().hex self.capsule.git.configure_user( - "CI Bot", "ci@example.com", cwd="/root/wrenn", scope="local" + "CI Bot", "ci@example.com", cwd="/home/wrenn-user/wrenn", scope="local" ) - self.capsule.files.write(f"/root/wrenn/sdk_probe_{marker}.txt", marker) - self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/root/wrenn") + self.capsule.files.write( + f"/home/wrenn-user/wrenn/sdk_probe_{marker}.txt", marker + ) + self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/home/wrenn-user/wrenn") - staged = self.capsule.git.status(cwd="/root/wrenn") + staged = self.capsule.git.status(cwd="/home/wrenn-user/wrenn") assert staged.has_staged - result = self.capsule.git.commit("probe commit", cwd="/root/wrenn") + result = self.capsule.git.commit("probe commit", cwd="/home/wrenn-user/wrenn") assert result.exit_code == 0 - after = self.capsule.git.status(cwd="/root/wrenn") + after = self.capsule.git.status(cwd="/home/wrenn-user/wrenn") assert after.is_clean assert after.ahead >= 1 def test_create_and_checkout_branch_in_clone(self): - self.capsule.git.create_branch("sdk-feature", cwd="/root/wrenn") - branches = self.capsule.git.branches(cwd="/root/wrenn") + self.capsule.git.create_branch("sdk-feature", cwd="/home/wrenn-user/wrenn") + branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn") current = [b for b in branches if b.is_current] assert current and current[0].name == "sdk-feature" - self.capsule.git.checkout_branch("main", cwd="/root/wrenn") + self.capsule.git.checkout_branch("main", cwd="/home/wrenn-user/wrenn") def test_diff_via_commands(self): - self.capsule.files.write("/root/wrenn/README.md", "overwritten\n") + self.capsule.files.write("/home/wrenn-user/wrenn/README.md", "overwritten\n") try: - result = self.capsule.commands.run("git diff --stat", cwd="/root/wrenn") + result = self.capsule.commands.run( + "git diff --stat", cwd="/home/wrenn-user/wrenn" + ) assert "README.md" in result.stdout finally: - self.capsule.git.restore(["README.md"], worktree=True, cwd="/root/wrenn") + self.capsule.git.restore( + ["README.md"], worktree=True, cwd="/home/wrenn-user/wrenn" + ) class TestGitErrors: @@ -481,7 +511,7 @@ class TestGitErrors: with pytest.raises(GitError): self.capsule.git.clone( "https://github.com/wrennhq/this-repo-does-not-exist-xyz", - "/root/missing", + "/home/wrenn-user/missing", timeout=120, ) @@ -493,7 +523,11 @@ class TestGitErrors: def test_clone_with_branch(self): self.capsule.git.clone( - WRENN_REPO, "/root/wrenn-main", branch="main", depth=1, timeout=300 + WRENN_REPO, + "/home/wrenn-user/wrenn-main", + branch="main", + depth=1, + timeout=300, ) - status = self.capsule.git.status(cwd="/root/wrenn-main") + status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn-main") assert status.branch == "main" -- 2.49.0 From 9141f2c7033460fb7fcd2c932b189bcf1edfde87 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sat, 23 May 2026 04:56:15 +0600 Subject: [PATCH 42/44] Version bump --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1b78b84..6ee4a11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wrenn" -version = "0.1.4" +version = "0.1.5" description = "Python SDK for Wrenn" readme = "README.md" license = "MIT" diff --git a/uv.lock b/uv.lock index d35d4ae..bc19b60 100644 --- a/uv.lock +++ b/uv.lock @@ -1166,7 +1166,7 @@ wheels = [ [[package]] name = "wrenn" -version = "0.1.4" +version = "0.1.5" source = { editable = "." } dependencies = [ { name = "certifi" }, -- 2.49.0 From 4dcbc73003f79e77ec1378805546db40586e348e Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sat, 23 May 2026 17:39:51 +0600 Subject: [PATCH 43/44] chore: remove unused generated models, expose git exceptions from wrenn.exceptions --- src/wrenn/exceptions.py | 13 + src/wrenn/models/__init__.py | 52 +-- src/wrenn/models/_generated.py | 704 +-------------------------------- 3 files changed, 27 insertions(+), 742 deletions(-) diff --git a/src/wrenn/exceptions.py b/src/wrenn/exceptions.py index 65ac7e8..2ead49d 100644 --- a/src/wrenn/exceptions.py +++ b/src/wrenn/exceptions.py @@ -164,4 +164,17 @@ def __getattr__(name: str) -> type: stacklevel=2, ) return WrennHostHasCapsulesError + if name in ("GitError", "GitCommandError", "GitAuthError"): + from wrenn._git.exceptions import ( + GitAuthError as _GitAuthError, + GitCommandError as _GitCommandError, + GitError as _GitError, + ) + + _m: dict[str, type] = { + "GitError": _GitError, + "GitCommandError": _GitCommandError, + "GitAuthError": _GitAuthError, + } + return _m[name] raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/wrenn/models/__init__.py b/src/wrenn/models/__init__.py index 52bdc62..49c99b1 100644 --- a/src/wrenn/models/__init__.py +++ b/src/wrenn/models/__init__.py @@ -1,63 +1,17 @@ from wrenn.models._generated import ( - APIKeyResponse, Capsule, - CreateAPIKeyRequest, - CreateCapsuleRequest, - CreateHostRequest, - CreateHostResponse, - CreateSnapshotRequest, - Encoding, - Error, - Error1, - ExecRequest, - ExecResponse, FileEntry, - Host, - ListDirRequest, ListDirResponse, - LoginRequest, - MakeDirRequest, MakeDirResponse, - ReadFileRequest, - RegisterHostRequest, - RegisterHostResponse, - RemoveRequest, - SignupRequest, Status, - Status1, Template, - Type, - Type2, ) __all__ = [ - "APIKeyResponse", - "CreateAPIKeyRequest", - "CreateHostRequest", - "CreateHostResponse", - "CreateCapsuleRequest", - "CreateSnapshotRequest", - "Encoding", - "Error", - "Error1", - "ExecRequest", - "ExecResponse", - "FileEntry", - "Host", - "ListDirRequest", - "ListDirResponse", - "LoginRequest", - "MakeDirRequest", - "MakeDirResponse", - "ReadFileRequest", - "RegisterHostRequest", - "RegisterHostResponse", - "RemoveRequest", "Capsule", - "SignupRequest", + "FileEntry", + "ListDirResponse", + "MakeDirResponse", "Status", - "Status1", "Template", - "Type", - "Type2", ] diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py index e78331f..3104a87 100644 --- a/src/wrenn/models/_generated.py +++ b/src/wrenn/models/_generated.py @@ -1,147 +1,13 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-05-22T19:20:45+00:00 +# timestamp: 2026-05-23T11:20:02+00:00 from __future__ import annotations -from pydantic import AwareDatetime, BaseModel, EmailStr, Field -from typing import Annotated, Any -from datetime import date as date_aliased +from pydantic import AwareDatetime, BaseModel, Field +from typing import Annotated from enum import StrEnum -class SignupRequest(BaseModel): - email: EmailStr - password: Annotated[str, Field(min_length=8)] - name: Annotated[str, Field(max_length=100)] - - -class LoginRequest(BaseModel): - email: EmailStr - password: str - - -class SignupResponse(BaseModel): - message: Annotated[ - str | None, - Field(description="Confirmation message instructing user to check email"), - ] = None - - -class SessionResponse(BaseModel): - """ - Returned by login, activate, and switch-team. The actual auth credential - is the wrenn_sid cookie set on the response. The body carries identity - data the SPA needs to bootstrap. - - """ - - user_id: str | None = None - team_id: str | None = None - email: str | None = None - name: str | None = None - role: str | None = None - is_admin: bool | None = None - - -class CreateAPIKeyRequest(BaseModel): - name: str | None = "Unnamed API Key" - - -class APIKeyResponse(BaseModel): - id: str | None = None - team_id: str | None = None - name: str | None = None - key_prefix: Annotated[ - str | None, Field(description='Display prefix (e.g. "wrn_ab12cd34...")') - ] = None - created_at: AwareDatetime | None = None - last_used: AwareDatetime | None = None - key: Annotated[ - str | None, - Field( - description="Full plaintext key. Only returned on creation, never again." - ), - ] = None - - -class CreateCapsuleRequest(BaseModel): - template: str | None = "minimal-ubuntu" - vcpus: int | None = 1 - memory_mb: int | None = 512 - disk_size_mb: Annotated[ - int | None, - Field( - description="Maximum size of the per-capsule copy-on-write disk in MB. Capped at 5 GB by default; the actual size is max(disk_size_mb, origin rootfs size).\n" - ), - ] = 5120 - timeout_sec: Annotated[ - int | None, - Field( - description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause. Positive values below 60 are silently clamped to 60 (the agent's startup envelope).\n", - ge=0, - ), - ] = 0 - - -class Point(BaseModel): - date: date_aliased | None = None - cpu_minutes: float | None = None - ram_mb_minutes: float | None = None - - -class UsageResponse(BaseModel): - from_: Annotated[date_aliased | None, Field(alias="from")] = None - to: date_aliased | None = None - points: list[Point] | None = None - - -class Range(StrEnum): - field_5m = "5m" - field_1h = "1h" - field_6h = "6h" - field_24h = "24h" - field_30d = "30d" - - -class Current(BaseModel): - running_count: int | None = None - vcpus_reserved: int | None = None - memory_mb_reserved: int | None = None - sampled_at: AwareDatetime | None = None - - -class Peaks(BaseModel): - """ - Maximum values over the last 30 days. - """ - - running_count: int | None = None - vcpus: int | None = None - memory_mb: int | None = None - - -class Series(BaseModel): - """ - Parallel arrays for chart rendering. - """ - - labels: list[AwareDatetime] | None = None - running: list[int] | None = None - vcpus: list[int] | None = None - memory_mb: list[int] | None = None - - -class CapsuleStats(BaseModel): - range: Range | None = None - current: Current | None = None - peaks: Annotated[ - Peaks | None, Field(description="Maximum values over the last 30 days.") - ] = None - series: Annotated[ - Series | None, Field(description="Parallel arrays for chart rendering.") - ] = None - - class Status(StrEnum): pending = "pending" starting = "starting" @@ -164,8 +30,6 @@ class Capsule(BaseModel): vcpus: int | None = None memory_mb: int | None = None timeout_sec: int | None = None - guest_ip: str | None = None - host_ip: str | None = None created_at: AwareDatetime | None = None started_at: AwareDatetime | None = None last_active_at: AwareDatetime | None = None @@ -176,16 +40,14 @@ class Capsule(BaseModel): description="Free-form key/value labels attached at create-time. Also carries\nagent-side version info (kernel_version, vmm_version,\nagent_version, envd_version) when running.\n" ), ] = None - disk_size_mb: int | None = None - - -class CreateSnapshotRequest(BaseModel): - sandbox_id: Annotated[ - str, Field(description="ID of the running capsule to snapshot.") - ] - name: Annotated[ - str | None, - Field(description="Name for the snapshot template. Auto-generated if omitted."), + disk_size_mb: Annotated[ + int | None, Field(description="Maximum disk capacity in MiB.") + ] = None + disk_used_mb: Annotated[ + int | None, + Field( + description="Current disk usage in MiB. Only populated on individual capsule GET; omitted in list responses." + ), ] = None @@ -216,121 +78,6 @@ class Template(BaseModel): metadata: dict[str, str] | None = None -class AdminTemplate(BaseModel): - """ - Template as returned by the admin templates list. Unlike `Template` - (the team-facing snapshot shape), this includes the owning `team_id` - and omits `platform`/`metadata`. - - """ - - name: str | None = None - type: Type | None = None - vcpus: int | None = None - memory_mb: int | None = None - size_bytes: int | None = None - team_id: Annotated[ - str | None, - Field( - description="Owning team ID (formatted, e.g. `team-…`). Platform team for global templates." - ), - ] = None - created_at: AwareDatetime | None = None - protected: Annotated[ - bool | None, - Field( - description="True for built-in system base templates (minimal-ubuntu,\nminimal-alpine, minimal-arch, minimal-fedora). Protected templates\ncannot be deleted.\n" - ), - ] = None - - -class ExecRequest(BaseModel): - cmd: str - args: list[str] | None = None - timeout_sec: Annotated[ - int | None, - Field(description="Timeout in seconds (foreground exec only, default 30)"), - ] = 30 - background: Annotated[ - bool | None, - Field( - description="If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202)" - ), - ] = False - tag: Annotated[ - str | None, - Field( - description="Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true." - ), - ] = None - envs: Annotated[ - dict[str, str] | None, - Field( - description="Environment variables for the process (background exec only)" - ), - ] = None - cwd: Annotated[ - str | None, - Field(description="Working directory for the process (background exec only)"), - ] = None - - -class BackgroundExecResponse(BaseModel): - sandbox_id: str | None = None - cmd: str | None = None - pid: int | None = None - tag: str | None = None - - -class ProcessEntry(BaseModel): - pid: int | None = None - tag: str | None = None - cmd: str | None = None - args: list[str] | None = None - - -class ProcessListResponse(BaseModel): - processes: list[ProcessEntry] | None = None - - -class Encoding(StrEnum): - """ - Output encoding. "base64" when stdout/stderr contain binary data. - """ - - utf_8 = "utf-8" - base64 = "base64" - - -class ExecResponse(BaseModel): - sandbox_id: str | None = None - cmd: str | None = None - stdout: str | None = None - stderr: str | None = None - exit_code: int | None = None - duration_ms: int | None = None - encoding: Annotated[ - Encoding | None, - Field( - description='Output encoding. "base64" when stdout/stderr contain binary data.' - ), - ] = None - - -class ReadFileRequest(BaseModel): - path: Annotated[str, Field(description="Absolute file path inside the capsule")] - - -class ListDirRequest(BaseModel): - path: Annotated[str, Field(description="Directory path inside the capsule")] - depth: Annotated[ - int | None, - Field( - description="Recursion depth (0 = non-recursive, 1 = immediate children)" - ), - ] = 1 - - class Type2(StrEnum): file = "file" directory = "directory" @@ -354,438 +101,9 @@ class FileEntry(BaseModel): symlink_target: str | None = None -class MakeDirRequest(BaseModel): - path: Annotated[ - str, Field(description="Directory path to create inside the capsule") - ] - - class MakeDirResponse(BaseModel): entry: FileEntry | None = None -class RemoveRequest(BaseModel): - path: Annotated[str, Field(description="Path to remove inside the capsule")] - - -class Type3(StrEnum): - """ - Host type. Regular hosts are shared; BYOC hosts belong to a team. - """ - - regular = "regular" - byoc = "byoc" - - -class CreateHostRequest(BaseModel): - type: Annotated[ - Type3, - Field( - description="Host type. Regular hosts are shared; BYOC hosts belong to a team." - ), - ] - team_id: Annotated[str | None, Field(description="Required for BYOC hosts.")] = None - provider: Annotated[ - str | None, - Field(description="Cloud provider (e.g. aws, gcp, hetzner, bare-metal)."), - ] = None - availability_zone: Annotated[ - str | None, Field(description="Availability zone (e.g. us-east, eu-west).") - ] = None - - -class RegisterHostRequest(BaseModel): - token: Annotated[ - str, Field(description="One-time registration token from POST /v1/hosts.") - ] - arch: Annotated[ - str | None, Field(description="CPU architecture (e.g. x86_64, aarch64).") - ] = None - cpu_cores: int | None = None - memory_mb: int | None = None - disk_gb: int | None = None - address: Annotated[str, Field(description="Host agent address (ip:port).")] - - -class Type4(StrEnum): - regular = "regular" - byoc = "byoc" - - -class Status1(StrEnum): - pending = "pending" - online = "online" - offline = "offline" - draining = "draining" - unreachable = "unreachable" - - -class Host(BaseModel): - id: str | None = None - type: Type4 | None = None - team_id: str | None = None - provider: str | None = None - availability_zone: str | None = None - arch: str | None = None - cpu_cores: int | None = None - memory_mb: int | None = None - disk_gb: int | None = None - address: str | None = None - status: Status1 | None = None - last_heartbeat_at: AwareDatetime | None = None - created_by: str | None = None - created_at: AwareDatetime | None = None - updated_at: AwareDatetime | None = None - - -class RefreshHostTokenRequest(BaseModel): - refresh_token: Annotated[ - str, - Field( - description="Refresh token obtained from registration or a previous refresh." - ), - ] - - -class RefreshHostTokenResponse(BaseModel): - host: Host | None = None - token: Annotated[ - str | None, Field(description="New host JWT. Valid for 7 days.") - ] = None - refresh_token: Annotated[ - str | None, - Field( - description="New refresh token. Valid for 60 days; old token is revoked." - ), - ] = None - - -class HostDeletePreview(BaseModel): - host: Host | None = None - sandbox_ids: Annotated[ - list[str] | None, - Field(description="IDs of capsules that would be destroyed on force-delete."), - ] = None - - -class Error(BaseModel): - code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None - message: str | None = None - sandbox_ids: Annotated[ - list[str] | None, Field(description="IDs of active capsules blocking deletion.") - ] = None - - -class HostHasCapsulesError(BaseModel): - error: Error | None = None - - -class AddTagRequest(BaseModel): - tag: str - - -class UserSearchResult(BaseModel): - user_id: str | None = None - email: str | None = None - - -class Team(BaseModel): - id: str | None = None - name: str | None = None - slug: Annotated[ - str | None, Field(description="Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)") - ] = None - created_at: AwareDatetime | None = None - - -class Role(StrEnum): - owner = "owner" - admin = "admin" - member = "member" - - -class TeamWithRole(Team): - role: Role | None = None - - -class TeamMember(BaseModel): - user_id: str | None = None - email: str | None = None - role: Role | None = None - joined_at: AwareDatetime | None = None - - -class TeamDetail(BaseModel): - team: Team | None = None - members: list[TeamMember] | None = None - - -class Range1(StrEnum): - field_5m = "5m" - field_10m = "10m" - field_1h = "1h" - field_2h = "2h" - field_6h = "6h" - field_12h = "12h" - field_24h = "24h" - - -class MetricPoint(BaseModel): - timestamp_unix: int | None = None - cpu_pct: Annotated[ - float | None, - Field( - description="CPU utilization percentage (0-100), normalized to vCPU count" - ), - ] = None - mem_bytes: Annotated[ - int | None, - Field( - description="Resident memory in bytes (VmRSS of Cloud Hypervisor process)" - ), - ] = None - disk_bytes: Annotated[ - int | None, Field(description="Allocated disk bytes for the CoW sparse file") - ] = None - - -class Provider(StrEnum): - discord = "discord" - slack = "slack" - teams = "teams" - googlechat = "googlechat" - telegram = "telegram" - matrix = "matrix" - webhook = "webhook" - - -class Event(StrEnum): - capsule_create = "capsule.create" - capsule_pause = "capsule.pause" - capsule_resume = "capsule.resume" - capsule_destroy = "capsule.destroy" - template_snapshot_create = "template.snapshot.create" - template_snapshot_delete = "template.snapshot.delete" - host_up = "host.up" - host_down = "host.down" - - -class CreateChannelRequest(BaseModel): - name: Annotated[str, Field(description="Unique channel name within the team.")] - provider: Provider - config: Annotated[ - dict[str, str], - Field( - description='Provider-specific configuration fields. Discord/Slack/Teams/Google Chat: {"webhook_url": "..."}. Telegram: {"bot_token": "...", "chat_id": "..."}. Matrix: {"homeserver_url": "...", "access_token": "...", "room_id": "..."}. Webhook: {"url": "...", "secret": "..."} (secret is auto-generated if omitted).\n' - ), - ] - events: list[Event] - - -class TestChannelRequest(BaseModel): - provider: Provider - config: Annotated[ - dict[str, str], - Field( - description="Provider-specific configuration fields (same as CreateChannelRequest.config)." - ), - ] - - -class RotateConfigRequest(BaseModel): - config: Annotated[ - dict[str, str], - Field( - description="New provider configuration fields. Must include all required fields for the channel's provider. Replaces the existing config entirely.\n" - ), - ] - - -class UpdateChannelRequest(BaseModel): - name: str - events: list[Event] - - -class ChannelResponse(BaseModel): - id: str | None = None - team_id: str | None = None - name: str | None = None - provider: Provider | None = None - events: list[str] | None = None - created_at: AwareDatetime | None = None - updated_at: AwareDatetime | None = None - secret: Annotated[ - str | None, - Field(description="Webhook secret. Only returned on creation, never again."), - ] = None - - -class MeResponse(BaseModel): - name: str | None = None - email: EmailStr | None = None - has_password: Annotated[ - bool | None, - Field( - description="Whether the user has a password set (false for OAuth-only accounts)" - ), - ] = None - providers: Annotated[ - list[str] | None, - Field(description='List of linked OAuth provider names (e.g. ["github"])'), - ] = None - - -class ChangePasswordRequest(BaseModel): - current_password: Annotated[ - str | None, Field(description="Required when changing an existing password") - ] = None - new_password: Annotated[str, Field(min_length=8)] - confirm_password: Annotated[ - str | None, - Field( - description="Required when adding a password to an OAuth-only account (must match new_password)" - ), - ] = None - - -class Error2(BaseModel): - code: str | None = None - message: str | None = None - - -class Error1(BaseModel): - error: Error2 | None = None - - -class ActorType(StrEnum): - user = "user" - api_key = "api_key" - host = "host" - system = "system" - - -class Status2(StrEnum): - success = "success" - failure = "failure" - - -class AuditLogEntry(BaseModel): - id: str | None = None - actor_type: ActorType | None = None - actor_id: str | None = None - actor_name: str | None = None - resource_type: str | None = None - resource_id: str | None = None - action: str | None = None - scope: str | None = None - status: Status2 | None = None - metadata: dict[str, Any] | None = None - created_at: AwareDatetime | None = None - - -class Event2(StrEnum): - connected = "connected" - capsule_create = "capsule.create" - capsule_pause = "capsule.pause" - capsule_resume = "capsule.resume" - capsule_destroy = "capsule.destroy" - capsule_state_changed = "capsule.state.changed" - template_snapshot_create = "template.snapshot.create" - template_snapshot_delete = "template.snapshot.delete" - host_up = "host.up" - host_down = "host.down" - - -class Outcome(StrEnum): - """ - Present for action events (capsule.* except state.changed, - template.snapshot.*). Absent for host.up/down, capsule.state.changed, - and the connected sentinel. - - """ - - success = "success" - error = "error" - - -class Resource(BaseModel): - id: str | None = None - type: str | None = None - - -class Type5(StrEnum): - user = "user" - api_key = "api_key" - system = "system" - - -class Actor(BaseModel): - type: Type5 | None = None - id: str | None = None - name: str | None = None - - -class SSEEvent(BaseModel): - """ - Wire format of one SSE message body. The event name (`event:` line) is - the `kind` and the JSON below is the `data:` line. - - """ - - event: Event2 | None = None - outcome: Annotated[ - Outcome | None, - Field( - description="Present for action events (capsule.* except state.changed,\ntemplate.snapshot.*). Absent for host.up/down, capsule.state.changed,\nand the connected sentinel.\n" - ), - ] = None - resource: Resource | None = None - actor: Actor | None = None - metadata: Annotated[ - dict[str, str] | None, - Field( - description="Event-specific context. Examples: `reason` (ttl_expired,\nhost_failure, cleanup_after_create_error, orphaned),\n`host_ip`, `from`/`to` (for capsule.state.changed).\n" - ), - ] = None - error: Annotated[ - str | None, Field(description="Failure reason; only set when outcome=error.") - ] = None - sandbox: Annotated[ - Capsule | None, - Field(description="Populated for capsule.* events; null if DB lookup failed."), - ] = None - timestamp: AwareDatetime | None = None - - class ListDirResponse(BaseModel): entries: list[FileEntry] | None = None - - -class CreateHostResponse(BaseModel): - host: Host | None = None - registration_token: Annotated[ - str | None, - Field( - description="One-time registration token for the host agent. Expires in 1 hour." - ), - ] = None - - -class RegisterHostResponse(BaseModel): - host: Host | None = None - token: Annotated[ - str | None, - Field(description="Host JWT for X-Host-Token header. Valid for 7 days."), - ] = None - refresh_token: Annotated[ - str | None, - Field( - description="Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use." - ), - ] = None - - -class CapsuleMetrics(BaseModel): - sandbox_id: str | None = None - range: Range1 | None = None - points: list[MetricPoint] | None = None -- 2.49.0 From a2d9aac78b809a131171f3cab09cc5a611ffbfb1 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sat, 23 May 2026 17:44:34 +0600 Subject: [PATCH 44/44] Version bump --- README.md | 54 +++++++++++++----- api/openapi.yaml | 141 ++++++++++++++++++++++++++++++++++++++++++----- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 169 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 2a48a06..e6facbe 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,8 @@ import sys # Stream a new command for event in capsule.commands.stream("python", args=["-u", "train.py"]): match event.type: + case "start": + print(f"PID: {event.pid}") case "stdout": print(event.data, end="") case "stderr": @@ -181,8 +183,11 @@ for event in capsule.commands.stream("python", args=["-u", "train.py"]): # Connect to a running background process for event in capsule.commands.connect(handle.pid): - if event.type == "stdout": - print(event.data, end="") + match event.type: + case "start": + print(f"PID: {event.pid}") + case "stdout": + print(event.data, end="") ``` #### Process Management @@ -211,6 +216,7 @@ capsule.files.exists("/app/main.py") # True # List directory entries = capsule.files.list("/home/user", depth=1) +# FileEntry has: name, type (file/dir), size, modified_at for entry in entries: print(entry.name, entry.type, entry.size) @@ -289,8 +295,27 @@ value = capsule.git.get_config("user.name", cwd="/app") # str | None capsule.git.remote_add("upstream", "https://github.com/org/repo.git", cwd="/app") url = capsule.git.remote_get("origin", cwd="/app") # str | None + +# Reset and restore +capsule.git.reset(mode="hard", ref="HEAD~1", cwd="/app") +capsule.git.restore(["file.txt"], staged=True, cwd="/app") ``` +#### Persistent Credential Store + +For workflows that need repeated authenticated operations, you can persist credentials via the git credential store: + +```python +capsule.git.dangerously_authenticate( + username="user", + password="ghp_token", + host="github.com", + protocol="https", +) +``` + +> **Warning:** Credentials are written in plaintext inside the capsule and are accessible to any process running there. Prefer per-operation `username`/`password` on `clone`, `push`, and `pull` instead. + Git errors raise `GitCommandError` (or `GitAuthError` for authentication failures), both inheriting from `GitError`: ```python @@ -308,7 +333,7 @@ except GitAuthError as e: ```python import sys -with capsule.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term: +with capsule.pty(cmd="/bin/bash", cols=80, rows=24, cwd="/home/user") as term: term.write(b"ls -la\n") for event in term: if event.type == "output": @@ -451,9 +476,10 @@ result = capsule.run_code("print('running on custom template')") | `logs` | `Logs` | `.stdout: list[str]` and `.stderr: list[str]` chunks | | `error` | `ExecutionError \| None` | `.name`, `.value`, `.traceback` | | `execution_count` | `int \| None` | Jupyter cell execution counter | +| `timed_out` | `bool` | ``True`` when execution was cut short by the timeout | | `text` | `str \| None` | (property) `text/plain` of the main `execute_result` | -Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `pdf`, `latex`, `json`, `javascript`, plus `extra` for unknown types. The `text` field is Jupyter's `text/plain` bundle verbatim — the Python `repr()` of the cell's last expression. So `run_code("'hi'").text` is `"'hi'"` (with quotes), and `run_code("42").text` is `"42"`. This preserves the distinction between the string `'2'` and the int `2`. +Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `gif`, `pdf`, `latex`, `json`, `javascript`, `plotly`, plus `extra` for unknown types. The `text` field is Jupyter's `text/plain` bundle verbatim — the Python `repr()` of the cell's last expression. So `run_code("'hi'").text` is `"'hi'"` (with quotes), and `run_code("42").text` is `"42"`. This preserves the distinction between the string `'2'` and the int `2`. ### Code Runner + Commands/Files @@ -527,15 +553,15 @@ The SDK maps server error codes to typed exceptions: ```python from wrenn import ( WrennError, - WrennValidationError, # 400 - WrennAuthenticationError, # 401 - WrennForbiddenError, # 403 - WrennNotFoundError, # 404 - WrennConflictError, # 409 - WrennHostHasCapsulesError, # 409 (host has running capsules) - WrennAgentError, # 502 - WrennInternalError, # 500 - WrennHostUnavailableError, # 503 + WrennValidationError, # 400 + WrennAuthenticationError, # 401 + WrennForbiddenError, # 403 + WrennNotFoundError, # 404 + WrennConflictError, # 409 + WrennHostHasCapsulesError, # 409 (host has running capsules) + WrennInternalError, # 500 + WrennAgentError, # 502 + WrennHostUnavailableError, # 503 ) try: @@ -603,7 +629,7 @@ with WrennClient(api_key="wrn_...") as client: # Snapshots template = client.snapshots.create(capsule_id="cl-abc", name="my-snap") - templates = client.snapshots.list() + templates = client.snapshots.list(type="custom") # optional type filter client.snapshots.delete("my-snap") ``` diff --git a/api/openapi.yaml b/api/openapi.yaml index c8ad59f..0a4b218 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2716,14 +2716,39 @@ paths: tags: [admin] security: - sessionAuth: [] + parameters: + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + description: Page number for pagination. responses: "200": - description: Teams list + description: Paginated teams list content: application/json: schema: - type: array - items: {type: object} + type: object + properties: + teams: + type: array + items: + $ref: "#/components/schemas/AdminTeam" + total: + type: integer + page: + type: integer + per_page: + type: integer + total_pages: + type: integer + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /v1/admin/teams/{id}/byoc: put: @@ -2743,12 +2768,20 @@ paths: application/json: schema: type: object - required: [byoc] + required: [enabled] properties: - byoc: {type: boolean} + enabled: + type: boolean + description: true to enable BYOC, false to disable. responses: "204": description: Updated + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /v1/admin/teams/{id}: delete: @@ -2765,6 +2798,38 @@ paths: responses: "204": description: Deleted + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + /v1/admin/hosts: + get: + summary: List all hosts (admin) + operationId: adminListHosts + tags: [admin] + security: + - sessionAuth: [] + description: | + Returns all hosts across all teams with per-host resource consumption. + Includes team name for hosts associated with a team. + responses: + "200": + description: Hosts list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Host" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /v1/admin/users: get: @@ -3581,10 +3646,6 @@ components: type: integer memory_mb_reserved: type: integer - sampled_at: - type: string - format: date-time - nullable: true peaks: type: object description: Maximum values over the last 30 days. @@ -3633,10 +3694,6 @@ components: type: integer timeout_sec: type: integer - guest_ip: - type: string - host_ip: - type: string created_at: type: string format: date-time @@ -3661,7 +3718,11 @@ components: agent_version, envd_version) when running. disk_size_mb: type: integer - nullable: true + description: Maximum disk capacity in MiB. + disk_used_mb: + type: integer + format: int64 + description: Current disk usage in MiB. Only populated on individual capsule GET; omitted in list responses. CreateSnapshotRequest: type: object @@ -4013,6 +4074,25 @@ components: updated_at: type: string format: date-time + team_name: + type: string + nullable: true + description: Team name (included when listing hosts as an admin). + running_vcpus: + type: integer + description: Total vCPUs allocated to running capsules on this host. + running_memory_mb: + type: integer + description: Total memory in MB allocated to running capsules on this host. + running_disk_mb: + type: integer + description: Total disk in MB allocated to running capsules on this host. + paused_memory_mb: + type: integer + description: Total memory in MB allocated to paused capsules on this host. + paused_disk_mb: + type: integer + description: Total disk in MB allocated to paused capsules on this host. RefreshHostTokenRequest: type: object @@ -4124,6 +4204,39 @@ components: items: $ref: "#/components/schemas/TeamMember" + AdminTeam: + type: object + properties: + id: + type: string + name: + type: string + slug: + type: string + is_byoc: + type: boolean + created_at: + type: string + format: date-time + deleted_at: + type: string + format: date-time + nullable: true + member_count: + type: integer + owner_name: + type: string + owner_email: + type: string + active_sandbox_count: + type: integer + channel_count: + type: integer + running_vcpus: + type: integer + running_memory_mb: + type: integer + CapsuleMetrics: type: object properties: diff --git a/pyproject.toml b/pyproject.toml index 6ee4a11..b67c7a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wrenn" -version = "0.1.5" +version = "0.2.0" description = "Python SDK for Wrenn" readme = "README.md" license = "MIT" diff --git a/uv.lock b/uv.lock index bc19b60..0dc0352 100644 --- a/uv.lock +++ b/uv.lock @@ -1166,7 +1166,7 @@ wheels = [ [[package]] name = "wrenn" -version = "0.1.5" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "certifi" }, -- 2.49.0