From 2b10fde45b5886f7a4b938f9f3b061ec83a884b8 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 20 May 2026 21:01:21 +0000 Subject: [PATCH] v0.1.4 (#9) ## What's New? - Updated the SDK to support v0.2.0 - Improved the test suite - Minor bugfix - No breaking changes Co-authored-by: Tasnim Kabir Sadik Reviewed-on: https://git.omukk.dev/wrenn/python-sdk/pulls/9 Co-authored-by: pptx704 Co-committed-by: pptx704 --- .gitignore | 1 + .woodpecker/check.yml | 24 - .woodpecker/code-runner.yml | 20 + .woodpecker/integration.yml | 25 + .woodpecker/unit.yml | 11 + AGENTS.md | 56 - CLAUDE.md | 59 + Makefile | 9 +- README.md | 61 +- api/openapi.yaml | 1378 +++++++++++++++++-- docs/reference.md | 1048 ++++++++------ pyproject.toml | 3 +- src/wrenn/__init__.py | 2 +- src/wrenn/_config.py | 2 + src/wrenn/_git/__init__.py | 160 ++- src/wrenn/_git/_cmd.py | 5 - src/wrenn/async_capsule.py | 201 ++- src/wrenn/capsule.py | 207 ++- src/wrenn/client.py | 209 ++- 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/_protocol.py | 133 ++ src/wrenn/code_runner/async_capsule.py | 334 +++++ src/wrenn/code_runner/capsule.py | 358 +++++ src/wrenn/code_runner/models.py | 149 ++ src/wrenn/commands.py | 135 +- src/wrenn/exceptions.py | 3 + src/wrenn/files.py | 128 +- src/wrenn/models/__init__.py | 2 - src/wrenn/models/_generated.py | 165 ++- src/wrenn/pty.py | 37 +- tests/test_capsule_features.py | 270 +++- tests/test_client.py | 56 +- tests/test_code_runner_e2e.py | 521 +++++++ tests/test_code_runner_unit.py | 887 ++++++++++++ tests/test_commands.py | 490 +++++++ tests/test_filesystem_pty.py | 55 + tests/test_integration.py | 30 +- tests/test_integration_advanced.py | 499 +++++++ uv.lock | 413 +++--- 43 files changed, 7000 insertions(+), 1998 deletions(-) delete mode 100644 .woodpecker/check.yml create mode 100644 .woodpecker/code-runner.yml create mode 100644 .woodpecker/integration.yml create mode 100644 .woodpecker/unit.yml delete mode 100644 AGENTS.md create mode 100644 src/wrenn/code_runner/__init__.py create mode 100644 src/wrenn/code_runner/_protocol.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 create mode 100644 tests/test_commands.py create mode 100644 tests/test_integration_advanced.py 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/.woodpecker/check.yml b/.woodpecker/check.yml deleted file mode 100644 index 3b78cc7..0000000 --- a/.woodpecker/check.yml +++ /dev/null @@ -1,24 +0,0 @@ -when: - event: pull_request - branch: - - main - - dev - path: - - "src/**" - - "tests/**" - -steps: - unit-tests: - image: ghcr.io/astral-sh/uv:python3.13-bookworm - commands: - - uv sync --dev - - uv run pytest -m "not integration" -v - - integration-tests: - image: ghcr.io/astral-sh/uv:python3.13-bookworm - environment: - WRENN_API_KEY: - from_secret: WRENN_API_KEY - commands: - - uv sync --dev - - uv run pytest -m integration -v diff --git a/.woodpecker/code-runner.yml b/.woodpecker/code-runner.yml new file mode 100644 index 0000000..9125333 --- /dev/null +++ b/.woodpecker/code-runner.yml @@ -0,0 +1,20 @@ +# 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" + - "pyproject.toml" + - "uv.lock" + +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..a427437 --- /dev/null +++ b/.woodpecker/integration.yml @@ -0,0 +1,25 @@ +# 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/**" + - "tests/**" + - "pyproject.toml" + - "uv.lock" + exclude: + - "src/wrenn/code_runner/**" + - "tests/test_code_runner_*.py" + +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 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/CLAUDE.md b/CLAUDE.md index 4aff987..4fc6d7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,3 +169,62 @@ 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`. + +## 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 + 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..047d79e 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,10 +30,13 @@ 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" + 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" check: lint test diff --git a/README.md b/README.md index 787a4b9..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://...") ``` @@ -84,10 +105,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 +350,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 +371,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 +426,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 +453,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 +498,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/api/openapi.yaml b/api/openapi.yaml index c18c575..f3fb110 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.4" + description: AI agent execution platform API. + version: "0.2.0" servers: - url: http://localhost:8080 @@ -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: @@ -866,8 +960,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: @@ -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,9 +1085,10 @@ paths: tags: [capsules] security: - apiKeyAuth: [] + - sessionAuth: [] responses: - "204": - description: Capsule destroyed + "202": + description: Capsule destruction initiated /v1/capsules/{id}/exec: parameters: @@ -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,13 +1359,14 @@ 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 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: @@ -1287,13 +1392,15 @@ 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: - "200": - description: Capsule resumed (new VM booted from snapshot) + "202": + description: Capsule resume initiated (status will be "resuming") content: application/json: schema: @@ -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 @@ -2035,6 +2152,57 @@ 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 + 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: + 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 @@ -2077,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. @@ -2114,7 +2282,7 @@ paths: operationId: listHostTags tags: [hosts] security: - - bearerAuth: [] + - sessionAuth: [] responses: "200": description: List of tags @@ -2130,7 +2298,7 @@ paths: operationId: addHostTag tags: [hosts] security: - - bearerAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -2165,7 +2333,7 @@ paths: operationId: removeHostTag tags: [hosts] security: - - bearerAuth: [] + - sessionAuth: [] responses: "204": description: Tag removed @@ -2182,7 +2350,7 @@ paths: operationId: createChannel tags: [channels] security: - - bearerAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -2203,7 +2371,7 @@ paths: operationId: listChannels tags: [channels] security: - - bearerAuth: [] + - sessionAuth: [] responses: "200": description: Channels list @@ -2223,7 +2391,7 @@ paths: operationId: testChannel tags: [channels] security: - - bearerAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -2256,7 +2424,7 @@ paths: operationId: getChannel tags: [channels] security: - - bearerAuth: [] + - sessionAuth: [] responses: "200": description: Channel details @@ -2275,7 +2443,7 @@ paths: operationId: updateChannel tags: [channels] security: - - bearerAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -2302,7 +2470,7 @@ paths: operationId: deleteChannel tags: [channels] security: - - bearerAuth: [] + - sessionAuth: [] responses: "204": description: Channel deleted @@ -2323,7 +2491,7 @@ paths: operationId: rotateChannelConfig tags: [channels] security: - - bearerAuth: [] + - sessionAuth: [] requestBody: required: true content: @@ -2346,7 +2514,859 @@ 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. Session caches for the target user + are invalidated immediately so the flag flip takes effect on the + user's next request. + security: + - sessionAuth: [] + 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" + + /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 @@ -2354,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 @@ -2398,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: @@ -2412,6 +3445,10 @@ components: type: string name: type: string + role: + type: string + is_admin: + type: boolean CreateAPIKeyRequest: type: object @@ -2456,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 @@ -2544,7 +3590,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: @@ -2571,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 @@ -2603,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 @@ -2904,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 @@ -2921,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 @@ -3011,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 @@ -3042,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 @@ -3087,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 @@ -3164,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/docs/reference.md b/docs/reference.md index 7e32f6c..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). @@ -709,7 +721,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 +849,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 +1285,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,23 +1599,15 @@ 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 = _DEFAULT_WAIT_TIMEOUT) -> 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``. - +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. @@ -2030,7 +1657,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, @@ -2092,7 +1719,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**: @@ -2101,8 +1728,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. @@ -2307,6 +1936,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 @@ -2347,6 +1988,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 @@ -2534,23 +2198,15 @@ inactivity TTL is set. #### wait\_ready ```python -def wait_ready(timeout: float = 30, interval: float = 0.5) -> None +def wait_ready(timeout: float = _DEFAULT_WAIT_TIMEOUT) -> 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``. - +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. @@ -2600,7 +2256,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, @@ -2661,7 +2317,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**: @@ -2670,8 +2326,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. @@ -2696,21 +2354,533 @@ 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). + +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 + +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. + + + +#### 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, + proxy_domain: str | None = None) -> str +``` + +Build the Jupyter kernel WebSocket URL for the given capsule. + # wrenn.\_config - - -## ConnectionConfig Objects - -```python -@dataclass(frozen=True) -class ConnectionConfig() -``` - -Resolved credentials and base URL for Wrenn API calls. - # wrenn.\_git.\_auth @@ -3165,16 +3335,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/pyproject.toml b/pyproject.toml index f27b9fc..1b78b84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wrenn" -version = "0.1.3" +version = "0.1.4" description = "Python SDK for Wrenn" readme = "README.md" license = "MIT" @@ -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/src/wrenn/__init__.py b/src/wrenn/__init__.py index 1ae84ae..0c4cb64 100644 --- a/src/wrenn/__init__.py +++ b/src/wrenn/__init__.py @@ -37,7 +37,7 @@ from wrenn.exceptions import ( from wrenn.models import FileEntry from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession -__version__ = "0.1.0" +__version__ = "0.1.4" __all__ = [ "__version__", 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/_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 1d72408..292941d 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 @@ -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_http_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. @@ -98,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( @@ -137,16 +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) - if info.status == Status.paused: - info = await client.capsules.resume(capsule_id) + capsule = cls( + _capsule_id=capsule_id, + _client=client, + _info=info, + ) - return 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() + + return capsule + except BaseException: + await client.aclose() + raise # ── Dual instance/static lifecycle ────────────────────────── @@ -155,22 +209,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 @@ -178,14 +245,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 @@ -193,11 +270,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) @@ -224,31 +309,30 @@ class AsyncCapsule: """ 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``. + 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 - Args: - timeout (float): Maximum seconds to wait. Defaults to ``30``. - interval (float): Polling interval in seconds. Defaults to ``0.5``. + 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: - 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") + await self._wait_for_status({Status.running}, _START_INTERVAL, timeout) async def is_running(self) -> bool: """Check whether the capsule is currently running. @@ -348,16 +432,23 @@ 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, + self._client._proxy_domain, + ) # ── Snapshots ─────────────────────────────────────────────── diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py index 29fe52f..9814076 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 @@ -13,21 +13,94 @@ 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 from wrenn.pty import PtySession -def _build_proxy_url(base_url: str, capsule_id: str | None, port: int) -> str: +def _proxy_url( + base_url: str, + capsule_id: str | None, + port: int, + proxy_domain: str | None, + *, + websocket: bool, +) -> 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" + if proxy_domain: + host = proxy_domain + else: + host = parsed.host + if parsed.port: + host = f"{host}:{parsed.port}" + 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://``).""" + return _proxy_url(base_url, capsule_id, port, proxy_domain, websocket=False) + + +_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 +173,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: @@ -112,9 +182,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 @@ -213,15 +283,21 @@ class Capsule: 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 = 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() + + return capsule + # ── Dual instance/static lifecycle ────────────────────────── destroy = _DualMethod("_instance_destroy", "_static_destroy") @@ -229,25 +305,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 @@ -255,16 +342,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 @@ -272,12 +369,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.""" @@ -306,31 +411,30 @@ class Capsule: """ 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``. + 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 - Args: - timeout (float): Maximum seconds to wait. Defaults to ``30``. - interval (float): Polling interval in seconds. Defaults to ``0.5``. + 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: - info = self._client.capsules.resume(self._id) - 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. @@ -429,16 +533,23 @@ 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, + self._client._proxy_domain, + ) # ── Snapshots ─────────────────────────────────────────────── diff --git a/src/wrenn/client.py b/src/wrenn/client.py index c51b190..58ef09b 100644 --- a/src/wrenn/client.py +++ b/src/wrenn/client.py @@ -1,10 +1,18 @@ from __future__ import annotations +import asyncio import os +import time 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 +23,56 @@ 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: @@ -26,6 +84,73 @@ 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 + + +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.""" @@ -51,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]: @@ -111,7 +230,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: @@ -167,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]: @@ -227,7 +340,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: @@ -282,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 ) @@ -303,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: @@ -346,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 ) @@ -367,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: @@ -393,19 +490,29 @@ 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`` + 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._http = httpx.Client( + self._proxy_domain = _resolve_proxy_domain(self._base_url, proxy_domain) + self._http = _RetryingClient( base_url=self._base_url, headers={"X-API-Key": self._api_key}, + timeout=_resolve_timeout(timeout), ) self.capsules = CapsulesResource(self._http) @@ -440,18 +547,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._http = httpx.AsyncClient( + self._proxy_domain = _resolve_proxy_domain(self._base_url, proxy_domain) + self._http = _RetryingAsyncClient( 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_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/_protocol.py b/src/wrenn/code_runner/_protocol.py new file mode 100644 index 0000000..751a8a6 --- /dev/null +++ b/src/wrenn/code_runner/_protocol.py @@ -0,0 +1,133 @@ +"""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 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: + """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 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, + 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_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 new file mode 100644 index 0000000..e96f329 --- /dev/null +++ b/src/wrenn/code_runner/async_capsule.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import asyncio +import json +import time +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_http_proxy_url +from wrenn.client import AsyncWrennClient +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, + 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 + _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 + # half-constructed instance. + 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: + 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 + 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( + 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) + 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: + 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}, + ) + 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() + 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}, + ) + 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}" + ) + + 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). + + 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. + """ + validate_language(language) + kernel_id = await self._ensure_kernel(jupyter_timeout=jupyter_timeout) + + msg = build_execute_request(code) + msg_id = msg["header"]["msg_id"] + + execution = Execution() + deadline = time.monotonic() + timeout + saw_idle = False + + def _emit_error(err: ExecutionError) -> None: + execution.error = err + if on_error is not None: + on_error(err) + + 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) + 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 + _emit_error( + ExecutionError( + name="Disconnected", + value=f"kernel WebSocket closed: {exc}", + ) + ) + execution.timed_out = 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: + 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..7fd7a40 --- /dev/null +++ b/src/wrenn/code_runner/capsule.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +import json +import time +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_http_proxy_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, + 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 + _ws: httpx_ws.WebSocketSession | None + _ws_cm: Any + + 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 + self._ws = None + self._ws_cm = 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_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: + proxy.close() + except Exception: + pass + self._proxy_client = None + + def __del__(self) -> None: + try: + self.close() + 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, + 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_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}, + ) + 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() + 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", + 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 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. + """ + validate_language(language) + kernel_id = self._ensure_kernel(jupyter_timeout=jupyter_timeout) + + msg = build_execute_request(code) + msg_id = msg["header"]["msg_id"] + + execution = Execution() + deadline = time.monotonic() + timeout + saw_idle = False + + def _emit_error(err: ExecutionError) -> None: + execution.error = err + if on_error is not None: + on_error(err) + + 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) + 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 + _emit_error( + ExecutionError( + name="Disconnected", + value=f"kernel WebSocket closed: {exc}", + ) + ) + execution.timed_out = 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: + 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..42d40ca --- /dev/null +++ b/src/wrenn/code_runner/models.py @@ -0,0 +1,149 @@ +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", + "image/gif": "gif", + "application/pdf": "pdf", + "text/latex": "latex", + "application/json": "json", + "application/javascript": "javascript", + "application/vnd.plotly.v1+json": "plotly", +} + + +@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.""" + gif: str | None = None + """``image/gif`` — 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.""" + 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.""" + + 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] = [ + attr for attr in _MIME_MAP.values() if getattr(self, attr) is not None + ] + 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 + 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: + """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/commands.py b/src/wrenn/commands.py index 98b596e..dece7f7 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: @@ -106,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 "" @@ -184,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. @@ -271,7 +299,7 @@ class Commands: yield event if event.type in ("exit", "error"): break - except httpx_ws.WebSocketDisconnect: + except _WS_CLOSED: break def stream( @@ -294,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() @@ -306,7 +330,7 @@ class Commands: yield event if event.type in ("exit", "error"): break - except httpx_ws.WebSocketDisconnect: + except _WS_CLOSED: break @@ -373,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. @@ -462,7 +461,7 @@ class AsyncCommands: yield event if event.type in ("exit", "error"): break - except httpx_ws.WebSocketDisconnect: + except _WS_CLOSED: pass async def stream( @@ -485,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() @@ -497,5 +492,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/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/files.py b/src/wrenn/files.py index 477aeca..08a7e5e 100644 --- a/src/wrenn/files.py +++ b/src/wrenn/files.py @@ -9,6 +9,76 @@ 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 + + +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``.""" @@ -118,17 +188,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") @@ -160,24 +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')}" - }, + headers=_multipart_headers(boundary), ) _raise_for_status(resp) @@ -315,17 +372,10 @@ 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): + 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") @@ -358,24 +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')}" - }, + headers=_multipart_headers(boundary), ) _raise_for_status(resp) 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 656f384..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-04T20:57:00+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 @@ -133,7 +146,10 @@ class Status(StrEnum): pending = "pending" starting = "starting" running = "running" + pausing = "pausing" paused = "paused" + resuming = "resuming" + stopping = "stopping" hibernated = "hibernated" stopped = "stopped" missing = "missing" @@ -153,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): @@ -177,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): @@ -399,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 @@ -407,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 @@ -476,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") @@ -494,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" @@ -591,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/src/wrenn/pty.py b/src/wrenn/pty.py index c116f2a..0b7ff77 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" @@ -49,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: @@ -109,6 +122,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 +164,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 +172,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 +258,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 +302,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 +310,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_capsule_features.py b/tests/test_capsule_features.py index 825eb52..7566bd7 100644 --- a/tests/test_capsule_features.py +++ b/tests/test_capsule_features.py @@ -1,11 +1,14 @@ from __future__ import annotations +import httpx +import pytest import respx -from wrenn.capsule import Capsule, _build_proxy_url -from wrenn.code_interpreter.models import Execution, ExecutionError, Logs, Result +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: @@ -26,13 +29,44 @@ 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" + + 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 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 +74,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 +82,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 +93,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 +102,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 +144,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" @@ -137,10 +181,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"} @@ -178,6 +223,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.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.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_client.py b/tests/test_client.py index 36adce9..3bc31ed 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" @@ -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_e2e.py b/tests/test_code_runner_e2e.py new file mode 100644 index 0000000..12cae7c --- /dev/null +++ b/tests/test_code_runner_e2e.py @@ -0,0 +1,521 @@ +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.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.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.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.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.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.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.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.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) + + +# ───────────────────────── build_execute_request ───────────────────────── + + +class TestJupyterRequest: + def test_structure(self): + 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)" + 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): + 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"] + + +# ───────────────────────── 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. + + 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 + 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: + nxt = next(self._iter) + except StopIteration: + raise TimeoutError("no more frames") + if isinstance(nxt, BaseException): + raise nxt + return nxt + + +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): + assert self._iter is not None + try: + nxt = next(self._iter) + except StopIteration: + raise TimeoutError("no more frames") + if isinstance(nxt, BaseException): + raise nxt + return nxt + + +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__() + + +# ───────────────────────── 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.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.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.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.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.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() 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 87941dd..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: @@ -57,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) @@ -76,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) @@ -91,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() @@ -104,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 @@ -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" diff --git a/uv.lock b/uv.lock index 2fd6a46..d35d4ae 100644 --- a/uv.lock +++ b/uv.lock @@ -1,8 +1,9 @@ version = 1 -revision = 2 +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]] @@ -1121,9 +1166,10 @@ wheels = [ [[package]] name = "wrenn" -version = "0.1.1" +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" },